Concepts
Registry
The registry canister on the Internet Computer lists the nodes and clients. How a consumer trusts it, and how a key rotation reaches the consumer.
The registry is the trust root for membership. It says which addresses are nodes and which addresses are clients, now and before. It is a canister on the Internet Computer. Nodes and consumers verify what the canister publishes. They never trust the view of the registry that a node gives them.
Mainnet values
The SDK and the node have these values built in, in oracle_client::mainnet:
| Value | Mainnet |
|---|---|
| Canister ID | xb5gv-diaaa-aaaaf-qg5qa-cai |
| Governance address | 0x7FB990Cc1111a00931B6396E5780C79574e36897 |
| Registry URL | https://xb5gv-diaaa-aaaaf-qg5qa-cai.raw.icp0.io/registry.json |
The governance address is the address of the threshold key that signs every registry version and every client list. Pin it in your consumer. Never read it from the document that you fetch.
What the canister holds
| State | Content |
|---|---|
nodes |
address → endpoints, weight, added_at |
retired |
address → added_at, removed_at |
clients |
address → expires_at, added_at |
removed_clients |
the client addresses that governance removed |
version, effective_from, quorum |
the metadata of the published document |
An address appears in this state at most once, ever.
One key per node. A node has one secret seed. The address of its secp256k1 key is the node id. The registry lists nodes by address, and the SDK keys all per-node state by address. There is no separate identity key and no operator field.
The published document
The canister publishes one entry for each listed node:
{
"version": 7,
"effective_from": 1754582400,
"nodes": [
{
"address": "0x19e7...ff2a",
"endpoints": ["wss://eu1.oracle.example/v2/ws"],
"weight": 1
}
],
"quorum": { "min_nodes": 3, "recommended": 5 },
"governance_signature": "0x..."
}| Field | Meaning |
|---|---|
version |
Rises by one with each change. A consumer rejects a version that is not strictly newer than the one it holds. |
effective_from |
When this version was issued, in unix seconds. It is not a freshness signal. |
nodes[].address |
The node key, which is also the node id. |
nodes[].endpoints |
1 to 16 ws:// or wss:// URLs. |
nodes[].weight |
A weight hint, at least 1. The SDK takes a plain median and does not use it. |
quorum |
Guidance from governance. The consumer chooses its own quorum. |
governance_signature |
Threshold ECDSA over the canonical bytes of the registry. Low-S, with an Ethereum recovery byte. |
The document does not name who runs each node. Governance checks operator diversity when it decides whom to list.
The client list
Nodes serve prices only to listed clients. Governance lists each client in a second document, the client list. The same threshold key signs it. The canister serves it at /clients.json.
{
"version": 12,
"effective_from": 1754582400,
"epoch_secs": 60,
"clients": [{ "address": "0x7f3a...", "expires_at": 1757174400 }],
"governance_signature": "0x..."
}- The address of the client API key is the client id. It names no customer.
- A removed client address never comes back.
- Nodes fetch the list every 30 s by default and check its certificate.
- A node refuses new sessions when no certified fetch has confirmed the list for 3 hours. It closes every session after 24 hours.
- Consumers never download the client list. A client change does not change the registry version.
See Client access for how to get a client key listed.
Rules the canister enforces
The rules are code, not policy. Each rule is a unit test.
- An address is used once.
add_node,replace_nodeandadd_clientrefuse an address that is, or ever was, a node or a client. A revoked client key never comes back. A client key never becomes a price-signing key. - Never below quorum. The canister refuses a removal that would leave fewer listed nodes than
quorum.min_nodes. A replacement keeps the count. - Versions only on change. The canister signs a new version only when governance changes something. Nothing signs again on a timer. A failed signing round changes nothing, and governance retries the same call.
Governance
Governance is the controller set of the canister, and it is the only caller of the update methods. Nodes never call the canister. inspect_message drops ingress from any other principal, so spam costs the canister nothing. Each update method also has a guard that rejects any other caller.
| Call | Effect |
|---|---|
add_node(entry) |
Lists a node. The address must be unused. New registry version. |
replace_node(old, new) |
Rotation. In one version, new takes the place of old with the same endpoints and weight. old is retired. |
remove_node(address) |
De-lists the node and retires its address. New registry version. |
set_quorum(min_nodes, recommended) |
Changes the quorum guidance. New registry version. |
add_client, set_client_expiry, remove_client |
Change the client list. New client-list version. |
republish, republish_clients |
Sign a document again with no change. |
get_registry, get_clients, get_node_listing |
Queries. get_node_listing returns added_at and removed_at of an address. |
A node operator sends the node entry, or the two addresses that oracle-node rotate prints, to governance off-chain. Governance makes the call.
Warning
Whoever controls the canister controls the trust root. A canister with a single controller is one governance key with extra steps. The design requires a multi-principal controller set, such as an SNS or a multisig canister, with a timelock on upgrades and a published wasm hash. The choice of governance body is still open.
How a consumer trusts the registry
A consumer needs two proofs. The signature proves that a document is genuine. The IC certificate proves that it is current. A consumer needs no Internet Computer agent.
The signature
The consumer pins the governance address. It accepts a document only when:
- the governance signature recovers to the pinned address;
- the version is strictly newer than the version it holds.
For this reason a node cannot vouch for its own registry, and an old version cannot come back to reinstate a revoked key.
The certificate and freshness
A signature does not prove that a document is current. A cache or a relay can replay an old version. The SDK and the nodes therefore check the IC certificate on every fetch of /registry.json and /clients.json:
- The certificate must chain to the root key of the network. The mainnet root key is built in.
- The certificate time must be within 5 minutes of the local clock. A replayed response fails like an outage.
- Mainnet sources fetch from the
rawdomain, which passes the response of the canister through unchanged.
The canister publishes only when something changes, so the age of a document says nothing. Each fetch that verifies confirms the version that it carries. The SDK fetches the registry on a jittered schedule, at most registry_poll_interval (60 s by default) apart. If no fetch confirms the registry for max_registry_age (48 hours by default), every feed reports degraded with the concern registry_stale. The prices are still verified, but the membership may be out of date.
Note
GET /v2/registry on a node serves the view of that node. It is a hint, never a trust root. A consumer that accepted it would let the node vouch for itself, and so add itself.
Other networks
HttpRegistrySource::canister(id) fetches from the mainnet raw domain and checks against the mainnet root key. For another network, such as a local network, pass Certification::new(id, root_key) to HttpRegistrySource::new.
How a rotation reaches the consumer
A rotation is governance's replace_node. In one registry version the new address takes the place of the old one, at the same endpoint. There is no overlap window. The consumer learns about it from the first frame that it cannot verify, or from a handshake challenge that names the new address.
- An unknown signer, or a
WrongNodechallenge, triggers one fetch from the registry source. The SDK fetches at most once perrefresh_interval(10 s by default). Anyone who can inject a frame can trigger a fetch, so this floor stops a hostile relay from turning the client into a load generator against the registry. refresh_registryadopts the document in place if it is strictly newer and signed by the pinned governance address. There is no reconnect and no loss of feed state. Nodes that the new version de-lists leave the quorum at adoption, not when their last update ages out.- The SDK retries the frame. The quorum seat moves to the new address in the same adoption that drops the old one, so the node never counts twice. The new address starts a fresh replay floor. That is safe, because the new key never signed before.
- The old key is now retired, not unknown. The SDK refuses its frames as
RetiredKeyand does not fetch again. A few such frames right after a rotation were in flight. A steady stream of them means that someone uses a stolen key.
From step 2 of the operator procedure until the first frame under the new key, the node is out of the quorum of that consumer. The other nodes carry the feed, as during a restart. This is a blip of one node, not an outage of the consumer. Governance staggers rotations so that a consumer never loses two seats at the same time. See Key rotation.
replace_node and remove_node
| Call | What the consumer sees |
|---|---|
replace_node(old, new) |
The seat moves from old to new. Frames from old give RetiredKey. The number of nodes stays the same. |
remove_node(address) |
The node leaves the quorum at adoption. Frames from it give RetiredKey. The number of nodes drops by one. |
The SDK refuses a new registry version that would leave fewer nodes than its quorum. It keeps the registry that it has and returns an error. A registry that silently shrank below the quorum would halt the consumer with no obvious cause.
After subscribe, the connections of the SDK follow the registry. When the SDK adopts a newer version, it connects to added nodes, disconnects removed nodes, and reconnects a node whose endpoint or address changed.
Stored frames
The live path verifies a frame only against a listed address. A stored frame, for an audit or a dispute, uses a separate path. verify_historical checks the frame against the listing of its signer from get_node_listing:
added_at ≤ publish_time_ns < removed_atWithout removed_at, a stolen key would verify backdated frames forever. The canister states these times. No signature covers them.
Keys have no expiry. A node key rotates on suspicion of compromise, not on a schedule. A stolen key that nobody notices keeps working until governance replaces or removes the node. Watch for divergence between nodes.