Repository navigation
[simplex]: never resend natively after an ambiguous bundler send, report unconfirmed operator sends as pending - #1413
Conversation
Wizdave97
left a comment
There was a problem hiding this comment.
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
paymasterAndDatanever ends in the0x22e325a297439656paymaster-signature suffix, so v0.9 hashes the whole field. initCodeis always0x.- 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.
-
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
ReplacementUnderpricedandOperationAlreadyKnownto -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.
nullis returned andTokenSendersends 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.
- Rundler maps
-
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 isunknown, andSubmittedUnconfirmedErroris 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
unknownflag so the message says "may have been submitted", and note the outage behaviour in the changelog.
- With rundler down behind
-
The first bundler call of every send still has no timeout.
getGasPrice→rundlerUserOperationFees→ the SDK's barefetch(rundlerFees.ts:29). The PR's own scenario, a gateway that accepts the connection and never answers, still leavestrySendSponsoredhanging there. Pass anAbortSignal.timeoutthrough. -
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
txHashasundefinedon 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
PrecheckFaileduses -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.
- 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.
65f1fc7 to
942a40a
Compare
- 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.
942a40a to
529ec46
Compare
- 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.
529ec46 to
c39dac6
Compare
Wizdave97
left a comment
There was a problem hiding this comment.
Requesting changes for one new issue. The four round-1 findings are fixed:
- The -32602 detection matches rundler's
replacement underpricedandalready knownerrors. - The outage message now says the send may have been submitted.
- The gas-price call has a timeout.
wallet.mdxdocuments the 202 response.
Must fix
- 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
onTxand 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
PendingOpOnNonceErrordoesn'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_poolis unset by default), every key-0 sponsored op getsPendingOpOnNonceErroruntil 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.
maybeSubmitteddescription. 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.
- 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.
c39dac6 to
8edc59b
Compare
…ort unconfirmed operator sends as pending
- 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.
8edc59b to
811fe28
Compare
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:
UserOpSendercalled the bundler with a barefetch, with no timeout and no check of the HTTP status. Every failure ofeth_sendUserOperationwas treated as "never submitted" and returnednull.TokenSender.submitCallsthen sent the same calls again as a native tx.-32602, the code of a malformed request. It was read as a rejection and sent natively, while the earlier op could still land./api/sendreturned 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):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 asECONNREFUSED,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 emptyresult.rejectedandunreachableallow the native fallback.-32602whose error data hascurrentMaxFee/currentMaxPriorityFee, or whose message says "replacement underpriced" or "already known", throwsPendingOpOnNonceError(chain,nonce,nonceKey, andblockingFeeswhen rundler sent fees). Nothing new was submitted, but the earlier op may still land, so the calls are never sent natively.blockingFeesholds rundler'scurrentMaxFee/currentMaxPriorityFeeas 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.null:TokenSenderdoes not catch it,POST /api/sendanswers it with 202, the vault sweep and redeem leave the batch for the next cycle, andDelegationServicereturnsfalsewithout a native delegation tx.validUntil. If it sits in rundler's pool (max_time_in_poolis unset by default), every key-0 sponsored op gets this error until rundler drops it.hashTypedData(packedUserOpTypedData(...)), the same typed data the op is signed with. For EntryPoint v0.8 this isgetUserOpHash, and it also holds for EIP-7702 ops, whoseinitCodestays0x.eth_getUserOperationByHashis polled at about 2 s, 7 s and 17 s:SubmittedUnconfirmedErrorif no receipt arrives);null. Rundler checks its mempool and then the chain, so the op never entered the pool, and the native fallback is allowed as before;SubmittedUnconfirmedErrorwithmaybeSubmitted = 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.computeUserOpHashappears only in a test, to show the two hashes agree.Pending operator sends (
UiServer.handleSend,ui/src/operator/WalletTools.tsx):SubmittedUnconfirmedErroror aPendingOpOnNonceErrorreturns 202{ pending, sponsored, userOpHash | txHash, message }. Other errors stay 400.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.txHash, and the message tells the operator to check it on the explorer before resending.Docs:
docs/content/developers/evm/simplex/api/wallet.mdx(the 202 response) anddocs/ai/changelog/2026-10-16-ambiguous-bundler-sends.md.Validation
UserOpSender.test.ts32,ui-server.test.ts70,token-sender.test.ts31,DelegationService.ordering.test.ts15 (DelegationService.test.tsis skipped),WalletTools.test.tsx3. They cover:ECONNREFUSED→null;-32603→ each goes to the lookup;SubmittedUnconfirmedErrorwith the local hash andmaybeSubmitted, and no native tx;-32602with fee data, with one fee field, and "already known" →PendingOpOnNonceError(withblockingFeeswhere sent), and no native tx; a plain-32602→null;computeUserOpHash, with and without 7702 auth;pnpm --filter="@hyperbridge/simplex":test:data146 passed,test:unit266 passed,test:filler70 passed (5 skipped).lintreports no errors (87 existing warnings).npx tsc --noEmit -p .insdk/packages/simplexshows only the 14 errors already on main (3 of them in theoxdependency); none is in a line this PR changed.Not covered:
DelegationServicestill falls back to a native delegation after an ambiguous sponsored op (audit c28, fixed in [simplex]: confirm a 7702 delegation on chain, sign it at the right nonce, never fall back after a sponsored op that may land #1415). It costs at most one redundant delegation tx.