Skip to content

[simplex]: never resend natively after an ambiguous bundler send, report unconfirmed operator sends as pending - #1413

Open
seunlanlege wants to merge 4 commits into
fix/simplex-top-up-placement-recoveryfrom
fix/simplex-ambiguous-bundler-send
Open

seunlanlege wants to merge 4 commits into
fix/simplex-top-up-placement-recoveryfrom
fix/simplex-ambiguous-bundler-send

Conversation

@seunlanlege

@seunlanlege seunlanlege commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Stacked on #1412, which is stacked on #1408. Review only the commits above it.

This PR fixes two audit findings from 2026-10-15: c07 (high), where a sponsored send could be paid twice, and c62 (low), where an unconfirmed operator send was reported as a failure.

Why:

  • UserOpSender called the bundler with a bare fetch, with no timeout and no check of the HTTP status. Every failure of eth_sendUserOperation was treated as "never submitted" and returned null. TokenSender.submitCalls then sent the same calls again as a native tx.
  • So a 504, a dropped socket or a timeout after the bundler had accepted the op meant a dashboard send could be paid twice. A top-up placement or a vault batch could also run twice.
  • A resent op that rundler refuses because an earlier op still holds the nonce comes back as -32602, the code of a malformed request. It was read as a rejection and sent natively, while the earlier op could still land.
  • Separately, /api/send returned 400 for an op that was submitted but not yet confirmed. The dashboard kept the amount filled in, which invited a resend.

Fix (src/services/UserOpSender.ts, src/services/submission.ts):

  • Bounded, classified bundler calls.
    • Send and gas estimation time out after 30 s; receipt and lookup polls after 10 s. The receipt wait has a 90 s deadline overall.
    • Every failure is sorted into one of four cases:
      • rejected: HTTP 200 with a deterministic JSON-RPC rejection code (-32700, -32600…-32602, or the ERC-4337 range -32599…-32500).
      • unreachable: a connect-phase failure such as ECONNREFUSED, ENOTFOUND, a connect timeout or a TLS failure, so no request bytes were sent.
      • pending: see below.
      • ambiguous: everything else. That covers timeouts, connection resets, a non-JSON body, any non-200 status (including a proxy's JSON 502/504), -32603, a missing code, and an empty result.
    • Only rejected and unreachable allow the native fallback.
  • An op already pending on the nonce is not a rejection.
    • A -32602 whose error data has currentMaxFee/currentMaxPriorityFee, or whose message says "replacement underpriced" or "already known", throws PendingOpOnNonceError (chain, nonce, nonceKey, and blockingFees when rundler sent fees). Nothing new was submitted, but the earlier op may still land, so the calls are never sent natively.
    • blockingFees holds rundler's currentMaxFee/currentMaxPriorityFee as the hex strings it sent. They are also in the error message and the warning log, so an operator can see what is in the way. "Already known" carries none.
    • The error reaches the callers instead of becoming null: TokenSender does not catch it, POST /api/send answers it with 202, the vault sweep and redeem leave the batch for the next cycle, and DelegationService returns false without a native delegation tx.
    • 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 this error until rundler drops it.
    • Detection recognises rundler's wording only, because production runs rundler. Alto's "already present in mempool" would read as a rejection.
  • An ambiguous send is resolved by lookup, never assumed.
    • The UserOp hash is computed locally as hashTypedData(packedUserOpTypedData(...)), the same typed data the op is signed with. For EntryPoint v0.8 this is getUserOpHash, and it also holds for EIP-7702 ops, whose initCode stays 0x.
    • Then eth_getUserOperationByHash is polled at about 2 s, 7 s and 17 s:
      • found → it is handled as a normal sponsored send (receipt wait, and SubmittedUnconfirmedError if no receipt arrives);
      • null on every attempt → null. Rundler checks its mempool and then the chain, so the op never entered the pool, and the native fallback is allowed as before;
      • no attempt found the op and at least one failed → SubmittedUnconfirmedError with maybeSubmitted = true, carrying the local hash, nonce, key and permit expiry, with no native fallback. [simplex]: recover a submitted BRIDGE top-up placement from on-chain evidence and retry transient tracking errors #1412's top-up recovery consumes this error unchanged.
  • No new sdk API is used. computeUserOpHash appears only in a test, to show the two hashes agree.

Pending operator sends (UiServer.handleSend, ui/src/operator/WalletTools.tsx):

  • A SubmittedUnconfirmedError or a PendingOpOnNonceError returns 202 { pending, sponsored, userOpHash | txHash, message }. Other errors stay 400.
  • Sponsored send. The response has userOpHash, and the message names it and says to look it up on the bundler, or compare the wallet's and the recipient's balance, before resending. A sponsored send that answers 202 never gets a wallet-history row, because a UserOp has no tx hash until it lands, so history is not evidence. When no attempt found the op and at least one lookup failed (maybeSubmitted, which is what a bundler outage looks like), the message says the send may have been submitted.
  • Native send. The tx hash is known, so the send is recorded in the wallet history like a confirmed one, the response has txHash, and the message tells the operator to check it on the explorer before resending.
  • Earlier op still pending. The response has no hash and says an earlier sponsored operation from this wallet is still pending and to wait for it to land. Nothing is recorded in the wallet history.
  • The dashboard shows the message as a warning in place of "Sent", clears the amount, and refreshes balances. A native tx hash links to the explorer; a UserOp hash is plain text.

Docs: docs/content/developers/evm/simplex/api/wallet.mdx (the 202 response) and docs/ai/changelog/2026-10-16-ambiguous-bundler-sends.md.

Validation

  • Touched tests: UserOpSender.test.ts 32, ui-server.test.ts 70, token-sender.test.ts 31, DelegationService.ordering.test.ts 15 (DelegationService.test.ts is skipped), WalletTools.test.tsx 3. They cover:
    • a JSON-RPC rejection and ECONNREFUSED → null;
    • a 504 HTML page, a 502 with a JSON error body, and a 200 with -32603 → each goes to the lookup;
    • found → receipt; null on every attempt → native fallback;
    • a lookup failure → SubmittedUnconfirmedError with the local hash and maybeSubmitted, and no native tx;
    • -32602 with fee data, with one fee field, and "already known" → PendingOpOnNonceError (with blockingFees where sent), and no native tx; a plain -32602 → null;
    • the local hash equals computeUserOpHash, with and without 7702 auth;
    • the 202 responses for sponsored (naming the UserOp hash, not the wallet history), possibly-submitted, pending-earlier-op and native sends, with the history rows;
    • the pending UI.
  • pnpm --filter="@hyperbridge/simplex": test:data 146 passed, test:unit 266 passed, test:filler 70 passed (5 skipped). lint reports no errors (87 existing warnings).
  • npx tsc --noEmit -p . in sdk/packages/simplex shows only the 14 errors already on main (3 of them in the ox dependency); none is in a line this PR changed.

Not covered:

@Wizdave97 Wizdave97 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: approve with minor comments. I reviewed only this PR's commit on top of #1412.

The local UserOp hash is correct for the chains simplex runs on:

  • The EIP-712 domain is the same in v0.9.
  • Simplex's mode-0/mode-2 paymasterAndData never ends in the 0x22e325a297439656 paymaster-signature suffix, so v0.9 hashes the whole field.
  • initCode is always 0x.
  • The hash and the signature use the same configured EntryPoint, so they can't diverge.

The unreachable errors really are connect-phase failures, and a reset mid-request falls through to ambiguous.

  1. A resend on the same EntryPoint nonce is read as a rejection, so the native fallback can still pay twice (UserOpSender.ts:154-159, used at :628).

    • Rundler maps ReplacementUnderpriced and OperationAlreadyKnown to -32602.
    • Scenario: an operator send returns 202 because the op was found but has no receipt and is still in the pool. The operator follows the message, sees an unchanged balance, and resends. The new op on the same nonce gets -32602, which is read as a rejection. null is returned and TokenSender sends natively. When the first op lands, the recipient is paid twice.
    • VaultFundingPlanner's retry on the next cycle has the same exposure.
    • Suggestion: when the -32602 error carries rundler's replacement data (currentMaxPriorityFee/currentMaxFee) or says "already known", treat it as "an op is already pending on this nonce" and throw instead of falling back.
    • Also reword the sponsored 202 message (UiServer.ts:2091): a balance check can't reveal an op that is still in the pool.
  2. A bundler outage behind the proxy now blocks every sponsored send and reports it as "submitted" (UserOpSender.ts:677).

    • With rundler down behind bundler.polytope.technology, the send and every lookup return 502. The outcome is unknown, and SubmittedUnconfirmedError is thrown.
    • For the whole outage, operator sends return 202 "The send was submitted to the bundler" for an op that never reached it. Top-up placements wait out PLACEMENT_WINDOW_MS (about 70 min), and vault batches stall.
    • Failing safe is defensible, but the PR description doesn't mention this change in behaviour.
    • Suggestion: add an unknown flag so the message says "may have been submitted", and note the outage behaviour in the changelog.
  3. The first bundler call of every send still has no timeout. getGasPrice → rundlerUserOperationFees → the SDK's bare fetch (rundlerFees.ts:29). The PR's own scenario, a gateway that accepts the connection and never answers, still leaves trySendSponsored hanging there. Pass an AbortSignal.timeout through.

  4. The public API docs still describe only 200 and 400 (wallet.mdx:149).

    • They say a send answers 200 or 400 and that a successful send is recorded in history.
    • A script that treats any 2xx as success reads txHash as undefined on a pending sponsored send.
    • Please document the 202 shape: { pending, sponsored, userOpHash | txHash, message }.
    • Also, a pending native send has a tx hash but writes no history row, so if it later confirms it never shows up in history. The "no tx hash yet" reason only applies to UserOps.

Minor:

  • Rundler's PrecheckFailed uses -32000, which counts as ambiguous. So every precheck failure (for example a pre-verification gas floor on an L2) now takes about 17 s of lookups before falling back. That's latency, not a correctness problem.
  • "Null on every lookup means it was never submitted" assumes every request reaches the same rundler pool. If the proxy load-balances across replicas with separate mempools, that doesn't hold. Worth confirming for the deployment.
  • paymaster-selection-for-a-sponsored-userop.md:16 says every bundler call is bounded by 10 s, and that any JSON-RPC error means "not submitted". In the code, send and estimate are 30 s, the gas-price call is unbounded, and only an HTTP 200 carrying a deterministic code counts as not submitted.
  • The changelog says "the v0.8 EIP-712 digest" for chains that run v0.9. One line saying why it equals v0.9's hash (no paymaster-signature suffix) would help.

Cross-PR: the new unknown outcome makes unconfirmed UserOp errors more frequent. The paid second reclaim cancel that #1412 lists as uncovered will therefore happen more often, and with (1) that second cancel can also go out natively.

seunlanlege added a commit that referenced this pull request Oct 19, 2026
- A bundler -32602 that says an op is already pending on the nonce
  (replacement underpriced / already known) throws PendingOpOnNonceError
  instead of reading as a rejection, so no caller falls back to a native
  send. /api/send answers 202 with a wait message, the vault sweep stalls
  until the next cycle, and delegation setup returns false without a
  native delegation tx. A plain -32602 invalid-params is still `null`.
- Reword the sponsored 202 message: the op may still land, wait before
  resending. A lost-answer outcome (`unknown`) says the send may have
  been submitted, via SubmittedUnconfirmedError.maybeSubmitted.
- Bound the gas-price lookup at 10 s; a timeout is never-submitted.
- Record a wallet-history row for a pending native send (it has a tx hash).
- Docs: 202 shape and pending-on-nonce answer in wallet.mdx, corrected
  timeouts in the paymaster flow doc, outage behaviour and v0.9 hash
  note in the changelog note.
seunlanlege added a commit that referenced this pull request Oct 19, 2026
@seunlanlege
seunlanlege force-pushed the fix/simplex-ambiguous-bundler-send branch from 65f1fc7 to 942a40a Compare October 19, 2026 13:45
seunlanlege added a commit that referenced this pull request Oct 19, 2026
- A bundler -32602 that says an op is already pending on the nonce
  (replacement underpriced / already known) throws PendingOpOnNonceError
  instead of reading as a rejection, so no caller falls back to a native
  send. /api/send answers 202 with a wait message, the vault sweep stalls
  until the next cycle, and delegation setup returns false without a
  native delegation tx. A plain -32602 invalid-params is still `null`.
- Reword the sponsored 202 message: the op may still land, wait before
  resending. A lost-answer outcome (`unknown`) says the send may have
  been submitted, via SubmittedUnconfirmedError.maybeSubmitted.
- Bound the gas-price lookup at 10 s; a timeout is never-submitted.
- Record a wallet-history row for a pending native send (it has a tx hash).
- Docs: 202 shape and pending-on-nonce answer in wallet.mdx, corrected
  timeouts in the paymaster flow doc, outage behaviour and v0.9 hash
  note in the changelog note.
seunlanlege added a commit that referenced this pull request Oct 19, 2026
@seunlanlege
seunlanlege force-pushed the fix/simplex-ambiguous-bundler-send branch from 942a40a to 529ec46 Compare October 19, 2026 13:53
seunlanlege added a commit that referenced this pull request Oct 19, 2026
- A bundler -32602 that says an op is already pending on the nonce
  (replacement underpriced / already known) throws PendingOpOnNonceError
  instead of reading as a rejection, so no caller falls back to a native
  send. /api/send answers 202 with a wait message, the vault sweep stalls
  until the next cycle, and delegation setup returns false without a
  native delegation tx. A plain -32602 invalid-params is still `null`.
- Reword the sponsored 202 message: the op may still land, wait before
  resending. A lost-answer outcome (`unknown`) says the send may have
  been submitted, via SubmittedUnconfirmedError.maybeSubmitted.
- Bound the gas-price lookup at 10 s; a timeout is never-submitted.
- Record a wallet-history row for a pending native send (it has a tx hash).
- Docs: 202 shape and pending-on-nonce answer in wallet.mdx, corrected
  timeouts in the paymaster flow doc, outage behaviour and v0.9 hash
  note in the changelog note.
seunlanlege added a commit that referenced this pull request Oct 19, 2026
@seunlanlege
seunlanlege force-pushed the fix/simplex-ambiguous-bundler-send branch from 529ec46 to c39dac6 Compare October 19, 2026 15:08

@Wizdave97 Wizdave97 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes for one new issue. The four round-1 findings are fixed:

  • The -32602 detection matches rundler's replacement underpriced and already known errors.
  • The outage message now says the send may have been submitted.
  • The gas-price call has a timeout.
  • wallet.mdx documents the 202 response.

Must fix

  1. The sponsored 202 message points to a check that always looks unsent (UiServer.ts:2111-2112; the example is at wallet.mdx:160).
    • Both variants say "wait a few minutes and check the wallet history before resending it".
    • But a sponsored send never gets a history row; the comment just above the message says so. The only other writers are the vault onTx and the ledger backfill, which only fills in amounts on existing vault rows.
    • An operator who follows the message sees nothing, resends after the first op lands, and pays the recipient twice. The nonce has moved by then, so PendingOpOnNonceError doesn't fire.
    • Suggested fix: point the message at the wallet balance or the UserOp hash, or write the history row once the op's receipt is found.

Non-blocking:

  • A stuck bootstrap op blocks sponsored sends. 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. Worth stating in the changelog, or logging the conflicting fees from the error data.
  • Rundler-only detection. It only recognises rundler's wording. Alto's "already present in mempool" would be read as a rejection, though production can't reach Alto. A comment saying so would help.
  • maybeSubmitted description. It is described as "every lookup failed" (submission.ts:20, wallet.mdx:175). The code sets it when no attempt found the op and any attempt failed.
  • PR description. It still says nothing is recorded in history for native sends. It doesn't mention PendingOpOnNonceError, maybeSubmitted, or the outage behaviour.

seunlanlege added a commit that referenced this pull request Oct 20, 2026
- A bundler -32602 that says an op is already pending on the nonce
  (replacement underpriced / already known) throws PendingOpOnNonceError
  instead of reading as a rejection, so no caller falls back to a native
  send. /api/send answers 202 with a wait message, the vault sweep stalls
  until the next cycle, and delegation setup returns false without a
  native delegation tx. A plain -32602 invalid-params is still `null`.
- Reword the sponsored 202 message: the op may still land, wait before
  resending. A lost-answer outcome (`unknown`) says the send may have
  been submitted, via SubmittedUnconfirmedError.maybeSubmitted.
- Bound the gas-price lookup at 10 s; a timeout is never-submitted.
- Record a wallet-history row for a pending native send (it has a tx hash).
- Docs: 202 shape and pending-on-nonce answer in wallet.mdx, corrected
  timeouts in the paymaster flow doc, outage behaviour and v0.9 hash
  note in the changelog note.
@seunlanlege
seunlanlege force-pushed the fix/simplex-ambiguous-bundler-send branch from c39dac6 to 8edc59b Compare October 20, 2026 11:54
seunlanlege added a commit that referenced this pull request Oct 20, 2026
seunlanlege added a commit that referenced this pull request Oct 20, 2026
- A bundler -32602 that says an op is already pending on the nonce
  (replacement underpriced / already known) throws PendingOpOnNonceError
  instead of reading as a rejection, so no caller falls back to a native
  send. /api/send answers 202 with a wait message, the vault sweep stalls
  until the next cycle, and delegation setup returns false without a
  native delegation tx. A plain -32602 invalid-params is still `null`.
- Reword the sponsored 202 message: the op may still land, wait before
  resending. A lost-answer outcome (`unknown`) says the send may have
  been submitted, via SubmittedUnconfirmedError.maybeSubmitted.
- Bound the gas-price lookup at 10 s; a timeout is never-submitted.
- Record a wallet-history row for a pending native send (it has a tx hash).
- Docs: 202 shape and pending-on-nonce answer in wallet.mdx, corrected
  timeouts in the paymaster flow doc, outage behaviour and v0.9 hash
  note in the changelog note.
@seunlanlege
seunlanlege force-pushed the fix/simplex-ambiguous-bundler-send branch from 8edc59b to 811fe28 Compare October 20, 2026 12:35
@seunlanlege
seunlanlege requested a review from Wizdave97 October 21, 2026 09:57

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants