Repository navigation
[simplex]: floor partial-fill bids, guard reconcile and overlapping rebalances, skip undelegated chains - #1410
Conversation
Wizdave97
left a comment
There was a problem hiding this comment.
Verdict: changes needed before merge. The fix is small.
These parts are clean:
- The partial-fill floor is wired consistently:
??throughout, validation>= 0, andemit-tomlwrites the key whenever it is set, so0round-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
- 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,
setupDelegationreturns false or throws, for example after agetCodeerror, 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.delegatedfrom an on-chaingetCodeand 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
undelegateduntil a restart. Before this PR, that chain filled normally. - #1415 makes this common, because sponsored ops that end
pendingand transient errors before submission now also return false. - Suggestion: at intake, re-check
delegationService.isDelegated(chain)for marked chains (rate-limited; onegetCode), 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.
- Scenario: at boot,
Should fix
-
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).
limitOrderUsdEdgesdrops 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.mdxdocuments, 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, whichconsume()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.
-
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 havesetWatchOnly(false)checkisDelegated.
- A solver-selection chain that is watch-only at boot is never delegated and never marked.
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.mdxdocumentsminOrderSizeUsdbut notminPartialFillUsd, even though the new default changes behaviour.troubleshooting.mdxdoesn't list the new partial-floor orundelegatedskip reasons.
- 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.
- 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.
- 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.
96ecf18 to
d012f2d
Compare
- 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
left a comment
There was a problem hiding this comment.
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.
guardDelegationtreats 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
getSolverSelectiontofalse, 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.
- 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.
…ebalances, skip undelegated chains
- 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.
b68eede to
6e1389b
Compare
- 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.
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)reconcile()wrote an under-funded entry's status back from alive()snapshot with no status guard. If acancel()finished while reconcile was paging the orderbook, the order went back toopen, and was then matched and reposted at the withdrawn price.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,0disables (src/strategies/fx.ts)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)Chains whose delegation failed stop bidding (
src/core/filler.ts)undelegatedChains), and orders to them are skipped at intake withorderSkipped { reason: "undelegated" }. Nothing is written tosimplex.watchOnly.enableFills,addChain) clears it. So doremoveChain, an add rollback, andenableFillson a chain without solver selection.getCode), so intake re-readsisDelegatedfor 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.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 readsisDelegatedand marks the chain when the wallet is not delegated.guardDelegationthrowsCould 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 a409carrying the message.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:
minPartialFillUsd = 0, because their partial-fill scenarios use amounts below the $20 floor.hyperfx-orderbook:v0.4.13. v0.3.0 rejects postings signed for the release-4 SolverAccount ([evm, sdk, simplex]: gateway release 4 and the official ERC-4337 and Permit2 interfaces #1385) withBadSignature. Main has had the same failure since [evm, sdk, simplex]: gateway release 4 and the official ERC-4337 and Permit2 interfaces #1385; this PR is the first to touchsrc/orderbook/**since then, which is what triggers the workflow.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/simplexat the PR head:undelegated-chainsandenable-fills, 24 passed. They cover a failed solver-selection read (guardDelegationrejects, the chain stays watch-only and unsaved), a successful read oftrue(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).testjob commands: simplexlintexit 0 (warnings only);test:data146 passed;test:unit261 passed;test:filler70 passed, 5 skipped.vitest --maxConcurrency=1 src/tests/core src/tests/data src/tests/orderbook src/tests/services): 741 passed, 4 skipped.tscshows the 14 errors already on main, none in changed files.Not covered: no mainnet run.