Olympus

WebSocket API

Real-time market data streaming and authenticated private data via WebSocket — order book diffs, trades, klines, mid prices, and account balance updates.

Connection

ws://localhost:3000/ws

The WebSocket endpoint accepts standard upgrade requests. Public market data channels require no authentication. Private channels (e.g., accountUpdate) require authentication after connecting.

Protocol

Clients send JSON messages to authenticate, subscribe, or unsubscribe from channels. The server publishes messages per engine tick when the underlying state changes.

Authenticate

To receive private data (balance and order updates), clients must authenticate using a challenge-response flow with EIP-191 personal_sign. The message shown in the wallet includes a Terms of Service disclaimer.

Step 1 — Request challenge:

{
  "method": "auth",
  "address": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
  "nonce": 1709123456789
}

Step 2 — Server responds with challenge:

{
  "channel": "auth",
  "data": {
    "status": "challenge",
    "message": "Sign in to Olympus Exchange\n\nBy signing this message...",
    "nonce": 1709123456789
  }
}

Step 3 — Client signs and sends back:

{
  "method": "auth",
  "address": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
  "nonce": 1709123456789,
  "signature": { "r": "0x...", "s": "0x...", "v": 27 }
}

Response (success):

{ "channel": "auth", "data": { "status": "ok", "address": "0xf39f...", "token": "a1b2c3..." } }

Response (failure):

{ "channel": "auth", "data": { "status": "error", "message": "signature mismatch" } }

The nonce must be within 30 seconds of the server's clock (millisecond timestamp).

Session Tokens

On successful auth, the server returns a token in the response. Clients can store this and use it for subsequent connections without prompting the wallet again:

{ "method": "auth", "address": "0x...", "nonce": 0, "token": "a1b2c3..." }

Tokens expire after 24 hours. If expired, the server falls through to the challenge flow.

In dev mode (OLYMPUS_ENV=dev), the signature field can be omitted — the server trusts the address field directly and issues a token immediately.

Subscribe

{
  "method": "subscribe",
  "subscription": { "type": "depthUpdate", "symbol": "AAPL-USD" }
}
{
  "method": "subscribe",
  "subscription": { "type": "allMids" }
}
{
  "method": "subscribe",
  "subscription": { "type": "accountUpdate" }
}

Unsubscribe

{
  "method": "unsubscribe",
  "subscription": { "type": "depthUpdate", "symbol": "AAPL-USD" }
}

Channels

depthUpdate

Streams incremental order book diffs for a specific symbol. Each message contains only the price levels that changed since the previous tick. A quantity of "0" means the level was removed.

Clients should fetch an initial snapshot via GET /api/v1/depth?symbol=X&limit=1000 and then apply diffs on top. See Order Book Initialization below.

Subscribe: { "type": "depthUpdate", "symbol": "AAPL-USD" }

Message:

{
  "e": "depthUpdate",
  "E": 1709500000000,
  "s": "AAPL-USD",
  "U": 42,
  "u": 42,
  "b": [["150.00", "120"], ["149.98", "0"]],
  "a": [["150.02", "300"]]
}
FieldDescription
eEvent type
EEvent time (ms)
sSymbol
UFirst update ID in event
uLast update ID in event
bBid level changes — [price, qty] strings. qty = "0" = removal
aAsk level changes — same format

trade

Streams individual trades as they occur. Each trade has a globally unique, monotonically incrementing ID (t field). Note: a single order matching against multiple resting orders produces multiple trade messages with consecutive IDs.

Subscribe: { "type": "trade", "symbol": "AAPL-USD" }

Message:

{
  "e": "trade",
  "E": 1709500000000,
  "s": "AAPL-USD",
  "t": 8315,
  "p": "150.01",
  "q": "50",
  "T": 1709500000000
}
FieldDescription
eEvent type
EEvent time (ms)
sSymbol
tTrade ID (globally unique, monotonically incrementing)
pPrice
qQuantity
TTrade time (ms)

The t field is a global counter across all instruments. For per-symbol gap detection, use the aggTrade channel instead.

aggTrade

Streams aggregated trades per symbol at a fixed interval. Collapses all trades within the window into a single message with total quantity and price range. Ideal for chart updates — much less bandwidth than individual trades during book sweeps.

Subscribe: { "type": "aggTrade", "symbol": "AAPL-USD" }

Message:

{
  "e": "aggTrade",
  "E": 1709500000100,
  "s": "AAPL-USD",
  "a": 42,
  "p": "150.05",
  "q": "230",
  "f": "150.01",
  "l": "150.05",
  "h": "150.05",
  "w": "150.01",
  "n": 5,
  "T": 1709500000100
}
FieldDescription
eEvent type
EEvent time (ms)
sSymbol
aAggregate trade ID (per-symbol, strictly consecutive)
pLast price in the window
qTotal quantity across all trades
fFirst price in the window
lLast price (same as p)
hHighest price in the window
wLowest price in the window
nNumber of individual trades aggregated
TTimestamp (ms)

The a field is per-symbol and strictly consecutive (1, 2, 3...). Any gap indicates missed updates.

kline

Streams candlestick (kline) updates for a symbol and interval.

Subscribe: { "type": "kline", "symbol": "AAPL-USD", "interval": "1m" }

Message:

{
  "e": "kline",
  "E": 1709500000000,
  "s": "AAPL-USD",
  "k": {
    "t": 1709500000000,
    "T": 1709500059999,
    "i": "1m",
    "o": "150.00",
    "c": "150.05",
    "h": "150.10",
    "l": "149.95",
    "v": "1250",
    "n": 8,
    "x": false
  }
}
FieldDescription
k.tKline open time
k.TKline close time
k.iInterval
k.o/c/h/lOpen, close, high, low prices
k.vVolume
k.nTrade count
k.xIs this kline closed?

allMids

Streams mid prices for all instruments that have both a best bid and best ask.

Subscribe: { "type": "allMids" }

Message:

{
  "channel": "allMids",
  "data": {
    "mids": {
      "AAPL-USD": "150.005"
    },
    "time": 1709500000000
  }
}

accountUpdate (private)

Streams real-time balance, open order, and order event updates for the authenticated account. Requires authentication (see above) before subscribing.

Updates are published whenever the account's state changes — after order placement, trade execution, cancellation, or admin credit.

Subscribe: { "type": "accountUpdate" } (no parameters — uses the authenticated address)

Message:

{
  "e": "balanceUpdate",
  "E": 1709500000000,
  "a": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266",
  "B": [
    {
      "asset": "USD",
      "available": "98500.00",
      "reserved": "1500.00",
      "locked": "0.00",
      "total": "100000.00"
    }
  ],
  "O": [
    {
      "symbol": "AAPL-USD",
      "orderId": "550e8400-e29b-41d4-a716-446655440000",
      "side": "BUY",
      "price": "150.00",
      "origQty": "10",
      "executedQty": "0",
      "status": "NEW",
      "type": "LIMIT"
    }
  ],
  "X": [
    {
      "orderId": "550e8400-e29b-41d4-a716-446655440000",
      "symbol": "AAPL-USD",
      "side": "BUY",
      "type": "MARKET",
      "status": "FILLED",
      "price": "150.01",
      "qty": "10",
      "filledQty": "10",
      "time": 1709500000000,
      "rejectReason": null
    }
  ]
}
FieldDescription
eEvent type
EEvent time (ms)
aAccount address (lowercase hex)
BCurrent balances (same format as GET /api/v1/balance)
OCurrent open (resting) orders (same format as GET /api/v1/openOrders)
XOrder events from this tick — status changes (filled, cancelled, rejected, etc.)

X entries include a rejectReason field when status is "REJECTED" (e.g., "cannot reserve 1500 USD for order").

The type field in X entries can be: MARKET, LIMIT, IOC (order types), DEPOSIT (Core→EVM bridge), WITHDRAW (EVM→Core bridge), or GIFT (admin credit / welcome faucet).

Welcome Faucet

On first successful signature-based authentication, new wallets automatically receive:

  • 10,000 USD on the Core layer (appears as a GIFT event)
  • 1 OLP native gas token on EVM (appears as a native transfer in Blockscout)

Order Book Initialization

The WebSocket only sends incremental diffs, not full snapshots. To build and maintain a local order book:

  1. Subscribe to depthUpdate for the symbol
  2. Buffer incoming diffs
  3. Fetch a snapshot via GET /api/v1/depth?symbol=AAPL-USD&limit=1000 — returns { lastUpdateId, bids, asks }
  4. Apply the snapshot to your local book
  5. Drain buffer — apply any buffered diffs where diff.u > lastUpdateId
  6. Apply future diffs directly, discarding any where u <= lastUpdateId
  7. Monitor for gaps — if a diff arrives with u > lastUpdateId + 1, diffs were lost. Re-fetch the snapshot immediately.

Subscribe before fetching the REST snapshot to avoid missing diffs during the round-trip.

Symbol change to "AAPL-USD":
  1. WS: subscribe("depthUpdate", "AAPL-USD")
  2. REST: GET /api/v1/depth?symbol=AAPL-USD&limit=1000
  3. WS diff arrives (u:101) → buffered
  4. WS diff arrives (u:102) → buffered
  5. REST responds: { lastUpdateId: 100, bids, asks }
  6. Set snapshot (lastUpdateId=100)
  7. Drain: u:101 > 100 → apply, u:102 > 100 → apply
  8. Future diffs applied directly
  9. If u jumps (e.g., 105 → 107) → re-fetch snapshot

Sequence Numbers

The u field in depthUpdate messages is a per-symbol monotonic counter. Each symbol has its own independent sequence. The counter only increments when a message is actually sent (unchanged books don't consume a sequence number). This means:

  • The sequence for a given symbol is strictly consecutive: 1, 2, 3, 4...
  • Any gap > 1 means diffs were lost (WS channel lag)
  • On gap detection, clients must re-fetch the REST snapshot

The lastUpdateId in the REST /depth response uses the same counter, ensuring the snapshot and diffs are comparable.

Crossed Book Detection

As a safety net, clients should also check for crossed books after applying each diff: if bestAsk < bestBid, the book is provably corrupted (impossible in a real order book). Trigger an immediate re-fetch when detected.

Update Rates

Market data channels are published at different rates depending on their type:

ChannelRateEnv VarRationale
depthUpdate100ms (default)OLYMPUS_MARKET_DATA_INTERVAL_MSTimer-driven — diffs batched per interval
aggTrade100ms (default)OLYMPUS_MARKET_DATA_INTERVAL_MSTimer-driven — trades aggregated per symbol per interval
tradePer-tickEvent-driven — each individual trade published immediately
kline250ms (default)OLYMPUS_KLINE_INTERVAL_MSTimer-driven — in-progress candle snapshots
allMids~1sPublished every 10th depth tick
accountUpdatePer-tickEvent-driven — balance/order changes published immediately

The matching engine processes orders on arrival (microsecond latency). Market data feeds are decoupled and published at fixed intervals to prevent WS channel overflow.

The frontend uses this combination for a TradingView-style experience:

PurposeChannelWhy
Order bookdepthUpdateIncremental level changes at 100ms
Chart candle close + header priceaggTradeSmooth 100ms interpolation between kline snapshots
Chart OHLCV (open, high, low, volume)klineAuthoritative candle state at 250ms, handles mid-candle page loads
Multi-instrument tickerallMidsMid prices at ~1s cadence
Balances, orders, trade historyaccountUpdatePer-tick private data

The trade channel is available for audit/logging but not used by the default frontend — aggTrade provides the same data in a more efficient batched form.

Behavior

  • Multiple subscriptions: A single connection can subscribe to multiple channels simultaneously (e.g., depthUpdate + aggTrade + kline + allMids).
  • Symbol switching: To change symbols, unsubscribe from the old symbol and subscribe to the new one. No reconnection required. Re-run the order book initialization sequence for the new symbol.
  • No heartbeat: The server does not send heartbeat frames. Clients should implement their own reconnection logic.
  • Lagged clients: If a client falls behind on the depth channel, the server re-subscribes to the internal broadcast channel. Some diffs may be lost — clients must re-fetch the REST snapshot if they detect a sequence gap (u > lastUpdateId + 1) or a crossed book (bestAsk < bestBid).

Example with websocat

# Install websocat
cargo install websocat

# Connect and subscribe to depth diffs
echo '{"method":"subscribe","subscription":{"type":"depthUpdate","symbol":"AAPL-USD"}}' | \
  websocat ws://localhost:3000/ws

Implementation Notes

The WebSocket handler fans out from per-channel tokio::broadcast senders. Each message type is serialized once as Arc<str> and shared across all subscribers — no per-client serialization cost.

The REST depth endpoint (GET /api/v1/depth) reads from ArcSwap<EngineSnapshot> — the same lock-free snapshot used by the WS publisher. This ensures the REST snapshot and WS diffs are derived from the same source.

On this page