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/wsThe 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"]]
}| Field | Description |
|---|---|
e | Event type |
E | Event time (ms) |
s | Symbol |
U | First update ID in event |
u | Last update ID in event |
b | Bid level changes — [price, qty] strings. qty = "0" = removal |
a | Ask 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
}| Field | Description |
|---|---|
e | Event type |
E | Event time (ms) |
s | Symbol |
t | Trade ID (globally unique, monotonically incrementing) |
p | Price |
q | Quantity |
T | Trade 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
}| Field | Description |
|---|---|
e | Event type |
E | Event time (ms) |
s | Symbol |
a | Aggregate trade ID (per-symbol, strictly consecutive) |
p | Last price in the window |
q | Total quantity across all trades |
f | First price in the window |
l | Last price (same as p) |
h | Highest price in the window |
w | Lowest price in the window |
n | Number of individual trades aggregated |
T | Timestamp (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
}
}| Field | Description |
|---|---|
k.t | Kline open time |
k.T | Kline close time |
k.i | Interval |
k.o/c/h/l | Open, close, high, low prices |
k.v | Volume |
k.n | Trade count |
k.x | Is 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
}
]
}| Field | Description |
|---|---|
e | Event type |
E | Event time (ms) |
a | Account address (lowercase hex) |
B | Current balances (same format as GET /api/v1/balance) |
O | Current open (resting) orders (same format as GET /api/v1/openOrders) |
X | Order 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:
- Subscribe to
depthUpdatefor the symbol - Buffer incoming diffs
- Fetch a snapshot via
GET /api/v1/depth?symbol=AAPL-USD&limit=1000— returns{ lastUpdateId, bids, asks } - Apply the snapshot to your local book
- Drain buffer — apply any buffered diffs where
diff.u > lastUpdateId - Apply future diffs directly, discarding any where
u <= lastUpdateId - 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 snapshotSequence 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:
| Channel | Rate | Env Var | Rationale |
|---|---|---|---|
depthUpdate | 100ms (default) | OLYMPUS_MARKET_DATA_INTERVAL_MS | Timer-driven — diffs batched per interval |
aggTrade | 100ms (default) | OLYMPUS_MARKET_DATA_INTERVAL_MS | Timer-driven — trades aggregated per symbol per interval |
trade | Per-tick | — | Event-driven — each individual trade published immediately |
kline | 250ms (default) | OLYMPUS_KLINE_INTERVAL_MS | Timer-driven — in-progress candle snapshots |
allMids | ~1s | — | Published every 10th depth tick |
accountUpdate | Per-tick | — | Event-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.
Recommended Client Subscriptions
The frontend uses this combination for a TradingView-style experience:
| Purpose | Channel | Why |
|---|---|---|
| Order book | depthUpdate | Incremental level changes at 100ms |
| Chart candle close + header price | aggTrade | Smooth 100ms interpolation between kline snapshots |
| Chart OHLCV (open, high, low, volume) | kline | Authoritative candle state at 250ms, handles mid-candle page loads |
| Multi-instrument ticker | allMids | Mid prices at ~1s cadence |
| Balances, orders, trade history | accountUpdate | Per-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/wsImplementation 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.