Skip to content

[simplex]: Match, reserve and value against limit orders - #1271

Merged
Wizdave97 merged 20 commits into
mainfrom
dami/simplex-limit-order-pricing
Sep 21, 2026
Merged

Wizdave97 merged 20 commits into
mainfrom
dami/simplex-limit-order-pricing

Conversation

@dharjeezy

@dharjeezy dharjeezy commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

The pieces the filler needs to price from the operator's limit orders rather than from pair curves: which limit order serves an incoming order, how much of it a bid may hold, and what an order is worth in dollars now the curves no longer say. Nothing is wired into FXFiller yet, so behaviour is unchanged.

reserve is a compare-and-set rather than a read then a write, since two chains bidding against one limit order would otherwise both see room in the gap and between them promise more output than it has. USD values come from the limit orders themselves, and a symbol with no route to a dollar stays unpriced rather than being guessed at, because the value sizes a confirmation wait.

Switching canFill, calculateProfitability and the valuation pass over follows separately. They reach the pair model through the same places, so that migration is one change rather than four, and it reviews better on its own.

Review fixes follow in the last commit here and one at the tip of the stack. repost now acts on the cancel it asked for instead of posting regardless, which is what kept a refused or unanswered cancel from leaving two live entries behind one liability. Cross-chain fills may be partial, since _fillCrossChain has kept per-token progress, cleared _filled on an under-fill and released escrow proportionally since #980; the comment that justified the old restriction is rewritten. drawDown and release report a guarded write that did not apply rather than losing it silently, and the draw-down now precedes the release, so a crash between them understates capacity instead of advertising output already paid.

On the matcher: dropping the offer >= reqOut precondition and ranking by payout rather than offer is deliberate, and it answers open question 2, since a thin order quoting a wonderful rate can no longer crowd out one that covers the swap. One swap can also draw on several limit orders now, walking levels best first until the ask is covered, because the orderbook already quotes a same-chain swapper the clearing price across every level that can fill together and honouring one of them advertises depth we then refuse to meet. That change lands at the tip of the stack, where the matcher is.

Part of #1267.

@dharjeezy
dharjeezy marked this pull request as draft September 15, 2026 13:36
@dharjeezy dharjeezy changed the title Match, reserve and value against limit orders [simplex]: Match, reserve and value against limit orders Sep 15, 2026
@dharjeezy
dharjeezy force-pushed the dami/simplex-limit-order-pricing branch 2 times, most recently from a1a38a8 to 15cf6c5 Compare September 15, 2026 14:18
@Wizdave97
Wizdave97 added this pull request to stack #1272 September 15, 2026 14:33

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

Reviewed against #1267 and hyperfx-orderbook@main.

One thing I want to record as verified rather than flagged, because it looks like a deviation and isn't: matchLimitOrder drops the issue's offer >= reqOut precondition and ranks by payout = min(offer, available) where §3 says rank by offer. Since the reduce takes the maximum payout, if any candidate can cover reqOut then the winner does too — no fill is lost, and fx.ts enforces the cross-chain all-or-nothing rule at the caller. Ranking by what is actually payable is the better rule and it answers open question 2. Worth saying so in the PR description.

The reservation lifecycle is the strongest part of this PR — claimReservation owning the hold from the moment a bid row exists, and the comment explaining why an overstated reservation is the safe way to be wrong, are both right.

Two feature gaps below (partial cross-chain fills, and combining levels) plus three defects.

Comment thread sdk/packages/simplex/src/orderbook/limit-orders.ts Outdated
Comment thread sdk/packages/simplex/src/data/sqlite/limit-orders.ts
Comment thread sdk/packages/simplex/src/strategies/fx.ts Outdated
Comment thread sdk/packages/simplex/src/orderbook/matching.ts
Comment thread sdk/packages/simplex/src/tests/strategies/fx.payout.test.ts
@dharjeezy
dharjeezy force-pushed the dami/simplex-limit-order-pricing branch from 501c7bc to c209e18 Compare September 16, 2026 12:54
@dharjeezy
dharjeezy force-pushed the dami/simplex-limit-order-pricing branch 2 times, most recently from 9e3a1ad to 7a74b23 Compare September 16, 2026 13:47
@dharjeezy

dharjeezy commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor Author

@Wizdave97 Five actioned here in c56f8646: repost acts on the cancel result, cross-chain fills may be partial now that _fillCrossChain keeps per-token progress and releases escrow proportionally, drawDown and release report a guarded write that did not apply, the draw-down precedes the release so a crash between them understates capacity, and pnpm test:filler names a test that exists. Your docs comment was already covered by an earlier pass that deleted the five stale flows.

Combining levels landed at the tip of the stack, in #1275 c50a81c4, because that is where the matcher is. matchLimitOrders walks levels best payout first and stops once the ask is covered, a bid row carries a list of holds taken all or nothing, and a fill shares out over them in the same order. One regression worth knowing: my first version skipped every candidate when the incoming order asked for zero output, which is how a quote reaches the engine, so canFill briefly answered false for same-chain FX. The loop always takes the best candidate now.

On the fee gate for partials: the cross-chain path uses the same isFullyFilled logic and only finalises on a full fill, so "a partial collects no order.fees" still holds. It does pay the relayer fee for RedeemEscrowPartial though, which the partial's profit figure does not net any more than it nets gas. Noted where the figure is computed rather than guessed at.

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

Re-reviewed at c56f8646. Both comments are addressed:

  • repost() now acts on the cancel: it proceeds only on cancelled or a genuine UNKNOWN_ORDER, and otherwise leaves the row open with its commitment intact and the reason on it, so the next cycle retries instead of stacking a second entry. Paired with the CancelOrderResult change in #1270, that closes the duplicate-entry path properly.
  • drawDown and release throw LimitOrderWriteError on a lost CAS rather than returning a stale read.
  • test:filler points at fx.payout.test.ts, and fbbfbd2a removes the curve and solver-link notes.

Cross-chain partials are enabled here too, and the fx.ts comment block correctly describes _partialFills / RedeemEscrowPartial from #980. One leftover: the matcher's own docstring still says "a cross-chain order reverts on any under-fill" — that line moved into #1275, so I have commented on it there.

No further comments from me on this one.

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

Followed the overfill question down into IntentGatewayV2 on current main (post-#980). Short answer: a partial fill cannot overpay, but a full fill with targetOutput > output.amount donates the entire excess — and the comment below says the opposite.

Both IntrinsicIntents._fillSameChain and ExtrinsicIntents._fillCrossChain share one branch:

if (alreadyFilled == 0 && solverAmount > totalRequired) {
    fillAmount = totalRequired;
    (protocolShare, beneficiaryShare) = _splitSurplus(solverAmount - totalRequired, order.output.call.length > 0);
} else {
    fillAmount = solverAmount > remaining ? remaining : solverAmount;
}

The else branch is every partial fill — a first fill short of the ask (solverAmount < totalRequired) and any continuation (alreadyFilled > 0). Both clamp to min(solverAmount, remaining) and pull only fillAmount, so an over-sized options.outputs[i].amount on a partial costs nothing; the excess is ignored, not charged. The surplus branch is unreachable on a partial by construction, since it requires solverAmount > totalRequired, which is a full fill.

The other branch is the problem, and _splitSurplus distributes all of it:

if (hasOutputCall) return (dust, 0);
protocolShare = (dust * _params.surplusShareBps) / 10_000;
beneficiaryShare = dust - protocolShare;

Then safeTransferFrom(msg.sender, beneficiary, fillAmount + beneficiaryShare) and safeTransferFrom(msg.sender, address(this), protocolShare) — the solver is debited exactly solverAmount. Nothing comes back.

Comment thread sdk/packages/simplex/src/strategies/fx.ts
@dharjeezy

Copy link
Copy Markdown
Contributor Author

The overfill fix landed at the tip, in #1275 104c939e, since the clamp interacts with the matcher change there. Bid is min(offered, output.amount) now, and payoutSurplusUsd describes margin actually kept rather than a donation.

Your releasedInput note is in as a comment: the arithmetic agrees with the contract only because the prior-partial guard keeps alreadyFilled at zero and the bid is never above the ask, and both halves are now stated where the value is computed.

@dharjeezy
dharjeezy force-pushed the dami/simplex-limit-order-pricing branch from c56f864 to 961a2e8 Compare September 17, 2026 11:29
@dharjeezy
dharjeezy force-pushed the dami/simplex-limit-order-pricing branch from 0262a45 to 405bbb3 Compare September 17, 2026 11:57
Comment thread sdk/packages/simplex/src/cli/init/steps/strategies.ts Outdated
Comment thread sdk/packages/simplex/src/cli/init/steps/strategies.ts Outdated
@dharjeezy
dharjeezy force-pushed the dami/simplex-limit-order-pricing branch from 501e684 to ffff9ad Compare September 18, 2026 15:51
@Wizdave97
Wizdave97 force-pushed the dami/simplex-limit-order-pricing branch from ffff9ad to bee5720 Compare September 18, 2026 15:55
@dharjeezy
dharjeezy force-pushed the dami/simplex-limit-order-pricing branch 2 times, most recently from 0e032a8 to d61d4a5 Compare September 18, 2026 16:51
@Wizdave97
Wizdave97 marked this pull request as ready for review September 21, 2026 11:53
@Wizdave97
Wizdave97 force-pushed the dami/simplex-limit-order-pricing branch from 93e3cb5 to 91c537d Compare September 21, 2026 12:48
Base automatically changed from dami/simplex-limit-orders to main September 21, 2026 13:00
dharjeezy and others added 20 commits September 21, 2026 13:00
Release-3 gateways settle `fillOrder` as one quote per leg: the input beside
each output is the most escrow the solver takes for it, and a take above the
share the fill earns is refused as `RateBelowOrder`. The limit-order pricing
path replaced the curve path that computed those takes, so its bids signed
none at all.

A fill that meets the ask takes the whole input; an under-fill takes the same
proportion of it as the output it delivers, which is the escrow the gateway
releases for that fill anyway. A payout too small to release any escrow is
skipped rather than signed with a zero take.
Retraction names a bid by the identifier recorded on its row, so a row
without one reads as nothing to retract.
@Wizdave97
Wizdave97 force-pushed the dami/simplex-limit-order-pricing branch from 91c537d to 846996a Compare September 21, 2026 13:06
@Wizdave97
Wizdave97 merged commit 50a5cbf into main Sep 21, 2026
16 of 17 checks passed
@Wizdave97
Wizdave97 deleted the dami/simplex-limit-order-pricing branch September 21, 2026 13:22
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