Titandocs

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 type

client.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 number

Decimal 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.

FieldSource
markOrderbook mid
marginDerived from size × entry ÷ leverage
leverageDefaulted until the engine reports it
liquidationDerived 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);
ServiceBacking routes
balanceService/v1/users/:party/balance
positionService/v1/users/:party/positions
orderServicereads /v1/users/:party/orders; place()POST /v1/orders
fillService/v1/users/:party/fills, /v1/fills
sessionServicePOST /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.

On this page