Integrate
Client access
How a consumer gets a client API key listed, and how the nodes authenticate and encrypt the stream for listed clients.
Nodes serve prices only to listed clients. The only way to receive prices is the node WebSocket /v2/ws. Every stream on it is authenticated and encrypted. There is no plaintext mode and no anonymous mode.
| You build | Who holds the client key |
|---|---|
| A Rust service with the Rust SDK | Your service. It has its own key. |
| A web app on the web proxy | The proxy operator. One proxy key serves all its browsers. |
The client API key
A client API key is a secp256k1 key.
- The key only signs. It never does key agreement. Each connection uses a fresh X25519 key for that.
- The address of the key is the client's id. It names no customer.
- The expiry time of each client is public.
- Use the key only for this protocol. Do not sign anything else with it.
- Keep the key on the consumer's machine. Governance needs only the address.
Get access
Make a key on the consumer's machine. The script needs a checkout of the price-oracle repository and Rust.
bashscripts/client-key.sh --out client.keyThe script prints the address and the next command:
textaddress 0x… key client.key (keep it on the client's machine) Governance registers it with: scripts/add-client.sh --identity <controller> --address 0x… --expires <YYYY-MM-DD>Send the address and the expiry date that you need to governance. Never send the key file.
Governance lists the address. The script calls
add_clienton the registry canister:bashscripts/add-client.sh --identity <controller> --address 0x… --expires 2027-01-01--expirestakes an ISO 8601 date or time, UTC unless it names an offset, or unix seconds. On mainnet the script asks for confirmation before it publishes.ENV=localtargets the local network.Each node fetches the new client list at its next poll, every 30 s by default. The key can then connect.
The key file
scripts/client-key.sh writes the key as plaintext hex. It runs the client_key example of oracle-client.
- The file has mode
0600, so only its owner can read it. - The script never overwrites a file that exists.
- The SDK reads no file. Your code reads the file and passes the key to the builder:
let api_key = oracle_wire::Signer::from_hex(std::fs::read_to_string("client.key")?.trim())?;
let builder = builder.client_key(api_key);oracle-proxyrefuses to start when the group or others can read its key file. The error tells you to runchmod 600on the file.
Governance calls
Governance is the controller set of the registry canister. Only governance can change the client list.
| Call | Candid | Effect |
|---|---|---|
add_client |
(text, nat64) |
Lists an address until expires_at, in unix seconds. expires_at must be in the future. |
set_client_expiry |
(text, nat64) |
Moves the expiry of a listed client, later or earlier. |
remove_client |
(text) |
Removes a client. |
icp canister call <CANISTER_ID> add_client '("0x…", <expires_at> : nat64)' -n icRules that the canister enforces:
- An address is listed once, ever, as a node or as a client.
- A removed client never comes back. A consumer whose key was removed needs a new key.
- Each call publishes a new version of the client list. The registry version does not change.
The client list
The client list is a second document, apart from the registry. The canister signs it with the same threshold key as the registry. It serves it as the certified document /clients.json.
{
"version": 12,
"effective_from": 1754582400,
"epoch_secs": 60,
"clients": [{ "address": "0x7f3a…", "expires_at": 1757174400 }],
"governance_signature": "0x…"
}| Field | Meaning |
|---|---|
version |
Increases with each change. Nodes refuse a lower version. |
effective_from |
The issue time of this version. |
epoch_secs |
The length of a broadcast-key epoch. Session deadlines align to it. |
clients |
The address and the expiry, in unix seconds, of each listed client. |
governance_signature |
The signature of the canister's threshold key. |
Consumers never download the client list. Only nodes read it.
What the transport does
The transport has two layers.
| Layer | Scope | What it does |
|---|---|---|
| Per session | One connection | The handshake, the control channel, and the delivery of broadcast keys. |
| Per update | One node and one epoch | One broadcast key seals each signed frame once. Every subscriber receives the same bytes. |
The Rust SDK runs all of this for you. This section tells you what it does, so that you can read its logs.
Handshake
node → client challenge: the node address, and 32 fresh random bytes
client checks that the node address is the address it dialled
client → node auth: the client public key, a fresh X25519 key, a nonce, a signature
node checks: the client is listed, the deadline is in the future, the signature
node → client auth_ok: the HPKE encapsulation, the session deadline,
the first broadcast keys, the node signature
client checks that the node signature recovers to the address it dialled
client → node the subscription, on the control channel- The client signs a transcript that binds the node address, the challenge, its nonce and its fresh X25519 key.
- The node answers through HPKE (RFC 9180): X25519 key agreement and HKDF-SHA256. HPKE gives two session keys, one for each direction.
- The node signs the encapsulation, the deadline and the sealed keys. A party in the middle cannot replace any of them.
- The client sends nothing else until the node signature checks out.
- If the challenge names another address, the node probably rotated its key. The client fetches the registry once and connects again.
- If the node refuses the client, it sends
refusedwith a reason and closes the connection.
Control channel
The control channel carries the subscriptions and the transport's own messages. AES-256-GCM-SIV seals each message. Each direction has its own key, and a counter that must increase by exactly one.
The node uses the channel to send:
- broadcast keys;
- a new session deadline;
close, with a reason.
Broadcast keys and epochs
- Each node has one broadcast key for each epoch. An epoch is
epoch_secslong, 60 s by default, on a fixed grid. - The node seals each frame once with AES-256-GCM-SIV. Every subscriber gets the same bytes.
- Each key is bound to the node address, the epoch, a generation number, and its start and end times. The client derives the frame key from the secret and the address that it dialled. A key thus never opens frames of another node or another epoch.
- The node sends the next key 10 s before the epoch boundary. The client keeps the old key for 5 s after the boundary, for frames in flight.
- Each frame has a random nonce. AES-GCM-SIV resists nonce reuse: a repeated nonce shows only whether two frames were equal.
Decryption proves nothing
Every listed client holds the broadcast key. Any of them can seal a frame that decrypts. The SDK therefore verifies each decrypted frame like any other frame: the node signature, the registry, the sequence and the clock. A frame that a key holder signed with its own key fails these checks.
Encryption limits who can read the stream. It does not limit who can pass it on. A listed client can forward the plaintext, and the node signatures make it verifiable. That is a contract matter, not a cryptographic one.
Expiry
The session deadline is the expiry of the client, rounded down to the epoch grid:
deadline = floor(expires_at / epoch_secs) × epoch_secs- A session gets a broadcast key only if the key ends at or before the deadline. No client holds a key for an epoch that it outlives.
- At the deadline the node stops sending frames to the session, sends
closewithexpired, and closes the socket. - If governance extends the expiry, the node pushes the new deadline on the control channel. The session continues.
Ask governance for an extension before the expiry. After the deadline, the nodes refuse the handshake, and your feeds go halted.
When a client is removed
Governance removes a client with remove_client, or moves its expiry earlier with set_client_expiry. Each node then does these steps when it adopts the new list:
- It closes the sessions of that client at once, with
closeand the reasonrevoked. - It rotates its broadcast keys off the grid. The new keys replace the current key and any next key that it already sent.
- It sends the new keys only to the sessions that remain.
- It refuses each new handshake from the removed key.
A closed socket does not erase a key that the client already holds. The off-grid rotation is what stops a removed client from reading frames that it captures elsewhere.
What the consumer sees:
- The SDK logs
node connection failedwith the reasonnode closed the session: Revoked. - The SDK connects again, with a backoff from 100 ms to 30 s. Each handshake fails with
refused: unknown client 0x…. - No frames arrive. Once the last updates are older than
max_age, each feed ishaltedwithinsufficient_nodes.
How long a node trusts its list
Each node polls /clients.json every 30 s by default. Each certified fetch confirms the list, also when nothing changed.
| Time without a confirmed list | What the node does |
|---|---|
| 3 h | It refuses new sessions. |
| 24 h | It closes every session, with the reason stale. |
A node that reads its list from a file, for a local run, is exempt from both limits.
Close reasons and errors
The node ends a session with close and one of these reasons:
| Reason | Cause |
|---|---|
expired |
The client reached its session deadline. |
revoked |
Governance removed the client or moved its expiry earlier. |
stale |
The node's client list is too old to trust. |
lagged |
The client fell too far behind. |
protocol |
The peer broke the protocol. |
The SDK logs a failed connection at warn level. These handshake reasons help you find the cause:
| Reason in the log | Cause | What to do |
|---|---|---|
refused: unknown client 0x… |
The address is not in the node's client list. | Check the address with governance. A new listing reaches a node at its next poll. |
refused: client authorisation ended at … |
The client expired. | Ask governance for set_client_expiry. |
refused: client signature does not verify |
The key does not match the transcript. | Check that you load the right key file. |
refused: the node's client list is stale; try another node |
The node has not confirmed its client list for 3 h. | Nothing on your side. The other nodes carry the feed. |
challenge is from node … |
The endpoint now serves another node address. | Nothing. The SDK fetches the registry once and connects again. |