Place your first signed order
Session auth, canonical bytes, grid snapping, and reading a result that arrives as HTTP 200 either way.
This walks the full non-custodial path. Everything here happens client-side except the proxy hop.
1 — Open a session
The wallet signs the canonical Session-Auth blob once.
import { sessionService } from '@/services/session';
const { sessionId, expiresAt } = await sessionService.authorize({
party,
signer: walletSigner, // from lib/auth/canton-wallet.ts
});If the user refuses this signature, the wallet is still connected — they just cannot trade. The
order form shows "Enable trading", which calls enableCantonTrading() to retry. Do not treat
a refused signature as a failed connect.
2 — Snap to the grid
Do this before computing any margin or fee preview, or the preview will not match the fill.
import { snapSize, snapPrice, validateNotional } from '@/lib/market-grid';
const size = snapSize(rawSize, market.szDecimals); // floor onto 10^-szDecimals
const price = snapPrice(rawPrice, market.szDecimals);
const check = validateNotional({ price, size, minNotional: market.minNotional, reduceOnly });
if (!check.ok) return showInlineError(check.reason);reduceOnly orders are exempt from minNotional. Gating a Close on it leaves a user with a
small residual position unable to close it.
3 — Build canonical bytes and sign
import { canonicalOrder } from '@/lib/auth/canonical';
const body = {
market: market.id, // "BTC-USDCX" — the engine symbol, one dialect
side: 'buy',
size,
price,
reduceOnly: false,
nonce: nextNonce(),
};
const bytes = canonicalOrder(body);
const sig = await sessionSign(bytes); // Ed25519, session key4 — Submit
const res = await orderService.place(body, {
'X-User': party,
'X-Session-Id': sessionId,
'X-Session-Sig': sig,
});Or by hand:
curl -X POST http://localhost:4000/v1/orders \
-H 'Content-Type: application/json' \
-H 'X-User: Alice::1220…' \
-H 'X-Session-Id: sess_7f2c…' \
-H 'X-Session-Sig: 3a91f0…' \
-d '{"market":"BTC-USDCX","side":"buy","size":"0.05","price":"64000","nonce":42}'5 — Read the result
Both outcomes are HTTP 200. Branch on the body, never on the status alone.
if (!res.ok) {
toast.error(rejectionMessage(res.code)); // invalid_size, below_min_notional (0x0C), …
return;
}On success, invalidate party-scoped keys rather than optimistically inserting a row:
queryClient.invalidateQueries({ queryKey: ['orders', party] });
queryClient.invalidateQueries({ queryKey: ['positions', party] });The WebSocket also delivers position.update and balance.update. Both paths converge; the
invalidation covers the case where the socket is down.
6 — TP/SL
The fill opens the position immediately, so the trigger can be armed right after:
await tpslService.set({ market, takeProfit, stopLoss, nonce: nextNonce() }, headers);7 — Watch it live
const ws = new WebSocket(
streamWsUrl({
kinds: ['position.update', 'balance.update', 'trade'],
markets: [market.id],
party,
}),
);Pass the exact engine symbol in markets=. The BFF compares it verbatim, and an unmatched
filter subscribes you to every market rather than none.
Nonce hygiene
Increment before a retry, not after the response. A timeout on a request the engine actually processed leaves the nonce's fate unknown; treating it as consumed is the safe assumption.