Data layer
The typed boundary to the BFF — client, wire types, mappers, streams, and the service objects on top.
src/lib/api/ is the only place the interface knows an endpoint exists. Above it,
src/services/ exposes one object per domain.
component → service (orderService) → lib/api (apiPost) → BFF /v1
↘ lib/api/mappers → UI domain typeclient.ts
import { apiGet, apiPost, apiDelete, ApiError } from '@/lib/api/client';Three rules it enforces:
Never call fetch() directly from a service. Everything goes through these three.
Every non-2xx throws ApiError carrying status, path, the { error } body, and the
isNotFound / isUnauthorized / isServer flags.
Never return a sentinel. No null-on-error, no { ok: false }. Query's retry predicate is
the single place that decides transient vs permanent — a sentinel would hide that decision inside
each caller.
A 10 second AbortSignal.timeout is applied to every request.
types.ts — wire shapes only
Hand-written mirrors of the BFF's Typebox schemas (api/src/schemas/). These are wire types:
type Decimal = string; // 10 dp, mirrors Canton — never a JS numberDecimal is a string on purpose. 0.1 + 0.2 is the reason. Parse to a number only at the
formatting boundary, never for arithmetic on balances or sizes.
mappers.ts — wire → UI domain
The engine projection is deliberately thin: it omits mark, margin, leverage, and
liquidation. The mapper synthesises them.
| Field | Source |
|---|---|
mark | Orderbook mid |
margin | Derived from size × entry ÷ leverage |
leverage | Defaulted until the engine reports it |
liquidation | Derived from maintenance margin fraction |
Keeping synthesis in one file means a screen never invents a number locally. When the engine
starts reporting mark for real, exactly one file changes.
stream.ts + ws.ts
WebSocket subscriptions for orderbook, trades, and candles.
streamWsUrl() derives the ws(s):// origin from the page — or from VITE_API_BASE_URL when the
BFF lives on another host — and guards against the mixed-content SecurityError you get when an
https:// page opens a ws:// socket.
const url = streamWsUrl({
kinds: ['orderbook.snapshot', 'trade', 'candle.update'],
markets: [market.id], // exact engine symbol — see the dialect warning
intervals: ['1m'],
party,
});markets= is compared exactly by the BFF. A wrong dialect (BTC/USDCx) matches nothing, and
an unmatched filter subscribes you to every market rather than none. Symptom: a suspiciously
busy tape.
Services
import { orderService } from '@/services/order';
await orderService.place(signedBody, headers);
const open = await orderService.list(party, market);| Service | Backing routes |
|---|---|
balanceService | /v1/users/:party/balance |
positionService | /v1/users/:party/positions |
orderService | reads /v1/users/:party/orders; place() → POST /v1/orders |
fillService | /v1/users/:party/fills, /v1/fills |
sessionService | POST /v1/session-auth + the signed-body builders |
walletVerification | /v1/wallet/nonce, /v1/wallet/verify (SIWE) |
Consumers import the object and never see a path. Swapping an endpoint is a one-file change.
Lazy wallet providers
main.tsx mounts the AppKit / wagmi / viem stack after first paint. That stack was most of a
~2 MB entry chunk, and nothing on the trading terminal needs it to render — wallet UI reads the
mirrored Zustand store, not the SDK.
If the chunk fails to load (offline, deploy skew), the app keeps working without wallet connectivity instead of white-screening.