Skip to content

[simplex]: Post operator limit orders to the HyperFX orderbook - #1270

Merged
Wizdave97 merged 12 commits into
mainfrom
dami/simplex-limit-orders
Sep 21, 2026
Merged

Wizdave97 merged 12 commits into
mainfrom
dami/simplex-limit-orders

Conversation

@dharjeezy

@dharjeezy dharjeezy commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

An operator can now create, list, fetch and cancel limit orders, and simplex advertises each one on the HyperFX orderbook. A limit order is what simplex offers to pay, stored in a new limit_orders table in bids.db, and the orderbook entry is a derived copy built from a signed fillOrder UserOperation that nothing ever submits. Every request is validated against serverInfo and books before it is stored, so the rejection codes the orderbook keeps for bad requests should never come back.

Pricing incoming orders against these limit orders, and drawing remaining down on a fill, land separately.

The create request deviates from #1267 deliberately. The issue publishes { book, side, fillChain, price, size, acceptedSources, ttlSecs, expiresAt? }; this takes { fillChain, tokenIn, amountIn, tokenOut, amountOut, acceptedSources, ttlSecs, expiresAt? } and derives the book, the side and the rate from the two amounts. An operator states what they take in and what they pay out, so the direction falls out of the request instead of being a separate field they can contradict, and there is no question of which price they meant. The issue needs updating, or this noting when it merges.

Review fixes are in the last two commits. A retryable OrderSubmissionFailed no longer retires the order: it stays open with the reason on it for reconciliation to post again, where before one timeout killed it permanently. A cancelOrder that never got an answer reads as failed rather than UNKNOWN_ORDER, so it no longer clears a commitment whose entry may still be live. ORDER_EXISTS is answered by reading order(solver, commitment) and treating a live entry of ours as the posting it already is, instead of bumping the nonce and leaving two entries behind one liability. And serverInfo.chains is checked before posting, so a mistyped source chain is refused on the way in.

Two of that review's points are answered in #1271 rather than here, because they need code this PR does not have: the missing limitOrderResized and limitOrderFilled events, which come out of the fill path, and GET /api/limit-orders/:id carrying the fills that consumed the order, which reads the limitOrderId bids only carry from there on.

docs/ai/decisions/2026-09-16-the-protocol-fee-is-already-out-of-an-order-by-the-time-we-price-it.md records the fee basis: placeOrder emits reducedInputs, so what the matcher prices is already net, the solver gets the rate it signed, and bookPrice is the same trade seen from the swapper's side. The invariant worth keeping is that the event carries reduced inputs; gross ones would make every payout 1/(1-f) too large with nothing failing.

Stacked on #1268, which it needs for encodePhantomBidDeclaration.

Part of #1267.

@dharjeezy
dharjeezy marked this pull request as draft September 15, 2026 12:18
@dharjeezy dharjeezy changed the title Post operator limit orders to the HyperFX orderbook [simplex]: Post operator limit orders to the HyperFX orderbook Sep 15, 2026
@dharjeezy
dharjeezy requested a review from Wizdave97 September 15, 2026 12:19
@dharjeezy
dharjeezy force-pushed the dami/simplex-limit-orders branch from d5904a5 to 407f570 Compare September 15, 2026 14:13
@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 the orderbook's own source at polytope-labs/hyperfx-orderbook@main.

The wire format is right. I checked every boundary against the server rather than against the issue text:

  • tests/fixtures/orderbook-schema.graphql is byte-identical to the orderbook's schema.graphql, and orderbook-userops.json to its fixtures/userops.json.
  • The EIP-712 CancelOrder / Heartbeat field order and the two-field domain match crates/core/src/messages.rs exactly.
  • The posted op satisfies crates/core/src/verify.rs: session = 0, output.assets[0].amount = 0, FillOptions v2, source == destination == fillChain, nonce = bidNonceKey(commitment, 0) << 64, commitment-prefixed 97-byte signature. The declaration round-trips through crates/core/src/paymaster.rs.
  • schema.test.ts validating every sent document and pinning all three enums against the published schema is the right call — it catches exactly the class of drift nothing else here would.

Worth recording somewhere, because it isn't obvious and it does hold: the gateway takes protocolFeeBps off the input (IntentGatewayV2.sol:356, with the commitment computed over the reduced inputs) while the orderbook shades the output (crates/core/src/haircut.rs). For a linear fee the algebra coincides, so bookPrice and what simplex actually pays agree end to end. The "Pricing basis" paragraph in #1267 is correct.

Comments below, the first being the one I'd block on.

Comment thread sdk/packages/simplex/src/orderbook/limit-orders.ts
Comment thread sdk/packages/simplex/src/orderbook/limit-orders.ts Outdated
Comment thread sdk/packages/simplex/src/orderbook/limit-orders.ts
Comment thread sdk/packages/simplex/src/orderbook/client.ts
Comment thread sdk/packages/simplex/src/orderbook/limit-orders.ts
Comment thread sdk/packages/simplex/src/simplex.ts
Comment thread sdk/packages/simplex/src/services/server/UiServer.ts Outdated
@dharjeezy

dharjeezy commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor Author

All seven actioned. Four here in e434064b and 11ea8c4c: the retryable failure keeps the row open for reconciliation instead of retiring it, CancelOrderResult has a failed variant so a cancel that got no answer stops reading as UNKNOWN_ORDER, ORDER_EXISTS is answered by reading order(solver, commitment) and taking a live entry of ours as the posting it already is, and serverInfo.chains is checked before posting.

Two landed in #1271 rather than here, because they need the fill path and the limitOrderId on bids: the missing limitOrderResized and limitOrderFilled events, and GET /api/limit-orders/:id returning the fills that consumed the order.

The contract deviation is called out in the description. Your UNKNOWN_ORDER thread is collapsed as outdated now, since the fix replaced the lines it sat on.

On the fee basis: I could not reproduce "the algebra coincides" at first, because I assumed inputNet was gross. It turns on placeOrder emitting inputs: reducedInputs, so what the matcher prices is already net, the solver gets the rate it signed, and bookPrice is the same trade from the swapper's side. Written up in docs/ai/decisions/2026-09-16-the-protocol-fee-is-already-out-of-an-order-by-the-time-we-price-it.md, framed around the invariant rather than the arithmetic: gross inputs would make every payout 1/(1-f) too large with nothing failing.

@Wizdave97

@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 11ea8c4c. All seven comments are addressed, and e434064b is a better fix than I proposed on two of them:

  • Retryable failures now keep the row open with lastError set, so reconciliation reposts it. A timeout no longer retires an order the operator still wants.
  • Transport failures are their own kind: "failed" on CancelOrderResult rather than being relabelled UNKNOWN_ORDER, and cancel() now keeps the commitment on the row when it could not confirm the entry is gone — which is the part that actually prevents orphaning.
  • ORDER_EXISTS looks the entry up through the new orderAt query and adopts it as unchanged, falling back to the nonce bump only when the lookup fails. Exactly what §2 asks for.
  • serverInfo.chains is validated for both fillChain and every declared source, with the right caveat that it only answers half of UNSUPPORTED_SOURCE_CHAIN.
  • The :id route returns fills via withFills, and limit-order:resized / limit-order:filled now exist.

The create-API shape still differs from the one #1267 published ({tokenIn, amountIn, tokenOut, amountOut} rather than {book, side, price, size}). I still think the implementation's shape is the better one — just worth a line in the PR description or an edit to the issue so the contract has one published form.

No further comments from me on this one.

@dharjeezy
dharjeezy force-pushed the dami/simplex-limit-orders branch 2 times, most recently from cf839a1 to 19afa7b Compare September 17, 2026 11:57
Comment thread sdk/packages/simplex/src/config/filler-toml.ts
Comment thread sdk/packages/simplex/src/services/server/UiServer.ts Outdated
@dharjeezy
dharjeezy force-pushed the dami/simplex-limit-orders branch from 19afa7b to f09aebf Compare September 18, 2026 15:51
Base automatically changed from dami/simplex-remove-phantom-bids to main September 18, 2026 15:55
@Wizdave97
Wizdave97 force-pushed the dami/simplex-limit-orders branch 4 times, most recently from 4f59e76 to 483e6d0 Compare September 21, 2026 10:02
@Wizdave97
Wizdave97 force-pushed the dami/simplex-limit-orders branch 3 times, most recently from 2bc78b2 to aa9de76 Compare September 21, 2026 11:46
@Wizdave97
Wizdave97 marked this pull request as ready for review September 21, 2026 11:53
@Wizdave97
Wizdave97 force-pushed the dami/simplex-limit-orders branch from aa9de76 to 02cbfdd Compare September 21, 2026 12:47
dharjeezy and others added 12 commits September 21, 2026 12:54
`fillOrder` takes one quote per leg: the input beside each output is the most
escrow the solver takes for it. A limit order's op carries the one leg it is,
so its take is the whole input the operator wants for what they are paying —
the rate itself, since the order's own output amount is zero, as the orderbook
requires.

The op is still never executed, and there is no longer a version to choose for
it: `encodeFillOrder` has one shape and `FillOptionsVersion` is gone with the
versioned encoder.
A posting is a bid's op in form, and SolverAccount now derives a bid's nonce
key from its calldata as well as its order and session key. The posting
follows the same rule, so the orderbook's nonce binding and the account's are
one derivation.
@Wizdave97
Wizdave97 force-pushed the dami/simplex-limit-orders branch from 02cbfdd to 8f2eb40 Compare September 21, 2026 12:56
@Wizdave97
Wizdave97 merged commit 1db7b8a into main Sep 21, 2026
15 of 16 checks passed
@Wizdave97
Wizdave97 deleted the dami/simplex-limit-orders branch September 21, 2026 13:00
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