Esc

    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_nonce and enc, and each entry of initial, are hex without a 0x prefix.
    • session_deadline is 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

    text
    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

    1. Open the WebSocket to the endpoint from the registry.

    2. Receive challenge. Check that proto is price-oracle/2. Check that node is the address that you dialled.

      • If node is another address, the node probably rotated its key. Refresh the registry once, then connect again. See Rotate a node key.
    3. Make a new X25519 key pair and a new 32-byte client_nonce.

    4. Compute the client transcript:

      text
      T_auth = keccak256(lp16("price-oracle/2 client-auth")
                         ‖ node[20] ‖ challenge[32] ‖ client_nonce[32]
                         ‖ client_pubkey[33] ‖ eph[32])
    5. Sign digest(0x01 ‖ 0x83 ‖ T_auth) with the API key. The 34-byte payload is version 1, kind 0x83, then T_auth.

    6. Send auth as a text message.

    7. Receive auth_ok or refused.

    8. Check the node's signature. Use no field of auth_ok before this check passes.

      text
      initial_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_sig over digest(0x01 ‖ 0x84 ‖ H_ok). It must be the address that you dialled. The initial entries in this digest are the raw bytes of the sealed messages, not their hex text.

    9. Run HPKE receiver setup in base mode: the secret X25519 key of step 3, enc, and info = T_auth. Export two 32-byte keys:

      • n2c with the context price-oracle/2 n2c, for node-to-client messages;
      • c2n with the context price-oracle/2 c2n, for client-to-node messages.
    10. Open each initial entry as a control message from the node, in order. Each one must be a key_update.

    11. 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.

    1. 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.
    2. The address of client_pubkey is in the client list.
    3. The time is before the client's session deadline.
    4. sig recovers to the address of client_pubkey.
    5. eph is 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:

    text
    deadline = floor(expires_at / epoch_secs) · epoch_secs

    epoch_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:

    text
    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 bytes

    A 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
    text
    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 initial keys in auth_ok use 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:

    json
    {"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:

    json
    {"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/feeds lists the feeds of a node.
    • Each list is optional. A missing list is empty.
    • A subscribe adds to the current subscriptions.
    • unknown lists every symbol, from any list, that the node does not serve.
    • An ack leaves out venues, books and funding when they are empty. feeds and unknown are always present.
    • unsubscribe_ack repeats the lists of the request.
    • With "feeds":["*"], the ack says "feeds":["*"]. An unsubscribe does 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 an unsubscribe ends 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.
    • id in ping is optional. pong returns it, or null.
    • 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 an error with the code bad_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
    text
    aad = lp32("price-oracle/2 frame") ‖ node[20] ‖ 0x02 ‖ 0x10 ‖ key_id

    node 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:

    1. closes the sessions of that client at once;
    2. replaces the current broadcast key, and any next key already sent, off the epoch grid;
    3. 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 sends close with lagged and 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 seq order.