Protocol
Node WebSocket protocol
How a client connects to a node on /v2/ws - the signed handshake, the sealed control channel, subscriptions, data frames, REST routes and fanout.
wss://<node>/v2/ws is the only way to receive prices from a node. A node serves only the clients in the governance-signed client list, and it encrypts the stream. There is no plaintext mode and no unauthenticated mode.
This page is for developers who implement a client in another language. The Rust SDK already does all of it. See Rust SDK. A browser cannot keep a client key, so it uses the web proxy. See Web proxy.
Important
Decryption proves nothing about a price. Every listed client holds the same broadcast key. Verify every opened frame as a signed frame. See Verify an update.
Overview
The protocol has two layers:
| Layer | Scope | Cryptography |
|---|---|---|
| Session | The handshake, the control channel and key delivery, for one connection. | HPKE (RFC 9180) gives two session keys. AES-256-GCM-SIV seals each control message. |
| Update | The data frames. | One broadcast key for each node and epoch. The node seals each signed frame once. Every subscriber receives the same bytes. |
A client needs:
- a secp256k1 API key that is only for this protocol, whose address governance listed in the client list with an
expires_at. See Client access; - the node's address and endpoint, from a verified registry. See Registry;
- these primitives:
- Keccak-256;
- secp256k1 recoverable ECDSA;
- HPKE (RFC 9180) in base mode, with DHKEM(X25519, HKDF-SHA256), the HKDF-SHA256 KDF and the export-only AEAD;
- HKDF-SHA256;
- AES-256-GCM-SIV.
The API key only signs. It never does key agreement. Each connection uses a new X25519 key, which gives forward secrecy.
Encoding conventions
| Notation | Meaning |
|---|---|
‖ |
Concatenation. |
lp16(x) |
The length of x as a big-endian u16, then x. Used in the signed transcripts T_auth and H_ok. |
lp32(x) |
The length of x as a big-endian u32, then x. Used in the AAD of control messages and data frames, in the key context, and in the digest of the initial keys. |
digest(p) |
The domain digest of a payload p. See Domain separation. |
| Integers | Big-endian. |
The handshake messages are JSON text messages. In them:
- Addresses, public keys and signatures are
0x-hex. - The 32-byte values
challenge,eph,client_nonceandenc, and each entry ofinitial, are hex without a0xprefix. session_deadlineis a JSON number, in unix seconds.- An address in mixed case must have a valid EIP-55 checksum.
All messages after the handshake are binary.
Handshake
N→C {"op":"challenge","proto":"price-oracle/2","node":"0x…","challenge":"…"}
C checks proto, and that node is the address it dialled
C→N {"op":"auth","client_pubkey":"0x…","eph":"…","client_nonce":"…","sig":"0x…"}
N checks the client, then sets up HPKE to eph
N→C {"op":"auth_ok","enc":"…","session_deadline":1757174400,"initial":["…"],"node_sig":"0x…"}
or {"op":"refused","reason":"…"}, and then the node closes the connection
C checks node_sig, then opens the session keys and the initial keys| Message | Field | Content |
|---|---|---|
challenge |
proto |
Always price-oracle/2. |
node |
The node's address, its id in the registry. | |
challenge |
32 random bytes, new for each connection. | |
auth |
client_pubkey |
The API key, a 33-byte compressed secp256k1 public key. |
eph |
A new X25519 public key for this connection, 32 bytes. | |
client_nonce |
32 random bytes, new for each connection. | |
sig |
The API key's signature over the client transcript, 65 bytes. | |
auth_ok |
enc |
The HPKE encapsulated key, 32 bytes. |
session_deadline |
The end of the session, unix seconds. | |
initial |
At most 4 sealed control messages, each a key_update. |
|
node_sig |
The node key's signature over the node transcript, 65 bytes. | |
refused |
reason |
A human-readable reason, for logs. |
Client steps
Open the WebSocket to the endpoint from the registry.
Receive
challenge. Check thatprotoisprice-oracle/2. Check thatnodeis the address that you dialled.- If
nodeis another address, the node probably rotated its key. Refresh the registry once, then connect again. See Rotate a node key.
- If
Make a new X25519 key pair and a new 32-byte
client_nonce.Compute the client transcript:
textT_auth = keccak256(lp16("price-oracle/2 client-auth") ‖ node[20] ‖ challenge[32] ‖ client_nonce[32] ‖ client_pubkey[33] ‖ eph[32])Sign
digest(0x01 ‖ 0x83 ‖ T_auth)with the API key. The 34-byte payload is version1, kind0x83, thenT_auth.Send
authas a text message.Receive
auth_okorrefused.Check the node's signature. Use no field of
auth_okbefore this check passes.textinitial_digest = keccak256(u32(count) ‖ lp32(initial[0]) ‖ lp32(initial[1]) ‖ …) H_ok = keccak256(lp16("price-oracle/2 auth-ok") ‖ T_auth ‖ enc[32] ‖ u64(session_deadline) ‖ initial_digest)Recover the signer of
node_sigoverdigest(0x01 ‖ 0x84 ‖ H_ok). It must be the address that you dialled. Theinitialentries in this digest are the raw bytes of the sealed messages, not their hex text.Run HPKE receiver setup in base mode: the secret X25519 key of step 3,
enc, andinfo = T_auth. Export two 32-byte keys:n2cwith the contextprice-oracle/2 n2c, for node-to-client messages;c2nwith the contextprice-oracle/2 c2n, for client-to-node messages.
Open each
initialentry as a control message from the node, in order. Each one must be akey_update.Send your subscriptions. Send nothing before step 8 passes.
The node signs enc, the deadline and the sealed keys. A man in the middle cannot replace any of them.
Node checks
The node checks the auth message in this order. The first failure refuses the client.
- Its client list is fresh. A node refuses new sessions when no certified fetch confirmed its list for 3 h. A list from a local file has no limit.
- The address of
client_pubkeyis in the client list. - The time is before the client's session deadline.
sigrecovers to the address ofclient_pubkey.ephis a valid X25519 key.
Handshake limits
| Limit | Value |
|---|---|
Time for the client to send auth |
10 s from the upgrade. The node closes the connection after that. |
| Handshakes in progress at one time | 256. A new upgrade above that gets HTTP 503. |
| Largest WebSocket message | 64 KiB |
Session deadline and broadcast keys
The session deadline comes from the client's expires_at, rounded down to the epoch grid:
deadline = floor(expires_at / epoch_secs) · epoch_secsepoch_secs comes from the client list. The default is 60. A session receives a key only if the key's not_after ≤ deadline. Thus no client ever holds a key for an epoch after its deadline.
Each broadcast key is bound to its context. The node sends the secret S and the context numbers. The client adds the address that it dialled and derives the key itself:
ctx = lp32("price-oracle/2 epoch") ‖ node[20]
‖ u64(epoch_index) ‖ u64(generation) ‖ u64(not_before) ‖ u64(not_after)
frame_key = HKDF-SHA256(ikm = S, salt = none, info = "price-oracle/2 frame-key" ‖ ctx), 32 bytes
key_id = HKDF-SHA256(ikm = S, salt = none, info = "price-oracle/2 key-id" ‖ ctx), 16 bytesA key thus never opens the frames of another node, epoch or generation.
| Rule | Value |
|---|---|
| Epoch length | epoch_secs, on a fixed grid. |
| Next key | Delivered 10 s before the epoch boundary. |
| Old key | A client keeps it for 5 s after its not_after, for frames in flight. |
generation |
Strictly increases within one node process, so a wall clock that steps back never repeats a context. |
| Keys held | The Rust SDK keeps at most 8 keys for each node. |
The epoch secret is random and lives only in the node's memory. When governance extends a client, the node sends a session_update with the new deadline, and the keys that the client can now hold.
Control channel
A control message is a binary WebSocket message:
| Offset | Size | Field |
|---|---|---|
| 0 | 1 | 0x02, the wire version |
| 1 | 1 | 0x11, control message |
| 2 | 8 | ctr, u64 |
| 10 | rest | AES-256-GCM-SIV ciphertext with its 16-byte tag |
| Direction | Key | Label |
|---|---|---|
| node → client | n2c |
price-oracle/2 ctl-n2c |
| client → node | c2n |
price-oracle/2 ctl-c2n |
nonce = 0x00000000 ‖ u64(ctr) 12 bytes
aad = lp32(label) ‖ 0x02 ‖ 0x11 ‖ u64(ctr)- Each direction has its own counter. It starts at 0 and increases by exactly 1 for each message.
- The
initialkeys inauth_okuse the first node-to-client counters. The next node-to-client message continues the count. - A gap, a repeat or a message that does not open ends the connection.
The plaintext starts with a tag:
| Tag | Message | Body |
|---|---|---|
0x01 |
application | JSON. See Subscriptions. |
0x02 |
key_update |
u64 epoch_index ‖ u64 generation ‖ u64 not_before ‖ u64 not_after ‖ secret[32]. Times are unix seconds. |
0x03 |
session_update |
u64 deadline, unix seconds. |
0x04 |
close |
One reason code. See Session end. |
A client sends only tag 0x01. The node ends the connection after any other tag, a text message, or a message that does not open.
Subscriptions
The application messages are JSON, inside control messages with tag 0x01.
Client to node:
{"op":"subscribe","feeds":["CRYPTO.BTC/USD","CRYPTO.ETH/USD"]}
{"op":"subscribe","venues":["CRYPTO.BTC/USD"]}
{"op":"subscribe","books":["CRYPTO.BTC/USD"]}
{"op":"subscribe","funding":["CRYPTO.BTC/USD"]}
{"op":"subscribe","feeds":["*"]}
{"op":"unsubscribe","feeds":["CRYPTO.ETH/USD"],"venues":[],"books":[],"funding":[]}
{"op":"ping","id":42}Node to client:
{"op":"subscribe_ack","feeds":["CRYPTO.BTC/USD"],"venues":["CRYPTO.BTC/USD"],"unknown":["CRYPTO.XYZ/USD"]}
{"op":"unsubscribe_ack","feeds":["CRYPTO.ETH/USD"]}
{"op":"pong","id":42}
{"op":"error","code":"bad_request","message":"…"}| List | Values | The node sends |
|---|---|---|
feeds |
Canonical feed symbols, or ["*"] |
The price updates of those feeds. ["*"] selects every feed. |
venues |
Canonical feed symbols | The venue reports of those feeds. There is no * form. |
books |
Canonical feed symbols | The books of those feeds' sources. There is no * form. |
funding |
Canonical feed symbols, or ["*"] |
The funding of those feeds' perpetual sources. ["*"] selects every perpetual. |
Rules:
- The symbols are canonical feed symbols. See Feeds.
GET /v2/feedslists the feeds of a node. - Each list is optional. A missing list is empty.
- A
subscribeadds to the current subscriptions. unknownlists every symbol, from any list, that the node does not serve.- An ack leaves out
venues,booksandfundingwhen they are empty.feedsandunknownare always present. unsubscribe_ackrepeats the lists of the request.- With
"feeds":["*"], the ack says"feeds":["*"]. Anunsubscribedoes not end this selection. Close the connection to leave it. - A book subscription produces frames only for sources that read depth. See How books travel. The node sends the latest snapshot of each book, and the deltas after it, at once.
- A funding subscription gets the latest frame of each perpetual at once. See FundingUpdate v1.
"funding":["*"]in anunsubscribeends it. - The node does not send the last price again when a client subscribes. The first price arrives with the next update or heartbeat of the feed.
idinpingis optional.pongreturns it, ornull.- The node sends no WebSocket pings, and a quiet subscription, such as funding only, can carry nothing for a minute. To find a dead connection, ping. The Rust SDK pings every 10 s and dials the node again after 30 s with no message.
- A message that does not parse, or an unknown
op, gets anerrorwith the codebad_request.
Data frames
A data frame is a binary WebSocket message:
| Offset | Size | Field |
|---|---|---|
| 0 | 1 | 0x02, the wire version |
| 1 | 1 | 0x10, data frame |
| 2 | 16 | key_id |
| 18 | 12 | nonce, random |
| 30 | rest | AES-256-GCM-SIV ciphertext of the signed frame, with its 16-byte tag |
aad = lp32("price-oracle/2 frame") ‖ node[20] ‖ 0x02 ‖ 0x10 ‖ key_idnode is the address that the client dialled. Find the key by key_id. A frame under a key that you do not hold does not open.
The nonces are random. AES-GCM-SIV resists nonce reuse: if a VM snapshot restore repeats a nonce, an attacker learns only whether two frames were equal.
The plaintext is a signed frame. Its first byte is the frame type:
| Frame type | Content | Signed frame | Sealed message |
|---|---|---|---|
| 1 | Price update | 182 bytes | 228 bytes |
| 2 | Venue report | 141–4,237 bytes | 187–4,283 bytes |
| 3 | Book update | 143–4,270 bytes | 189–4,316 bytes |
| 4 | Funding update | 191–222 bytes | 237–268 bytes |
Verify the signed frame as Signing and the price payload and Venue reports and book streams describe.
Session end
The node ends a session with a close control message, and then it closes the socket.
| Code | Reason | When |
|---|---|---|
| 1 | expired | The session reached its deadline. |
| 2 | revoked | Governance removed the client or moved its expiry earlier. |
| 3 | stale | The node's client list has been unconfirmed for 24 h. The node closes every session. |
| 4 | lagged | The client fell too far behind. See Fanout and backpressure. |
| 5 | protocol | The peer broke the protocol. |
The node polls the client list every 30 s by default. A revocation:
- closes the sessions of that client at once;
- replaces the current broadcast key, and any next key already sent, off the epoch grid;
- sends the new keys only to the remaining sessions.
A closed socket does not erase a key that the client already holds. The rotation is what stops a revoked client from reading frames that it gets elsewhere.
REST routes
| Route | Response |
|---|---|
GET /v2/ws |
The WebSocket upgrade for the protocol on this page. |
GET /v2/feeds |
A JSON array of the node's feeds, sorted by symbol. Each item has symbol, feed_id (0x-hex) and expo. |
GET /v2/registry |
The registry document that the node fetched at startup. |
GET /v2/health |
A JSON object: address (the node's address), feeds (feeds configured), subscribers (open subscribers), publishing (feeds that published at least once). |
The REST routes are open and carry no prices.
Warning
GET /v2/registry is a hint, never a trust root. A consumer that trusts it lets the node vouch for itself. Verify the registry against the governance address that you pinned. See Registry.
Fanout and backpressure
The node signs, encodes and seals each update once. Every subscriber receives the same bytes. The node does no cryptography for each subscriber, except the handshake and the control messages.
| Stream | Queue in each client | When the client is slow |
|---|---|---|
| Prices | One slot for each feed. | A newer update replaces the waiting one. |
| Venue reports | One slot for each feed, apart from the price slot. | A newer report replaces the waiting one. A report never replaces a price. |
| Books | One queue for each book: at most 64 frames, from the latest snapshot on. | When the queue is full, the client gets nothing for that book until the next snapshot. |
| Funding | One slot for each perpetual. | A newer frame replaces the waiting one: each frame carries the whole state. |
Prices conflate because a stale price has negative value. A 4-second-old price behind a backlog is worse than no price, because the consumer acts on it as current. Book deltas cannot replace one another, so they queue.
Note
A client sees state, not transitions. A short HALTED update can be replaced by the next healthy update and never arrive. Every frame carries complete state, and the heartbeat makes a longer halt visible. Write client code that reads the current state. Do not count transitions.
More rules:
- Replay to new subscribers. The node keeps the latest funding frame of each perpetual, and a new funding subscriber gets it at once. For each book, the node keeps the latest snapshot and the deltas after it. A new book subscriber gets them at once and does not wait for the next snapshot. This history holds at most 64 frames. When it is full, the node empties it, and a new subscriber waits for the next snapshot.
- Lagged clients. Each pending slot counts toward
max_pending_feeds(default 512). A slot is one feed in one stream, prices or venue reports. When a client has that many slots pending and a new feed arrives, the node sendsclosewithlaggedand drops the connection. The node does the same when a client cannot take a key delivery. - Order. The node gives no order across feeds. The frames of one book arrive in
seqorder.