Integrate
Web proxy API
The JSON WebSocket that oracle-proxy serves to browsers. Endpoints, every message, how to keep a book, limits, and a TypeScript client.
A browser cannot keep a client API key secret, so it cannot use the node transport. oracle-proxy is one listed client. It runs the Rust SDK with its signature checks and its quorum. It serves the result as JSON over one plain WebSocket. A reverse proxy in front of it terminates TLS. Run the web proxy tells an operator how to set it up.
Important
The browser trusts the proxy operator, through TLS. The proxy did the signature, registry, replay, staleness and quorum checks. The node signatures stop at the proxy, so the proxy operator can change prices. A consumer that must not trust an operator uses the Rust SDK directly. See Trust when you use the web proxy.
Endpoints
| Path | Method | Purpose |
|---|---|---|
/v1/ws |
GET, WebSocket upgrade | The data socket. Open to anyone, within the limits. |
/v1/catalog |
GET | The prices, venue feeds and books that the proxy serves now. |
/v1/nodes |
GET | The latest node reports: the nodes message. |
/v1/history |
GET | One feed's past prices from the archive, for a chart. |
/health |
GET | The state of the SDK client inside the proxy. |
A browser connects to wss://<operator host>/v1/ws. The proxy itself speaks plain ws, by default on 127.0.0.1:8792. The reverse proxy adds TLS.
The GET routes send Access-Control-Allow-Origin: *, because their data is public and read-only. A page on any origin can read them. The socket does not check Origin. Each refusal of a GET route is an error body.
/v1/catalog
{
"prices": ["CRYPTO.BTC/USD", "CRYPTO.BTC/USDT", "CRYPTO.ETH/USD"],
"venues": ["CRYPTO.BTC/USDT"],
"books": ["binance:spot:BTCUSDT", "bybit:spot:BTCUSDT", "okx:spot:BTC-USDT"],
"funding": ["bybit:linear_perp:BTCUSDT", "okx:linear_perp:BTC-USDT-SWAP"],
"nodes": true
}| Field | Contents |
|---|---|
prices |
Feed symbols whose prices the proxy serves, sorted. |
venues |
Feed symbols whose venue reports the proxy serves, sorted. |
books |
Book names, venue:market:symbol, sorted. |
funding |
Perpetual names, venue:market:symbol, sorted. |
nodes |
true: the proxy serves the node reports. |
The operator configures prices and venues. The books and funding lists grow as frames arrive from the nodes. The venue symbol is the venue's own, so it differs between venues. A book that has not arrived yet is not in the list. The funding of a node that leaves the registry goes from the list.
/health
{
"status": "ok",
"registry_version": 7,
"quorum": 3,
"nodes": 3,
"connected": 3,
"last_frame_ns": "1791299498663252000",
"clients": 12,
"accepted": 10452,
"venue_reports": 881,
"book_updates": 20311,
"rejected": 0
}| Field | Contents |
|---|---|
status |
How the nodes deliver now. See the list below. |
registry_version |
The registry version that the SDK client trusts. |
quorum |
Nodes that must agree on a price. |
nodes |
Nodes in the registry. |
connected |
Nodes with a connection now. |
last_frame_ns |
When the newest frame from any node arrived. null before the first. |
clients |
Open web sockets. |
accepted |
Price frames accepted. |
venue_reports |
Venue report frames accepted. |
book_updates |
Book frames accepted. |
rejected |
Frames refused: untrusted, replayed, out of time, incoherent or from a retired key. |
archive |
The archive's counters, only on a proxy that runs the archive. |
The status values:
ok: a quorum of nodes is connected, and the newest frame is at most 10 s old.degraded: the newest frame is fresh, but fewer nodes than the quorum are connected.starting: no frame has arrived yet.down: no frame has arrived in the last 10 s.
The HTTP status is 200 for ok and degraded, and 503 for starting and down. The body is the same in each case. The fields update once a second.
/v1/nodes
The latest nodes message, as the socket sends it. Before the first report, the proxy answers 503 with the error unavailable.
/v1/history
One feed's past prices and venue values, from the proxy's archive. Only a proxy with a [history] section has this route. Another proxy answers 404 with the error not_found.
GET /v1/history?feed=CRYPTO.BTC/USD&from=1791619200000&to=1791621000000| Parameter | Contents |
|---|---|
feed |
A symbol from the catalog's prices or venues. |
from |
The start, unix milliseconds. Default: 30 minutes before to. |
to |
The end, unix milliseconds. Default: now. A later time stops at now. |
One request covers at most 24 hours (max_range_hours). The proxy picks the step, from 1 s to 1 day, so that each line has at most 2000 values. Each step divides a day, and steps start on whole multiples of the step since the unix epoch. So the first step can start before from, and two requests share their step times. The archive keeps 7 days by default (retention_days). A step before that has no value.
{
"type": "history",
"feed": "CRYPTO.BTC/USD",
"from_ms": 1791619200000,
"to_ms": 1791621000000,
"step_ms": 1000,
"times_ms": [1791619200000, 1791619201000],
"price": ["82776.45", null],
"conf": ["11.7064", null],
"sources": [
{
"venue": "binance",
"market": "spot",
"symbol": "BTCUSDT",
"bid": ["82858.53", "82858.6"],
"ask": ["82858.54", "82858.61"]
}
]
}| Field | Contents |
|---|---|
step_ms |
The length of one step. |
times_ms |
The start of each step. The first step holds from_ms. Each line below has one value for each step. |
price |
The quorum price at the end of the step. null when the feed was halted or the archive has no row. |
conf |
The confidence half-width at the end of the step. |
sources |
The feed's venues, in the order of the venue report. Each has bid, ask, mark, index and funding at the end of the step. A field the venue never sent is absent. |
The venue values are the venue report's own, before any peg conversion. To draw a USDT venue on a USD chart, multiply it by the CRYPTO.USDT/USD price of the same step.
The proxy reads the archive with a database role that can only read. It refuses a role that can write, so the process that faces the internet holds no credential that can change the archive. Each address can send 30 requests a minute (requests_per_minute). Errors use the error format: bad_request (400), not_found (404), rate_limited (429, Retry-After: 60), busy (503, Retry-After: 1) and unavailable (503).
Message format
Every message is a JSON text frame. A web client sends ops with an op field. The proxy sends messages with a type field. A client message can be at most 64 KiB.
Why numbers are strings
Every 64-bit time and every decimal is a JSON string. A JavaScript number is a 64-bit float, so it loses precision past 2^53. A nanosecond timestamp is about 1.8 × 10^18, far past that limit. A decimal as a string also never goes through a float on the proxy side.
- Parse a time such as
publish_time_nswithBigInt. - Keep a price as a string, or parse it with a decimal library.
- Small counts stay JSON numbers:
nodes,halted_nodes,weight_bps,expo, the bookseq, the pongid, and the counts and microseconds in anodesmessage. - Venue report values are integer mantissas as strings, not decimals. The value is the mantissa × 10^exponent.
Client ops
subscribe
{
"op": "subscribe",
"prices": ["CRYPTO.BTC/USD"],
"venues": ["CRYPTO.BTC/USDT"],
"books": ["binance:spot:BTCUSDT"],
"funding": ["okx:linear_perp:BTC-USDT-SWAP"],
"nodes": true
}| Field | Type | Contents |
|---|---|---|
op |
string | "subscribe" |
prices |
array of strings, optional | Feed symbols whose prices to send. |
venues |
array of strings, optional | Feed symbols whose venue reports to send. |
books |
array of strings, optional | Book names, venue:market:symbol. |
funding |
array of strings, optional | Perpetual names, venue:market:symbol, or "*" for every perpetual. |
nodes |
boolean, optional | true to get the node reports, once a second. |
The proxy answers with subscribed, before any data of the new topics. Then it sends at once, for the names in the request:
- the latest
pricemessage of each price feed; - the latest
venuesmessage of each node, for each venue feed; - a snapshot of each book;
- the latest
fundingmessage of each node, for each perpetual; - the latest
nodesmessage, whennodesistrue.
The node reports count as one subscription, and so does "*" for funding. The limit counts the topics that the socket would hold, each once. A name that the socket holds already, or that the request repeats, does not count again.
A book or a perpetual counts once its name is valid, also before its first frame: the data starts when a node sends it. So a client that subscribes just after a proxy restart loses nothing. A funding name must be a perpetual market. A name that the proxy does not serve goes to unknown in the reply. A request that would pass the subscription limit changes nothing. The proxy answers with the error too_many_subscriptions.
unsubscribe
{
"op": "unsubscribe",
"prices": ["CRYPTO.BTC/USD"],
"venues": [],
"books": ["binance:spot:BTCUSDT"]
}The fields are the same as for subscribe. "nodes": true stops the node reports, and "funding": ["*"] stops all funding. The proxy drops the messages that wait for those topics, and then it answers with unsubscribed. After that reply, no message for those topics comes.
ping
{ "op": "ping", "id": 1 }| Field | Type | Contents |
|---|---|---|
op |
string | "ping" |
id |
unsigned integer, optional | Sent back in the pong. |
The id is at most 2^53 − 1, so a JavaScript number holds it. An unknown op, an unknown field or a larger id is a bad_request error.
Server messages
subscribed
{
"type": "subscribed",
"prices": ["CRYPTO.BTC/USD"],
"venues": [],
"books": ["binance:spot:BTCUSDT"],
"funding": [],
"nodes": false,
"unknown": ["CRYPTO.X/USD"]
}| Field | Contents |
|---|---|
type |
"subscribed" |
prices, venues, books, funding |
The names from the request that the proxy serves, each once, in canonical form. |
nodes |
The nodes flag of the request. |
unknown |
The names from the request that the proxy does not serve. Present only when not empty. |
unsubscribed
{
"type": "unsubscribed",
"prices": ["CRYPTO.BTC/USD"],
"venues": [],
"books": ["binance:spot:BTCUSDT"],
"funding": [],
"nodes": false
}| Field | Contents |
|---|---|
type |
"unsubscribed" |
prices, venues, books, funding |
The names from the request that the proxy serves, each once, in canonical form. |
nodes |
The nodes flag of the request. |
unknown |
The names from the request that the proxy does not serve. Present only when not empty. |
price
The Resolved value of the SDK, with type added. Resolved explains each field.
{
"type": "price",
"feed_id": "0x0523e79c7ed211911a19afaa4def68c896bc72d674bf33c5c64d4d7089e320e2",
"symbol": "CRYPTO.BTC/USD",
"status": "ok",
"price": "86599.95",
"conf": "19.71359135",
"nodes": 3,
"halted_nodes": 0,
"concerns": [],
"publish_time_ns": "1791299498663252000",
"spread_bps": "0"
}| Field | Type | Contents |
|---|---|---|
type |
string | "price" |
feed_id |
string | The feed ID, 0x hex. |
symbol |
string | The feed symbol. |
status |
string | "ok", "degraded" or "halted". |
price |
decimal string or null |
The median of the node prices. null when halted. |
conf |
decimal string or null |
The half-width of the confidence interval. null when halted. |
nodes |
number | Nodes that contributed. |
halted_nodes |
number | Nodes left out because they reported their own feed halted. |
concerns |
array of strings | insufficient_nodes, node_divergence, node_reported_degraded, node_reported_halted, registry_stale. |
publish_time_ns |
string | The newest contributing publish time, unix nanoseconds. "0" when halted. |
spread_bps |
decimal string or null |
The spread between nodes, in basis points of the median. |
What to do for each status and concern is in Status and concerns. In short: use ok and degraded prices and act on conf. Never settle or liquidate on halted.
venues
One node's verified venue report for one feed. The proxy does not combine reports across nodes.
{
"type": "venues",
"symbol": "CRYPTO.BTC/USDT",
"node": "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf",
"report": {
"feed_id": "0xf8e0c6b396a831650eced3ac35b166f564b62a172ce533ce8671d8629ceb6b52",
"publish_time_ns": "1791299498663252000",
"seq": "1791299498663",
"expo": -8,
"entries": [
{
"venue": "binance",
"market": "spot",
"symbol": "BTCUSDT",
"status": "used",
"weight_bps": 3400,
"bid": "8659995000000",
"ask": "8659996000000",
"bid_qty": "150000000",
"ask_qty": "42000000",
"impact_bid": "8659980000000",
"impact_ask": "8660010000000",
"mark": null,
"index": null,
"funding": null,
"exchange_time_ns": "1791299498623252000",
"local_recv_ns": "1791299498661252000"
}
]
}
}| Field | Type | Contents |
|---|---|---|
type |
string | "venues" |
symbol |
string | The feed symbol. |
node |
string | The address of the node that signed the report. |
report.feed_id |
string | The feed ID, 0x hex. |
report.publish_time_ns |
string | When the node signed the report. |
report.seq |
string | The report sequence of this node and feed. It is separate from the price sequence. |
report.expo |
number | The exponent of every price in the entries. |
report.entries |
array | One entry for each configured source, in config order. |
Each entry:
| Field | Type | Contents |
|---|---|---|
venue |
string | binance, bybit, okx, coinbase, kraken or deribit. |
market |
string | spot, linear_perp or inverse_perp. |
symbol |
string | The venue's own symbol for the instrument, such as BTCUSDT. |
status |
string | used, outlier, stale, crossed, no_data, invalid, feed_halted or peg_unavailable. |
weight_bps |
number | The share of the total weight of the price, 0 to 10,000. It is 0 unless the status is used. |
bid, ask |
string or null |
Mantissas at report.expo. |
bid_qty, ask_qty |
string or null |
Mantissas at exponent −8, in base units. |
impact_bid, impact_ask |
string or null |
Mantissas at report.expo, for the impact notional of the feed. |
mark, index |
string or null |
Mantissas at report.expo. |
funding |
string or null |
The venue's own rate over its own interval, a mantissa at exponent −9. |
exchange_time_ns |
string or null |
The venue's own timestamp on its newest observation. |
local_recv_ns |
string | When the node received the newest observation, or "0" with no data. |
In the example, "bid": "8659995000000" at expo −8 is 86599.95.
book
A snapshot or a delta of one book.
{
"type": "book",
"book": "binance:spot:BTCUSDT",
"seq": 1,
"snapshot": true,
"available": true,
"node": "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf",
"publish_time_ns": "1791299498663252000",
"local_recv_ns": "1791299498662252000",
"exchange_time_ns": "1791299498660000000",
"bids": [["86599.95", "1.5"]],
"asks": [["86599.96", "0.42"]]
}A delta for the same book. The level at 86599.95 left, and a level at 86599.9 arrived:
{
"type": "book",
"book": "binance:spot:BTCUSDT",
"seq": 2,
"snapshot": false,
"available": true,
"node": "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf",
"publish_time_ns": "1791299498763252000",
"local_recv_ns": "1791299498761252000",
"exchange_time_ns": "1791299498760000000",
"bids": [
["86599.95", "0"],
["86599.9", "2.1"]
],
"asks": []
}| Field | Type | Contents |
|---|---|---|
type |
string | "book" |
book |
string | The book name, venue:market:symbol. |
seq |
number | The proxy's own sequence for this book. |
snapshot |
boolean | true for the whole book. false for the changed levels only. |
available |
boolean | false when no node has a valid book. The levels are then empty. |
node |
string or null |
The node whose book this is. null when not available. |
publish_time_ns |
string | When the node signed its last book frame. "0" when not available. |
local_recv_ns |
string | When the node received the newest book data. "0" when not available. |
exchange_time_ns |
string or null |
The venue's own time on the newest book data. null when the venue sends none, or when not available. |
bids |
array of [price, size] |
Decimal strings, best (highest) price first. |
asks |
array of [price, size] |
Decimal strings, best (lowest) price first. |
Sizes are in base units. In a delta, a size of "0" removes the level.
funding
One node's funding of one perpetual. Each message carries the whole state, so only the latest one counts. The proxy keeps one slot for each node and perpetual.
{
"type": "funding",
"funding": "okx:linear_perp:BTC-USDT-SWAP",
"node": "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf",
"update": {
"venue": "okx",
"market": "linear_perp",
"symbol": "BTC-USDT-SWAP",
"publish_time_ns": "1791542600000000000",
"seq": "1791542400003",
"basis": "interval",
"rate": "-30339016",
"predicted_rate": null,
"interval_ns": "28800000000000",
"next_funding_time_ns": "1791561600000000000",
"settled": { "rate": "6729944", "time_ns": "1791532800000000000" },
"open_interest": null,
"exchange_time_ns": "1791542585113000000",
"local_recv_ns": "1791542585120000000"
}
}| Field | Type | Contents |
|---|---|---|
type |
string | "funding" |
funding |
string | The perpetual, venue:market:symbol. |
node |
string | The node that signed the update. |
update |
object | The signed funding update. FundingUpdate v1 lists its fields. |
Rates are mantissas at exponent −12: "-30339016" is −0.000030339016. basis is interval for a fraction paid at each funding time, or eight_hours for Deribit's funding_8h. Open interest is a mantissa at −8, with its unit: base, contracts or quote. A field that the venue does not send is null.
nodes
Every node in the registry, as the SDK client inside the proxy sees it. The proxy sends one each second to a client that subscribed with "nodes": true.
{
"type": "nodes",
"time_ns": "1791469482805717000",
"registry_version": 3,
"quorum": 3,
"nodes": [
{
"address": "0x02acaf5c6723d2ffaa8600647e32a227b22e4425",
"weight": 1,
"endpoints": ["ws://127.0.0.1:8803/v2/ws"],
"connection": { "state": "connected", "since_ns": "1791469442141312000" },
"connects": 1,
"failures": 2,
"last_error": "IO error: Connection refused (os error 61)",
"last_frame_ns": "1791469482797795000",
"frames": {
"accepted": 831,
"venue_reports": 7199,
"book_updates": 2798,
"replayed": 0,
"out_of_time": 0,
"incoherent": 0
},
"delivery": { "count": 831, "p50_us": 193, "p90_us": 342, "p99_us": 606, "max_us": 24927 },
"feeds": [
{
"symbol": "CRYPTO.BTC/USD",
"state": "live",
"price": "82437.35104361",
"conf": "11.65840197",
"deviation_bps": "0",
"publish_time_ns": "1791469482765948959",
"seq": "1791469441964"
}
]
}
]
}| Field | Type | Contents |
|---|---|---|
type |
string | "nodes" |
time_ns |
string | When the proxy took the report, unix nanoseconds. |
registry_version |
number | The registry version that the SDK client trusts. |
quorum |
number | Nodes that must agree on a price. |
nodes |
array | One report for each node in the registry, in its order. |
Each node:
| Field | Type | Contents |
|---|---|---|
address |
string | The node's signing address, its id in the registry. |
weight |
number | The registry's weight hint. |
endpoints |
array of strings | The registry's endpoints for the node. |
connection |
object | state: idle, connecting, connected with since_ns, or backoff with retry_at_ns. |
connects |
number | Handshakes that passed. |
failures |
number | Connection attempts and sessions that ended with an error. |
last_error |
string or null |
The last such error. It stays after the node connects again. |
last_frame_ns |
string or null |
When the last accepted frame from the node arrived. |
frames |
object | Accepted price, venue and book frames, and rejections as replayed, out_of_time and incoherent. |
delivery |
object or null |
Node signature to receipt at the proxy, in microseconds, over the last full minute: p50_us to max_us. |
feeds |
array | The node's latest update for each price feed of the proxy. |
Each feed of a node:
| Field | Type | Contents |
|---|---|---|
symbol |
string | The feed symbol. |
state |
string or null |
live, degraded, halted or stale, by the quorum rules. null before the first update. |
price, conf |
decimal string or null |
The node's own price and confidence. |
deviation_bps |
decimal string or null |
The node's price against the median, in signed basis points. null without a median. |
publish_time_ns |
string or null |
When the node signed its latest update. |
seq |
string or null |
The sequence number of that update. |
The report is for monitoring. Only live and degraded updates count toward the quorum, and the quorum alone decides a price. The delivery times compare the node clock with the proxy clock, so they are exact only with clocks in sync.
pong
{ "type": "pong", "id": 1 }| Field | Contents |
|---|---|
type |
"pong" |
id |
The id of the ping, or null if the ping had none. |
error
{ "type": "error", "code": "rate_limited", "message": "at most 10 messages a second" }| Field | Contents |
|---|---|
type |
"error" |
code |
A stable code. See Limits and errors. |
message |
Text for a person. Do not parse it. |
Keep a book
The proxy keeps its own snapshot and delta stream for each book. It builds the stream from the freshest valid book of the SDK, which is always one node's whole book.
- Subscribe to the book. The first
bookmessage aftersubscribedis a snapshot. - On a snapshot, clear the book and load its levels. Keep its
seq. - On a delta, check that
seqis the keptseqplus 1. Then apply each level: set the new size, or remove the level when the size is"0". Keep the newseq. - On a gap in
seq, drop the book. Ignore deltas until the next snapshot. Sendunsubscribeand thensubscribefor the book: the proxy answers a subscribe with a fresh snapshot. - On
available: false, show that there is no book. No node has a valid book now. A later snapshot withavailable: truebrings the book back.
The seq belongs to the proxy, not to a node. Snapshots and deltas of one book share it. The proxy sends a snapshot only at these times:
- when a client subscribes to the book;
- when the source node changes, because two nodes' books are not deltas of each other;
- when the queue of a slow client for the book fills: the queue becomes one snapshot;
- when the book becomes unavailable, and when it comes back.
The proxy compares each book at most every book_interval_ms (100 ms by default) and sends a delta only when a level changed. It sends no periodic snapshot, so after a gap you must ask for one.
Prices and venue reports
- Each socket has one slot for each price feed. A newer price replaces the one that waits. A slow client gets the newest state, not a backlog.
- Each socket has one slot for each pair of venue feed and node.
- Each socket has one slot for the node reports.
- You observe state, not transitions. Read
statuson each message. - The proxy resolves each price feed again every second. A silent upstream thus shows as
halted, not as the last good price. - The proxy does not send a message again when its content is the same as the last one. A price that does not change thus comes once. Use
publish_time_nsfor the age of a price, not the time of the message. - The venue reports and the funding of a node that leaves the registry go. A new subscriber does not get them.
Limits and errors
The socket is open to anyone. These limits bound the cost of one client. The operator sets them in [limits]. The values below are the defaults.
| Limit | Config key | Default | When a client passes it |
|---|---|---|---|
| Open sockets, in total | max_connections |
1024 | HTTP 503 at connect, busy, Retry-After: 5 |
| Open sockets from one IP address | connections_per_ip |
8 | HTTP 429 at connect, rate_limited, Retry-After: 60 |
| New sockets from one IP address each minute | connects_per_minute |
30 | HTTP 429 at connect, rate_limited, Retry-After: 60 |
| Messages from one socket each second, pings and pongs too | messages_per_second |
10 | Error rate_limited, then the proxy closes the socket |
GET requests to /v1/catalog and /v1/nodes each minute |
routes_per_minute |
120 | HTTP 429, rate_limited, Retry-After: 60 |
| Subscriptions on one socket | max_subscriptions |
256 | Error too_many_subscriptions. Nothing changes. |
| Book messages that wait, for one book | max_book_queue |
64 | The queue becomes one snapshot |
| Size of one client message | fixed | 64 KiB | Error bad_request, then the proxy closes the socket |
| Replies that wait, when the client does not read | fixed | 64 | The proxy closes the socket |
| Time for one batch of messages to send | fixed | 30 s | The proxy closes the socket |
| Time with no message from the client, pongs included | fixed | 75 s | The proxy closes the socket |
Each limit is more than 0; the proxy refuses a config with a 0. An IPv6 address counts by its /64, because one client usually owns a whole /64. The proxy pings each socket every 30 s. A browser answers each ping by itself, so a page that is open never reaches the 75 s limit.
Error codes:
| Code | Cause | The socket |
|---|---|---|
bad_request |
The message is not valid JSON, has an unknown op, or has an unknown field. |
Stays open |
bad_request |
The message is binary, or larger than 64 KiB. | Closes |
rate_limited |
More than messages_per_second messages in one second. |
Closes |
too_many_subscriptions |
The request would pass max_subscriptions. |
Stays open |
internal |
The proxy cannot encode a message. This is a bug in the proxy. | Stays open |
A refused HTTP request has the same error body, with the codes bad_request, not_found, rate_limited, busy and unavailable.
A browser WebSocket does not show the HTTP status of a refused upgrade. A refused connect looks like a socket that closes. Wait before you connect again, and wait longer after each failure.
Behind a reverse proxy, the per-IP limits use the last X-Forwarded-For entry when the operator sets trust_forwarded_for. See trust_forwarded_for.
A browser client in TypeScript
The JavaScript SDK does all of the above for you. This section shows the protocol without it.
This client uses the plain WebSocket API and no library. It subscribes to one price and one book, keeps the book, and connects again after a close.
// A minimal browser client for oracle-proxy: one price and one book.
const ENDPOINT = 'wss://oracle.example.com/v1/ws';
const PRICE = 'CRYPTO.BTC/USD';
const BOOK = 'binance:spot:BTCUSDT';
type Level = [price: string, size: string];
interface PriceMessage {
type: 'price';
feed_id: string;
symbol: string | null;
status: 'ok' | 'degraded' | 'halted';
price: string | null;
conf: string | null;
nodes: number;
halted_nodes: number;
concerns: string[];
publish_time_ns: string;
spread_bps: string | null;
}
interface BookMessage {
type: 'book';
book: string;
seq: number;
snapshot: boolean;
available: boolean;
node: string | null;
publish_time_ns: string;
local_recv_ns: string;
bids: Level[];
asks: Level[];
}
type ServerMessage =
| PriceMessage
| BookMessage
| {
type: 'subscribed' | 'unsubscribed';
prices: string[];
venues: string[];
books: string[];
funding: string[];
unknown?: string[];
}
| { type: 'venues'; symbol: string; node: string; report: unknown }
| { type: 'funding'; funding: string; node: string; update: unknown }
| { type: 'pong'; id: number | null }
| { type: 'error'; code: string; message: string };
class LocalBook {
bids = new Map<string, string>();
asks = new Map<string, string>();
available = false;
node: string | null = null;
// null means: wait for a snapshot.
private seq: number | null = null;
/** Applies one message. Returns false on a seq gap. */
apply(message: BookMessage): boolean {
if (message.snapshot) {
this.bids.clear();
this.asks.clear();
this.available = message.available;
this.node = message.node;
} else if (this.seq === null) {
return true; // A delta before the first snapshot: ignore it.
} else if (message.seq !== this.seq + 1) {
this.seq = null;
return false;
}
this.seq = message.seq;
const sides = [
[this.bids, message.bids],
[this.asks, message.asks],
] as const;
for (const [side, levels] of sides) {
for (const [price, size] of levels) {
if (size === '0') side.delete(price);
else side.set(price, size);
}
}
return true;
}
best(): { bid?: Level; ask?: Level } {
// Number() only orders the levels. Keep the strings for display and maths.
const sorted = (side: Map<string, string>, direction: number): Level[] =>
[...side].sort((a, b) => direction * (Number(a[0]) - Number(b[0])));
return { bid: sorted(this.bids, -1)[0], ask: sorted(this.asks, 1)[0] };
}
}
function onPrice(message: PriceMessage): void {
if (message.price === null) {
console.log(`${message.symbol} halted`, message.concerns);
return;
}
// BigInt keeps the nanoseconds exact; milliseconds fit a Date.
const publishedMs = Number(BigInt(message.publish_time_ns) / 1000000n);
console.log(
`${message.symbol} ${message.price} ± ${message.conf} (${message.status})`,
new Date(publishedMs).toISOString(),
message.concerns,
);
}
function onBook(book: LocalBook): void {
if (!book.available) {
console.log(`${BOOK}: no node has a valid book`);
return;
}
const { bid, ask } = book.best();
console.log(
`${BOOK} from ${book.node}: bid ${bid?.[0]} x ${bid?.[1]}, ask ${ask?.[0]} x ${ask?.[1]}`,
);
}
function connect(delayMs = 1000): void {
const socket = new WebSocket(ENDPOINT);
const book = new LocalBook();
const send = (op: object) => socket.send(JSON.stringify(op));
socket.onopen = () => {
delayMs = 1000;
send({ op: 'subscribe', prices: [PRICE], books: [BOOK] });
};
socket.onmessage = (event: MessageEvent<string>) => {
const message = JSON.parse(event.data) as ServerMessage;
switch (message.type) {
case 'subscribed':
if (message.unknown?.length) console.warn('not served:', message.unknown);
break;
case 'price':
onPrice(message);
break;
case 'book':
if (message.book !== BOOK) break;
if (book.apply(message)) {
onBook(book);
} else {
// A gap. A new subscribe brings a fresh snapshot.
send({ op: 'unsubscribe', books: [BOOK] });
send({ op: 'subscribe', books: [BOOK] });
}
break;
case 'error':
console.error(message.code, message.message);
break;
}
};
// A refused connect (HTTP 429) also ends here. Wait longer each time.
socket.onclose = () => {
setTimeout(() => connect(Math.min(delayMs * 2, 30000)), delayMs);
};
}
connect();Notes on the client:
- A new socket starts with an empty book. Its first book message is a snapshot.
- Two messages after a gap stay well inside the default limit of 10 messages a second.
- Book names come from
/v1/catalog. Take them from there, not from a guess.