Titandocs

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 key

4 — 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.

On this page