Titandocs

Order flow

From the order form to a signed POST — validation order, TP/SL arming, and the failure modes that look like success.

The submit path

Gate on wallet + session

handleOrderSubmit requires a connected wallet and a live engine session. Missing session surfaces as the form's "Enable trading" CTA, which re-runs session-auth; missing wallet surfaces as "Connect".

Snap to the grid

Size is floored onto 10^-szDecimals and any UI-derived price (mid, order-book click, a TP/SL computed from a percentage) is snapped before margin and fee previews are computed.

Build canonical bytes

lib/auth/canonical.ts produces the byte-exact blob. Not JSON.stringify — field order and encodings are fixed because the engine reproduces them.

Sign with the session key

Ed25519 via @noble/curves. The wallet is not prompted.

POST and interpret

orderService.place()POST /v1/orders, then success / reject / error toasts and a party-scoped query invalidation.

A rejected order is HTTP 200. The engine answers {"ok": false, "code": "0x0C"} with a 200 status. Any code path that treats res.ok as "the order rested" will show a filled order that never existed.

Order types

TypeEngine behaviour
MARKETCrosses immediately; TP/SL can be armed right after the fill
LIMITRests; TP/SL cannot be armed until it fills
Reduce-onlyCan only shrink; exempt from minNotional

TP/SL — why LIMIT is harder

Engine triggers are position-attached: they need an open position to attach to. There is no stop-entry trigger.

  • MARKET — the fill opens the position, so tpslService.set is chained straight after submission (signed POST /v1/tpsl).
  • LIMIT — the position does not exist yet. The chosen levels are parked in stores/pending-tpsl, and usePendingTpSlArming arms them the moment the fill lands.

pending-tpsl is deliberately not persisted. A reload drops the intent — and the toast says so — because silently arming a trigger the user set up in a previous session, at prices that have since moved, is worse than losing it.

Trigger lifecycle, readable at GET /v1/users/:party/triggers:

armed ──► fired
   └────► cancelled

What is wired today

ActionStatus
Place order✅ signed POST /v1/orders
TP/SL on MARKET✅ chained after fill
TP/SL on LIMIT✅ armed by usePendingTpSlArming
CancelsignedCancel exists, not wired to the positions panel
Leverage changesignedLeverage exists, not wired

GET /v1/user/me is unusable from a browser. The engine wants a signed GET body, and fetch forbids bodies on GET. This blocks armed-trigger display in the UI and needs an API-side change — not a front-end workaround.

Reading the result

After a successful place, invalidate party-scoped keys rather than optimistically inserting a row:

queryClient.invalidateQueries({ queryKey: ['orders', party] });
queryClient.invalidateQueries({ queryKey: ['positions', party] });

The WebSocket will also deliver position.update and balance.update. Both paths converge; the invalidation is what covers the case where the socket is down.

On this page