Repository navigation
[simplex]: confirm a 7702 delegation on chain, sign it at the right nonce, never fall back after a sponsored op that may land - #1415
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 #1413.
These parts are right:
- Native set-code txs are sent at the
pendingnonce, with the authorization signed at nonce + 1, for delegate, batched approve and revoke. Every signer backend keeps the explicit nonce, including MPCVault's authorization-list path. - The delegation-code comparison and the empty-code check for a revoke are correct.
- The lock key is normalised and the lock is released whether the attempt succeeds or throws.
- The TOML watch-only map only rewrites the chain that changed.
The "never fall back after a sponsored op that may land" promise holds only within a single call, and a delegation that landed can still be reported as failed:
-
After a
pendingoutcome, the next attempt can still send a native delegation while the first op may land (DelegationService.ts:411-414, :635-642).- The op sits in the bundler pool past the 90 s receipt wait, so
SubmittedUnconfirmedErroris raised, the outcome ispending, and the call returns false. - The operator then clicks Enable fills again, creates a limit order, or restarts. The retry builds a new op on the same EntryPoint nonce, and the bundler rejects it as replacement-underpriced or already known.
trySendSponsoredreturnsnullfor that (as UserOpSender.ts:174 documents), the code reads it asunavailable, and a native set-code tx goes out. This is the c28/c36 pattern, one call later.- Suggestion: keep the unconfirmed op's hash, nonce and key in the module-level map per wallet and chain. On the next attempt, resolve that op first (look it up, or compare
getNoncewith the recorded nonce), and treatnullaspendingwhile it is unresolved.
- The op sits in the bundler pool past the 90 s receipt wait, so
-
A delegation that landed is reported as failed after one transient read (:303, :397-403, :440-456, :666).
- If
getCodeat the receipt's block errors (for example a load-balanced RPC lagging behind),confirmDelegationAtreturns false. - If the public RPC doesn't have the receipt within 30 s, the bundler path returns
failedwithout checkingisDelegated. - The final native fallback returns
trySendDelegation's result directly, while the batched path already falls back toisDelegated(:622). - Effects: Enable fills and adding a chain fail and roll back, and a boot with a single solver-selection chain shuts down. With #1410, the chain is also marked undelegated until someone manually clicks Enable fills.
- Suggestion: on a read error at the mined block or a receipt timeout, fall back to
isDelegated(chain)at the latest block, and add the same|| isDelegatedcheck at:666.
- If
-
Creating a same-asset limit order now delegates under watch-only (limit-orders.ts:259).
LimitOrderServicealways gets aDelegationServiceand knows nothing about watch-only. So under--watch-onlyor TOMLwatchOnly = true, creating a same-asset order now sends a set-code tx or a sponsored op.- That contradicts this PR's own rule that a watch-only run delegates nothing. In signerless mode, every same-asset create is refused instead.
- Partly a question of intent, but either refuse with the watch-only message, or only call
isDelegatedin that mode.
Nits:
- Errors thrown before submission but outside the preparation
try(getNonce, signing the UserOp) are classed asfailed. The comment at:416-419says a native tx could delegate twice there, but nothing was submitted. Harmless, since the caller retries. - The changelog repeats the flow doc's nonce, confirmation and lock text almost word for word. Keep the mechanics in the flow doc.
- Changelog line 27 says the native path "queues behind it at the same nonce". It queues at the next (
pending) nonce. - Changelog line 54 says the public API is unchanged, but
setupDelegationcan now reject on a read failure before anything is submitted.
Cross-PR: #1410's undelegatedChains set is filled from these results and never re-checked. With more results now coming back false (pending, failed reads at the mined block), a chain stuck as undelegated becomes a common outcome. See my comment on #1410.
- Remember a sponsored delegation op that never confirmed (userOpHash, nonce, key) per wallet and chain, and resolve it before the next attempt sends anything: a receipt confirms it on chain, a moved EntryPoint nonce with no delegation drops it, otherwise the attempt stays pending and never goes native. A PendingOpOnNonceError is pending too. - Fall back to isDelegated(latest) when the mined-block getCode read fails or the bundler path's receipt wait times out, and return `trySendDelegation(...) || isDelegated(chain)` from the final native fallback, so a delegation that landed is not reported as failed. - LimitOrderService takes a watch-only predicate (injected from boot.ts). On a watch-only chain create only checks isDelegated and refuses with the watch-only wording; it never calls setupDelegation. - Note: native path queues at the next (pending) nonce, setupDelegation can reject on a pre-submit read failure, and the mechanics now live in the flow doc instead of being repeated.
b363f9b to
27f06a3
Compare
- Remember a sponsored delegation op that never confirmed (userOpHash, nonce, key) per wallet and chain, and resolve it before the next attempt sends anything: a receipt confirms it on chain, a moved EntryPoint nonce with no delegation drops it, otherwise the attempt stays pending and never goes native. A PendingOpOnNonceError is pending too. - Fall back to isDelegated(latest) when the mined-block getCode read fails or the bundler path's receipt wait times out, and return `trySendDelegation(...) || isDelegated(chain)` from the final native fallback, so a delegation that landed is not reported as failed. - LimitOrderService takes a watch-only predicate (injected from boot.ts). On a watch-only chain create only checks isDelegated and refuses with the watch-only wording; it never calls setupDelegation. - Note: native path queues at the next (pending) nonce, setupDelegation can reject on a pre-submit read failure, and the mechanics now live in the flow doc instead of being repeated.
27f06a3 to
924c5d4
Compare
- Remember a sponsored delegation op that never confirmed (userOpHash, nonce, key) per wallet and chain, and resolve it before the next attempt sends anything: a receipt confirms it on chain, a moved EntryPoint nonce with no delegation drops it, otherwise the attempt stays pending and never goes native. A PendingOpOnNonceError is pending too. - Fall back to isDelegated(latest) when the mined-block getCode read fails or the bundler path's receipt wait times out, and return `trySendDelegation(...) || isDelegated(chain)` from the final native fallback, so a delegation that landed is not reported as failed. - LimitOrderService takes a watch-only predicate (injected from boot.ts). On a watch-only chain create only checks isDelegated and refuses with the watch-only wording; it never calls setupDelegation. - Note: native path queues at the next (pending) nonce, setupDelegation can reject on a pre-submit read failure, and the mechanics now live in the flow doc instead of being repeated.
924c5d4 to
7c787de
Compare
Wizdave97
left a comment
There was a problem hiding this comment.
Approved. All three round-1 findings are fixed:
- An unconfirmed op is remembered under the lock and resolved before any new attempt.
- A delegation that landed is confirmed by falling back to
isDelegated(latest). - On a watch-only chain, creating a limit order refuses instead of delegating.
Non-blocking follow-ups:
- PR description. It doesn't cover the unconfirmed-op record, the
isDelegatedfallbacks, or the watch-only refusal, which now also covers cross-asset orders. The test counts are from round 1. - Changelog line 44 ("Without this the record kept every retry refused until a restart…", link). It describes this PR's own earlier commit, so drop it. Lines 22-26 and 36-51 still repeat the flow doc.
- Two stale messages.
- The comment at DelegationService.ts:28-32 still says that after 30 s the delegation "is reported failed".
isDelegated(latest)decides now. - DelegationService.ts:726 logs "has not settled" for an op that did mine, but without delegating.
- The comment at DelegationService.ts:28-32 still says that after 30 s the delegation "is reported failed".
- Remember a sponsored delegation op that never confirmed (userOpHash, nonce, key) per wallet and chain, and resolve it before the next attempt sends anything: a receipt confirms it on chain, a moved EntryPoint nonce with no delegation drops it, otherwise the attempt stays pending and never goes native. A PendingOpOnNonceError is pending too. - Fall back to isDelegated(latest) when the mined-block getCode read fails or the bundler path's receipt wait times out, and return `trySendDelegation(...) || isDelegated(chain)` from the final native fallback, so a delegation that landed is not reported as failed. - LimitOrderService takes a watch-only predicate (injected from boot.ts). On a watch-only chain create only checks isDelegated and refuses with the watch-only wording; it never calls setupDelegation. - Note: native path queues at the next (pending) nonce, setupDelegation can reject on a pre-submit read failure, and the mechanics now live in the flow doc instead of being repeated.
7c787de to
fd1524b
Compare
…once, never fall back after a sponsored op that may land
- Remember a sponsored delegation op that never confirmed (userOpHash, nonce, key) per wallet and chain, and resolve it before the next attempt sends anything: a receipt confirms it on chain, a moved EntryPoint nonce with no delegation drops it, otherwise the attempt stays pending and never goes native. A PendingOpOnNonceError is pending too. - Fall back to isDelegated(latest) when the mined-block getCode read fails or the bundler path's receipt wait times out, and return `trySendDelegation(...) || isDelegated(chain)` from the final native fallback, so a delegation that landed is not reported as failed. - LimitOrderService takes a watch-only predicate (injected from boot.ts). On a watch-only chain create only checks isDelegated and refuses with the watch-only wording; it never calls setupDelegation. - Note: native path queues at the next (pending) nonce, setupDelegation can reject on a pre-submit read failure, and the mechanics now live in the flow doc instead of being repeated.
fd1524b to
d2e8e94
Compare
Stacked on #1413 (which is stacked on #1412 and #1408). Review only the commits above it.
This PR fixes EIP-7702 delegation findings from the 2026-10-15 simplex audit: c09, c28, c36 and c59, plus two round-2 lows (delegation trusted from a receipt alone, and same-asset orders skipping the check). It also fixes one problem the audit missed: the sponsored path signed its authorization at a stale nonce.
Why. simplex could believe the wallet was delegated to the SolverAccount when it wasn't. "Enable fills" would then clear watch-only, and the chain would bid from an EOA with no code, paying Hyperbridge fees for bids that can never execute. The causes were:
latest + 1while viem sent the tx atpending. With any wallet tx in flight, EIP-7702 skipped the tuple without reverting, the tx still mined, and success was read from the receipt alone.DelegationServiceand the limit-order service's could both delegate at once. The second op hit a duplicate nonce, was read as "never submitted", and fell back to native.--watch-only, "Enable fills" still delegated and cleared the chain's flag. With a per-chain TOMLwatchOnlymap, any watch-only edit wrote the override's every-chain snapshot back, so one--watch-onlyrun became permanent.Fix (
src/services/DelegationService.ts,src/services/UserOpSender.ts,src/simplex.ts,src/orderbook/limit-orders.ts,src/core/boot.ts):pending, send with it explicitly, and sign the tuple atnonce + 1. Every signer backend carries the explicit nonce, including MPCVault's 7702 path.pending > latest), because the tuple would go stale.trySendSponsoredreports that as never submitted, so the attempt falls to the native path, which queues behind the tx at thependingnonce.isDelegated(latest)decides, so a delegation that landed is not reported as failed;isDelegatedanswersfalsewhenlatestcannot be read either. The final native fallback likewise returnstrySendDelegation(...) || isDelegated(chain).setupDelegationViaBundlerreturnsdelegated,unavailable,pendingorfailed. Onlyunavailable(never submitted, or no config) falls back to the direct tx.SubmittedUnconfirmedErroror aPendingOpOnNonceErrorispending; a reverted op or any other throw isfailed. Either is upgraded todelegatedif the chain shows the delegation.pendingop is recorded in memory per wallet and chain with itsuserOpHash, EntryPointnonce,nonceKeyandsubmittedAt.setupDelegationresolves the record first, before the batched native path or any new op, so a retry never builds a second op on its nonce.UserOpSender.lookupUserOpReceiptis confirmed on chain:delegated, orfailedif the op mined without delegating.latestpast the stored one, with no delegation, means the op is gone and the record is dropped.UserOpSender.isUserOpKnown(eth_getUserOperationByHash, answered by rundler from its pool and then the chain) gets a definitenull. A bundler that evicts an op never moves the nonce, so without this the record would refuse every retry until a restart. A lookup or read that fails or is ambiguous leaves the recordpending. A wrong drop costs at most one set-code tx's gas.DelegationServiceinstance. Concurrent setups share one attempt, and a revoke queues behind it.--watch-onlyor TOMLsimplex.watchOnly = true):setWatchOnly(false)and adding a chain withwatchOnly: falseare refused, and the message names both causes.LimitOrderService.create). Same-asset orders now need a delegated fill chain too, since they price bids even though they are never posted. On a watch-only chain (read live from the filler viaboot.ts), an order of either kind only checksisDelegated: an undelegated solver is refused with the watch-only wording, andsetupDelegationis never called, so creating an order cannot send a set-code tx or a sponsored op..github/workflows/test-simplex-orderbook.ymlnow runs againstpolytopelabs/hyperfx-orderbook:v0.4.13.Behaviour changes:
watchOnly = true, "Enable fills" is now refused. Before, it delegated and cleared the flag until restart.setupDelegationreject, so the caller retries, rather than report failure.Docs:
docs/ai/changelog/2026-10-16-delegation-confirmed-on-chain.md, with the mechanics indocs/ai/flows/signing-from-construction-to-each-signature.md.Validation
DelegationService.ordering.test.ts: 47 tests. Its chain mock models EIP-7702 tuple skipping: a delegation applies only when the tuple nonce is valid. Coverage:false;SubmittedUnconfirmedError,PendingOpOnNonceErrorand a reverted op, none sending a native tx;nullafter the grace period; keptpendinginside the grace period and on a failed lookup; cleared by a confirmed revoke; the pending and mined-without-delegating log messages;nullfalling back as before;UserOpSender.test.ts37 (includingisUserOpKnownand a throwing authorization factory resolvingnull),enable-fills.test.ts7 andchain-lifecycle.test.ts34 (the watch-only refusals and the TOML invariant),limit-orders.test.ts74 (same-asset and watch-only refusals),ui-server.test.ts70,token-sender.test.ts31.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: