Skip to content

[simplex]: confirm a 7702 delegation on chain, sign it at the right nonce, never fall back after a sponsored op that may land - #1415

Open
seunlanlege wants to merge 5 commits into
fix/simplex-ambiguous-bundler-sendfrom
fix/simplex-delegation-correctness
Open

seunlanlege wants to merge 5 commits into
fix/simplex-ambiguous-bundler-sendfrom
fix/simplex-delegation-correctness

Conversation

@seunlanlege

@seunlanlege seunlanlege commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

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:

  • c09: the native set-code tx signed its authorization at latest + 1 while viem sent the tx at pending. 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.
  • Round-2: the sponsored path also trusted the receipt alone. Re-delegating away from an old SolverAccount "succeeded" while the wallet still pointed at the old one.
  • c28: a sponsored op that timed out or came back ambiguous was followed by a native delegation while the op could still land.
  • c36: the filler's DelegationService and 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.
  • c59: under --watch-only, "Enable fills" still delegated and cleared the chain's flag. With a per-chain TOML watchOnly map, any watch-only edit wrote the override's every-chain snapshot back, so one --watch-only run became permanent.

Fix (src/services/DelegationService.ts, src/services/UserOpSender.ts, src/simplex.ts, src/orderbook/limit-orders.ts, src/core/boot.ts):

  • Nonces.
    • Native set-code txs (delegate and revoke) read the nonce once at pending, send with it explicitly, and sign the tuple at nonce + 1. Every signer backend carries the explicit nonce, including MPCVault's 7702 path.
    • The sponsored path refuses to sign while a wallet tx is in flight (pending > latest), because the tuple would go stale. trySendSponsored reports that as never submitted, so the attempt falls to the native path, which queues behind the tx at the pending nonce.
  • Confirmed on chain.
    • A delegation counts only when the wallet's code at the receipt's block points at the configured SolverAccount. A revoke counts only when that code is empty. A read error at that block is never taken as proof.
    • The sponsored path waits up to 30 s for the bundle tx's receipt on the public RPC. When that wait or the block read fails, isDelegated(latest) decides, so a delegation that landed is not reported as failed; isDelegated answers false when latest cannot be read either. The final native fallback likewise returns trySendDelegation(...) || isDelegated(chain).
  • No native fallback after an op that may land.
    • setupDelegationViaBundler returns delegated, unavailable, pending or failed. Only unavailable (never submitted, or no config) falls back to the direct tx.
    • A SubmittedUnconfirmedError or a PendingOpOnNonceError is pending; a reverted op or any other throw is failed. Either is upgraded to delegated if the chain shows the delegation.
  • Unconfirmed sponsored ops are remembered.
    • A pending op is recorded in memory per wallet and chain with its userOpHash, EntryPoint nonce, nonceKey and submittedAt. setupDelegation resolves the record first, before the batched native path or any new op, so a retry never builds a second op on its nonce.
    • A receipt found through UserOpSender.lookupUserOpReceipt is confirmed on chain: delegated, or failed if the op mined without delegating.
    • An EntryPoint nonce at latest past the stored one, with no delegation, means the op is gone and the record is dropped.
    • Bundler drop detection. With the nonce unchanged, the op is also treated as gone when it is at least 2 minutes old and the new UserOpSender.isUserOpKnown (eth_getUserOperationByHash, answered by rundler from its pool and then the chain) gets a definite null. 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 record pending. A wrong drop costs at most one set-code tx's gas.
    • The record is also dropped once the wallet is seen delegated and once a revoke is confirmed.
    • A refused attempt logs whether the earlier op is still pending or mined without delegating the wallet.
  • One attempt at a time. A module-level lock keyed on wallet and chain covers every DelegationService instance. Concurrent setups share one attempt, and a revoke queues behind it.
  • Watch-only mode (--watch-only or TOML simplex.watchOnly = true):
    • "Enable fills", setWatchOnly(false) and adding a chain with watchOnly: false are refused, and the message names both causes.
    • The config sync never writes the override's snapshot. Under it, only the chain the operator added, removed or toggled is changed in the TOML map, and an unflagged add persists nothing.
  • Limit orders (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 via boot.ts), an order of either kind only checks isDelegated: an undelegated solver is refused with the watch-only wording, and setupDelegation is never called, so creating an order cannot send a set-code tx or a sponsored op.
  • .github/workflows/test-simplex-orderbook.yml now runs against polytopelabs/hyperfx-orderbook:v0.4.13.

Behaviour changes:

  • With TOML watchOnly = true, "Enable fills" is now refused. Before, it delegated and cleared the flag until restart.
  • While a wallet tx is in flight, a sponsored delegation waits.
  • A transient RPC failure before submitting now makes setupDelegation reject, so the caller retries, rather than report failure.
  • Creating a limit order on a watch-only chain never delegates; it is refused unless the solver is already delegated there.

Docs: docs/ai/changelog/2026-10-16-delegation-confirmed-on-chain.md, with the mechanics in docs/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:
    • explicit nonce and tuple nonce for delegate, batched approve and revoke;
    • in-flight refusal on the sponsored path;
    • a skipped tuple, and a receipt left on the old SolverAccount, both returning false;
    • a re-delegation that lands the new SolverAccount;
    • SubmittedUnconfirmedError, PendingOpOnNonceError and a reverted op, none sending a native tx;
    • the unconfirmed-op record: resolved by receipt, by a moved EntryPoint nonce, and by a definite bundler null after the grace period; kept pending inside the grace period and on a failed lookup; cleared by a confirmed revoke; the pending and mined-without-delegating log messages;
    • null falling back as before;
    • two instances sharing one attempt, and a revoke queued behind a setup.
  • UserOpSender.test.ts 37 (including isUserOpKnown and a throwing authorization factory resolving null), enable-fills.test.ts 7 and chain-lifecycle.test.ts 34 (the watch-only refusals and the TOML invariant), limit-orders.test.ts 74 (same-asset and watch-only refusals), ui-server.test.ts 70, token-sender.test.ts 31.
  • 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:

  • No mainnet or live-bundler run.
  • The dashboard can't yet show that a run is watch-only by override (existing behaviour); clicking "Enable fills" returns the refusal.

@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 #1413.

These parts are right:

  • Native set-code txs are sent at the pending nonce, 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:

  1. After a pending outcome, 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 SubmittedUnconfirmedError is raised, the outcome is pending, 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.
    • trySendSponsored returns null for that (as UserOpSender.ts:174 documents), the code reads it as unavailable, 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 getNonce with the recorded nonce), and treat null as pending while it is unresolved.
  2. A delegation that landed is reported as failed after one transient read (:303, :397-403, :440-456, :666).

    • If getCode at the receipt's block errors (for example a load-balanced RPC lagging behind), confirmDelegationAt returns false.
    • If the public RPC doesn't have the receipt within 30 s, the bundler path returns failed without checking isDelegated.
    • The final native fallback returns trySendDelegation's result directly, while the batched path already falls back to isDelegated (: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 || isDelegated check at :666.
  3. Creating a same-asset limit order now delegates under watch-only (limit-orders.ts:259).

    • LimitOrderService always gets a DelegationService and knows nothing about watch-only. So under --watch-only or TOML watchOnly = 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 isDelegated in that mode.

Nits:

  • Errors thrown before submission but outside the preparation try (getNonce, signing the UserOp) are classed as failed. The comment at :416-419 says 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 setupDelegation can 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.

seunlanlege added a commit that referenced this pull request Oct 19, 2026
- 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.
seunlanlege added a commit that referenced this pull request Oct 19, 2026
@seunlanlege
seunlanlege force-pushed the fix/simplex-delegation-correctness branch from b363f9b to 27f06a3 Compare October 19, 2026 13:45
seunlanlege added a commit that referenced this pull request Oct 19, 2026
- 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.
seunlanlege added a commit that referenced this pull request Oct 19, 2026
@seunlanlege
seunlanlege force-pushed the fix/simplex-delegation-correctness branch from 27f06a3 to 924c5d4 Compare October 19, 2026 13:53
seunlanlege added a commit that referenced this pull request Oct 19, 2026
- 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.
seunlanlege added a commit that referenced this pull request Oct 19, 2026
@seunlanlege
seunlanlege force-pushed the fix/simplex-delegation-correctness branch from 924c5d4 to 7c787de 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.

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 isDelegated fallbacks, 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.

seunlanlege added a commit that referenced this pull request Oct 20, 2026
- 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.
seunlanlege added a commit that referenced this pull request Oct 20, 2026
seunlanlege added a commit that referenced this pull request Oct 20, 2026
@seunlanlege
seunlanlege force-pushed the fix/simplex-delegation-correctness branch from 7c787de to fd1524b Compare October 20, 2026 11:55
…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.
@seunlanlege
seunlanlege force-pushed the fix/simplex-delegation-correctness branch from fd1524b to d2e8e94 Compare October 20, 2026 12:35

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