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
| Type | Engine behaviour |
|---|---|
| MARKET | Crosses immediately; TP/SL can be armed right after the fill |
| LIMIT | Rests; TP/SL cannot be armed until it fills |
| Reduce-only | Can 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.setis chained straight after submission (signedPOST /v1/tpsl). - LIMIT — the position does not exist yet. The chosen levels are parked in
stores/pending-tpsl, andusePendingTpSlArmingarms 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
└────► cancelledWhat is wired today
| Action | Status |
|---|---|
| Place order | ✅ signed POST /v1/orders |
| TP/SL on MARKET | ✅ chained after fill |
| TP/SL on LIMIT | ✅ armed by usePendingTpSlArming |
| Cancel | ⛔ signedCancel exists, not wired to the positions panel |
| Leverage change | ⛔ signedLeverage 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.