Skip to content

[simplex]: floor partial-fill bids, guard reconcile and overlapping rebalances, skip undelegated chains - #1410

Merged
seunlanlege merged 5 commits into
mainfrom
fix/simplex-filler-guards
Oct 21, 2026
Merged

seunlanlege merged 5 commits into
mainfrom
fix/simplex-filler-guards

Conversation

@seunlanlege

@seunlanlege seunlanlege commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

This PR makes four fixes on the filler side, from the 2026-10-15 simplex audit (c10, c05, c11, c20).

Reconcile can no longer undo a cancel (src/orderbook/limit-orders.ts)

  • Why: reconcile() wrote an under-funded entry's status back from a live() snapshot with no status guard. If a cancel() finished while reconcile was paging the orderbook, the order went back to open, and was then matched and reposted at the withdrawn price.
  • Fix: the write now checks the snapshot's own status (setStatus(id, status, reason, [status])), as every other posting write already does. The under-funded tally only counts writes that applied.

Minimum partial fill: new simplex.minPartialFillUsd, default 20, 0 disables (src/strategies/fx.ts)

  • Why: partial fills are exempt from the fee gate and the profit floor, but had no size floor at all. A nearly exhausted limit order, or a wallet just above its gas reserve, bid dust on every matching order and paid the fill gas, the relayer fee and the Hyperbridge bid fees for it.
  • Fix: a partial bid worth less than the floor is skipped.
    • When the leg is already known to be partial and even its maximum payout is under the floor, the skip happens before any balance or vault planning, so the bid holds no wallet room and reserves no vault liquidity.
    • Otherwise the check runs after sizing and before the gas estimate.
  • Valuation: the payout is priced through the limit-order USD factors. If that fails, it falls back to the order's input value pro rata for a single-leg order, or to this leg's input for a multi-leg one. A partial nothing can price is skipped.
  • Wiring: the key is handled like minOrderSizeUsd (TOML validation, getMinPartialFillUsd(), boot, emit-toml, the example config). The fee-gate and profit-floor exemptions for partials are unchanged.

Rebalance runs no longer overlap (src/core/filler.ts)

  • Why: the 5-minute loop started a new run while a CEX leg (up to 30 min) or a USDT0 leg (up to 15 min) was still in flight, and planned the same deficit again.
  • Fix: a tick is skipped while a run is in progress.
  • Not fixed here: after a timeout, error or restart, money still in transit is planned again. That needs persisted in-flight transfer accounting.

Chains whose delegation failed stop bidding (src/core/filler.ts)

  • Why: such a chain kept bidding, because the bundler estimate injects SolverAccount code by state override. It paid Hyperbridge bid fees and deposits for bids that could never execute.
  • Fix: those chains are tracked in memory (undelegatedChains), and orders to them are skipped at intake with orderSkipped { reason: "undelegated" }. Nothing is written to simplex.watchOnly.
  • When a chain leaves the set: a later successful delegation (enableFills, addChain) clears it. So do removeChain, an add rollback, and enableFills on a chain without solver selection.
  • Re-check at intake: a marked chain can be stale (a delegation that mined after its receipt timeout, or a failed getCode), so intake re-reads isDelegated for it at most once per 60 s (DELEGATION_RECHECK_MS) and drops the mark when the wallet is delegated. The read in flight is held per chain: orders that arrive while it runs wait for its answer instead of being skipped on the old mark. A failed read counts as not delegated.
  • Turning watch-only off (ChainController.setWatchOnly(chainId, false) → IntentFiller.guardDelegation): a chain that was watch-only at boot was never delegated or marked, so on a solver-selection chain it reads isDelegated and marks the chain when the wallet is not delegated.
    • That read of solver selection is live for such a chain, and it fills the shared cache that intake needs to evaluate the chain's orders.
    • If the read fails, guardDelegation throws Could not read solver selection on EVM-<id>, so it stays watch-only: <reason>. setWatchOnly(false) rejects before the flag flips or anything is saved, so the chain stays watch-only and the call can be retried. The dashboard's "Enable fills" (POST /api/chains/enable-fills) reads solver selection the same way and answers a failed read with a 409 carrying the message.
    • An order that still reaches intake with no cached solver-selection answer is skipped with orderSkipped { reason: "Solver selection not read for the destination chain" } (and the existing error log) instead of being dropped silently.

Behaviour change: operators whose partial fills are worth under $20 stop bidding them until they set minPartialFillUsd = 0. A partial that nothing can price is skipped too, so the partials of same-asset books (CNGN/CNGN, EURC/EURC) and of books with no path to a dollar stable (a ZARP/CNGN book on its own) are all skipped by default. Undelegated chains are re-checked at most every 60 s, so a chain whose delegation failed at boot comes back on its own once the wallet is delegated.

CI:

Docs: one note, docs/ai/changelog/2026-10-16-minimum-partial-fill-and-filler-guards.md, and the watch-only intake flow (docs/ai/flows/watch-only-orders-in-the-activity-feed.md).

Validation

Run in sdk/packages/simplex at the PR head:

  • Touched tests: undelegated-chains and enable-fills, 24 passed. They cover a failed solver-selection read (guardDelegation rejects, the chain stays watch-only and unsaved), a successful read of true (the cache is filled and the order is evaluated, not dropped), an order reaching intake with an empty cache (skip event), and concurrent orders awaiting one re-check (both the cleared and the still-undelegated outcome).
  • CI's test job commands: simplex lint exit 0 (warnings only); test:data 146 passed; test:unit 261 passed; test:filler 70 passed, 5 skipped.
  • Core, data, orderbook and services suites (vitest --maxConcurrency=1 src/tests/core src/tests/data src/tests/orderbook src/tests/services): 741 passed, 4 skipped.
  • Typecheck: tsc shows the 14 errors already on main, none in changed files.

Not covered: no mainnet run.

@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: changes needed before merge. The fix is small.

These parts are clean:

  • The partial-fill floor is wired consistently: ?? throughout, validation >= 0, and emit-toml writes the key whenever it is set, so 0 round-trips.
  • The cheap pass before planning can't skip a leg that would be a full fill.
  • The rebalance overlap guard always clears.
  • The reconcile() status guard closes the cancel race.

Must fix

  1. An undelegated mark never clears by itself, and the dashboard can't clear it once the delegation has landed (filler.ts:364, :812, Chains.tsx:61-63).
    • Scenario: at boot, setupDelegation returns false or throws, for example after a getCode error, or when the delegation tx mines after the 180 s receipt timeout. The wallet is in fact delegated on chain, or soon will be.
    • The UI switch reads checked = funding.delegated from an on-chain getCode and is disabled when checked. So it shows "Fills enabled", and the remedy the PR describes, clicking Enable fills, isn't available.
    • Every order to that chain is then skipped as undelegated until a restart. Before this PR, that chain filled normally.
    • #1415 makes this common, because sponsored ops that end pending and transient errors before submission now also return false.
    • Suggestion: at intake, re-check delegationService.isDelegated(chain) for marked chains (rate-limited; one getCode), and clear the mark when it's true. That is the at-use check #1411 already does. Alternatively, put the filler's mark in the balances data so the switch stays clickable.

Should fix

  1. Partial fills the operator's own book can't price are now skipped by default, and the behaviour-change note doesn't say so (fx.ts:625-637).

    • limitOrderUsdEdges drops same-asset orders. An operator whose markets have no path to USDC/USDT/DAI therefore silently loses every partial fill, not just those under $20.
    • Examples: same-asset CNGN/CNGN or EURC/EURC books, which limit-orders.mdx documents, or a ZARP/CNGN book on its own.
    • The fallbacks only help when a registry alias names the token differently.
    • Unpriceable legs are also deferred until after planWithdrawalForToken, which consume()s a 60 s vault reservation for a bid that is then skipped. #1421 removes reservations, which makes that part moot, and the nearby comment about leaving venue reservations for later candidates is inaccurate.
    • Suggestion: name the unpriceable case in the behaviour change and the public docs, and skip known-partial unpriceable legs in the cheap pass.
  2. setWatchOnly(false) gets around the undelegated guard (simplex.ts:584-600).

    • A solver-selection chain that is watch-only at boot is never delegated and never marked. chains.setWatchOnly(id, false) then switches it to filling from a wallet with no code.
    • This predates the PR, but it is exactly the case the PR targets.
    • Suggestion: mark such chains at boot (cleared by enableFills), or have setWatchOnly(false) check isDelegated.

Nit: reconcile()'s second loop can still reach an unguarded write: repost() → retire() → setPosting({ status: "expired" }) (limit-orders.ts:685 → :975). That can turn a just-cancelled or filled row into expired. Both statuses are terminal, so nothing re-opens, but passing POSTABLE there closes it.

Docs:

  • docs/content/developers/evm/simplex/configuration.mdx documents minOrderSizeUsd but not minPartialFillUsd, even though the new default changes behaviour.
  • troubleshooting.mdx doesn't list the new partial-floor or undelegated skip reasons.

seunlanlege added a commit that referenced this pull request Oct 19, 2026
- Intake re-reads isDelegated for an undelegated-marked chain (at most once per
  60 s per chain) and clears a stale mark.
- Partials nothing can price in USD are refused in the cheap pre-planning pass;
  the venue-reservation comment is corrected. Document minPartialFillUsd and the
  same-asset/no-dollar-path behaviour change.
- Turning watch-only off on a solver-selection chain checks delegation and marks
  the chain undelegated through IntentFiller.guardDelegation.
- retire() writes "expired" under the POSTABLE guard.
- Troubleshooting lists the partial-floor and undelegated skip reasons.
seunlanlege added a commit that referenced this pull request Oct 19, 2026
- executeOrder skips a cached bid plan whose limit order is no longer
  open/resizing, and re-checks after the bid row is stored: a bid that went
  out while its order closed is retracted at once (just that bid).
- Completion is recorded in the two EventMonitor listeners, ahead of the
  settlement queue, for complete fills only, so the bid loop stops while
  settlement is queued behind reconcile or orderbook work.
- Fill attribution follows the escrow evidence: live bids whose take covers
  the release, then retracted bids in the window, before the payout and
  first-bid fallbacks.
- A retracted bid's window is counted back from when the fill event arrived,
  not from settlement; the margin is RETRACTED_BID_EXECUTION_MARGIN_SECONDS (120).
- Note: the BidStore `rows` filter is stated as a breaking change for custom stores.
- Merge: retire()'s POSTABLE guard matches #1410's identical change; the
  rebase test fixtures follow the fill's block number from #1421.
seunlanlege added a commit that referenced this pull request Oct 19, 2026
seunlanlege added a commit that referenced this pull request Oct 19, 2026
- executeOrder skips a cached bid plan whose limit order is no longer
  open/resizing, and re-checks after the bid row is stored: a bid that went
  out while its order closed is retracted at once (just that bid).
- Completion is recorded in the two EventMonitor listeners, ahead of the
  settlement queue, for complete fills only, so the bid loop stops while
  settlement is queued behind reconcile or orderbook work.
- Fill attribution follows the escrow evidence: live bids whose take covers
  the release, then retracted bids in the window, before the payout and
  first-bid fallbacks.
- A retracted bid's window is counted back from when the fill event arrived,
  not from settlement; the margin is RETRACTED_BID_EXECUTION_MARGIN_SECONDS (120).
- Note: the BidStore `rows` filter is stated as a breaking change for custom stores.
- Merge: retire()'s POSTABLE guard matches #1410's identical change; the
  rebase test fixtures follow the fill's block number from #1421.
seunlanlege added a commit that referenced this pull request Oct 19, 2026
- Intake re-reads isDelegated for an undelegated-marked chain (at most once per
  60 s per chain) and clears a stale mark.
- Partials nothing can price in USD are refused in the cheap pre-planning pass;
  the venue-reservation comment is corrected. Document minPartialFillUsd and the
  same-asset/no-dollar-path behaviour change.
- Turning watch-only off on a solver-selection chain checks delegation and marks
  the chain undelegated through IntentFiller.guardDelegation.
- retire() writes "expired" under the POSTABLE guard.
- Troubleshooting lists the partial-floor and undelegated skip reasons.
seunlanlege added a commit that referenced this pull request Oct 19, 2026
@seunlanlege
seunlanlege force-pushed the fix/simplex-filler-guards branch from 96ecf18 to d012f2d Compare October 19, 2026 15:08
seunlanlege added a commit that referenced this pull request Oct 19, 2026
- executeOrder skips a cached bid plan whose limit order is no longer
  open/resizing, and re-checks after the bid row is stored: a bid that went
  out while its order closed is retracted at once (just that bid).
- Completion is recorded in the two EventMonitor listeners, ahead of the
  settlement queue, for complete fills only, so the bid loop stops while
  settlement is queued behind reconcile or orderbook work.
- Fill attribution follows the escrow evidence: live bids whose take covers
  the release, then retracted bids in the window, before the payout and
  first-bid fallbacks.
- A retracted bid's window is counted back from when the fill event arrived,
  not from settlement; the margin is RETRACTED_BID_EXECUTION_MARGIN_SECONDS (120).
- Note: the BidStore `rows` filter is stated as a breaking change for custom stores.
- Merge: retire()'s POSTABLE guard matches #1410's identical change; the
  rebase test fixtures follow the fill's block number from #1421.

@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. The round-1 findings are fixed:

  • An undelegated chain clears itself, through a re-check at intake every 60 s.
  • Partial fills nothing can price are skipped before any venue planning.
  • setWatchOnly(false) checks delegation.
  • The docs cover the new config key and skip reasons.

Non-blocking follow-ups:

  • A failed solver-selection read leaves the chain dropping every order.
    • guardDelegation treats the failed read as "active" but never fills the cache (filler.ts:396-399).
    • For a chain that was watch-only at boot, every order then hits Shared cache is not initialized (filler.ts:894-897) and is dropped, with no skip event.
    • Fix either by letting the read's error propagate, so setWatchOnly(false) fails and can be retried, or by filling the cache in the intake re-check.
    • The tests stub getSolverSelection to false, so they don't cover this path.
  • Orders arriving during the re-check are skipped. While the read is in flight they are skipped rather than waiting for it (filler.ts:367-369). Caching the in-flight promise per chain would fix that.
  • PR description. The behaviour-change line should name the same-asset and no-dollar-path books whose partial fills are now skipped, and mention the 60 s re-check.

seunlanlege added a commit that referenced this pull request Oct 20, 2026
seunlanlege added a commit that referenced this pull request Oct 20, 2026
- executeOrder skips a cached bid plan whose limit order is no longer
  open/resizing, and re-checks after the bid row is stored: a bid that went
  out while its order closed is retracted at once (just that bid).
- Completion is recorded in the two EventMonitor listeners, ahead of the
  settlement queue, for complete fills only, so the bid loop stops while
  settlement is queued behind reconcile or orderbook work.
- Fill attribution follows the escrow evidence: live bids whose take covers
  the release, then retracted bids in the window, before the payout and
  first-bid fallbacks.
- A retracted bid's window is counted back from when the fill event arrived,
  not from settlement; the margin is RETRACTED_BID_EXECUTION_MARGIN_SECONDS (120).
- Note: the BidStore `rows` filter is stated as a breaking change for custom stores.
- Merge: retire()'s POSTABLE guard matches #1410's identical change; the
  rebase test fixtures follow the fill's block number from #1421.
- Intake re-reads isDelegated for an undelegated-marked chain (at most once per
  60 s per chain) and clears a stale mark.
- Partials nothing can price in USD are refused in the cheap pre-planning pass;
  the venue-reservation comment is corrected. Document minPartialFillUsd and the
  same-asset/no-dollar-path behaviour change.
- Turning watch-only off on a solver-selection chain checks delegation and marks
  the chain undelegated through IntentFiller.guardDelegation.
- retire() writes "expired" under the POSTABLE guard.
- Troubleshooting lists the partial-floor and undelegated skip reasons.
@seunlanlege
seunlanlege force-pushed the fix/simplex-filler-guards branch from b68eede to 6e1389b Compare October 20, 2026 12:30
seunlanlege added a commit that referenced this pull request Oct 20, 2026
- executeOrder skips a cached bid plan whose limit order is no longer
  open/resizing, and re-checks after the bid row is stored: a bid that went
  out while its order closed is retracted at once (just that bid).
- Completion is recorded in the two EventMonitor listeners, ahead of the
  settlement queue, for complete fills only, so the bid loop stops while
  settlement is queued behind reconcile or orderbook work.
- Fill attribution follows the escrow evidence: live bids whose take covers
  the release, then retracted bids in the window, before the payout and
  first-bid fallbacks.
- A retracted bid's window is counted back from when the fill event arrived,
  not from settlement; the margin is RETRACTED_BID_EXECUTION_MARGIN_SECONDS (120).
- Note: the BidStore `rows` filter is stated as a breaking change for custom stores.
- Merge: retire()'s POSTABLE guard matches #1410's identical change; the
  rebase test fixtures follow the fill's block number from #1421.
@seunlanlege
seunlanlege merged commit eb4b96b into main Oct 21, 2026
5 of 8 checks passed
@seunlanlege
seunlanlege deleted the fix/simplex-filler-guards branch October 21, 2026 09:52
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