Titandocs

Data flow

Follow one order from keypress to on-chain leaf, and one price tick from a CEX to the order book.

An order, end to end

Browser builds canonical bytes

lib/auth/canonical.ts serialises the order into a byte-exact blob. Not JSON — a fixed field order with fixed encodings, because the engine reproduces the same bytes to verify.

Session key signs

@noble/curves Ed25519 over those bytes. The wallet is not involved — it signed once, at session open.

BFF proxies

POST /v1/orders with X-User / X-Session-Id / X-Session-Sig forwarded verbatim to the engine. The BFF adds nothing and strips nothing.

Engine validates then matches

Grid rules first (invalid_size, invalid_price, below_min_notional), then the book. Rejections come back as HTTP 200 with ok: false — a 200 does not mean the order rested.

WAL entry written

The engine appends a signed entry: engine.order_placed, then engine.order_filled per leg.

Two consumers read the WAL

The indexer persists it into Postgres. The BFF receives the same events over SSE and re-derives domain frames for live clients. Same source, two latencies.

Merkle commitment

Fills accumulate into a per-user tree. On cadence, the engine commits a UserBatch root per user and a global BatchManifest on-chain.

A rejection is a 200. POST /v1/orders returning HTTP 200 with {"ok": false, "code": "0x0C"} is the normal rejection path. Code that only checks res.ok will report a rejected order as filled.

Snapshot + delta

Never poll for what the stream already pushes. Every live surface boots from REST and then follows the WebSocket.

Snapshot (REST)Delta (WS frame)
GET /v1/users/:party/positionsposition.update
GET /v1/users/:party/balancebalance.update
GET /v1/orderbook?depth=orderbook.snapshot
— (live only)trade
— (live only)candle.update / candle.close

There is no REST trade history feed for the tape: trade frames are live-only, deduplicated to the taker leg, and flip legs are coalesced into a single print.

A price tick

Binance ─┐
OKX     ─┼──► oracle (WS) ──► median/robust index ──► Redis ──► engine
Bybit   ─┘                                                        │
                                                                  ├─► mark price
                                                                  ├─► funding rate
                                                                  └─► liquidation checks

The oracle publishes an index price per asset to Redis. The engine consumes it for mark price, funding, and liquidation — it never reads a CEX directly.

The engine names oracle assets BTC / ETH / SOL, while the token registry keys the Canton wrappers CBTC / CETH / CSOL. One alias layer (resolveTokenKey) reconciles them — see Markets & tokens.

Positions are epochs, not rows

A positionId (pos-<32 hex>) identifies an epoch, not a running position. A flip closes one epoch and opens another, so the stream emits two frames:

{"kind":"position.update","payload":{"positionId":"pos-1f0e…","size":"0"}}
{"kind":"position.update","payload":{"positionId":"pos-9b3a…","size":"-0.25","realizedPnl":"0"}}

realizedPnl restarts at zero under the new id. UI code that keys positions by market instead of by positionId will silently merge two epochs and show wrong realised PnL.

On this page