Operate
Run a node
Install a node with two commands, register it with governance, read its status lines and logs, and run it under systemd.
A node connects to the venues, aggregates their quotes, signs each update, and serves listed clients over /v2/ws. Nodes do not talk to each other. Each node is an independent signer. See Architecture.
The short path, on the node's Linux machine:
curl -fsSL https://raw.githubusercontent.com/markoutlabs/oracle-node/main/release/install.sh | sh
sudo oracle-node init- The install script builds the
oracle-nodebinary. See Install oracle-node. sudo oracle-node initasks for the endpoint. Then it sets up the service and starts it. See The system install.- Send the
add_nodecall thatinitprints to governance. The node serves when the registry lists it.
Install oracle-node
On Linux, run the install script. It builds the node from source on your machine:
curl -fsSL https://raw.githubusercontent.com/markoutlabs/oracle-node/main/release/install.sh | shThe script does these steps:
- If there is no C compiler, it installs one with the system's package manager: apt, dnf, yum, apk, zypper or pacman.
- If there is no Rust 1.85 or newer, it installs Rust with
rustup. It does not change your shell profile. - It builds
oracle-nodewithcargo install --locked. This takes about 5 to 10 minutes. With less than 3.5 GB of memory, it builds one job at a time. - It installs the binary to
/usr/local/bin, with sudo if necessary. Without root and sudo, it uses~/.local/bin.
Put options after sh -s --:
curl -fsSL https://raw.githubusercontent.com/markoutlabs/oracle-node/main/release/install.sh | sh -s -- --version v0.1.0 --to /opt/oracle/bin| Option | Effect |
|---|---|
--version <ref> |
Builds that tag, branch or commit. The default is main. |
--to <dir> |
Installs the binary to that directory. Keep it outside your home folder. |
Note
The service user cannot open a home folder. sudo oracle-node init therefore refuses a binary under /home or /root.
The build needs about 4 GB of memory. With 2 GB, add swap; the build is then slower.
What a node needs
| Need | Why |
|---|---|
The oracle-node binary |
The install script downloads it. make node builds it for this machine. |
| A synced host clock | Consumers compare publish times with their own clocks. Use chrony or systemd-timesyncd. See The clock. |
| Outbound connections | To the venues and to the registry canister. |
| One inbound port | For clients on /v2/ws and for the open REST routes. |
| A registry entry | Governance must list the node's address. Until then the node waits, and serves nothing. |
| A client list | Governance must publish the client list. The node refuses to start without it. |
| Core dumps off, swap encrypted or off | The key is in memory while the node runs. The systemd unit from init sets both. |
| An independent failure domain | Use a different cloud provider, region and operator from the other nodes. Five nodes in one zone, or under one operator's credentials, act as one node. |
The deployment needs at least 5 nodes for a quorum of 3. That leaves room for one planned and one unplanned outage. Diversity is more important than latency: the median absorbs a 40 ms difference between nodes, but not a correlated outage.
The node serves plain WebSocket on its listen address. It does not terminate TLS. To publish a wss:// endpoint, put a TLS-terminating reverse proxy in front of the node.
Create the node with init
oracle-node init generates the node's secret seed and derives its two keys. It prints:
- the secp256k1 address, which is the node's id in the registry;
- the ed25519 public key, for venues that verify ed25519, such as tapray-perps;
- the registry entry for governance, which holds no secret.
Nothing that the node signs verifies for anyone until governance adds the entry to a new registry version.
The system install
As root, oracle-node init installs the node as a systemd service:
sudo oracle-node initIt asks one question: the endpoint, the WebSocket URL that clients dial, such as ws://203.0.113.7:8791/v2/ws. After you confirm, it does these steps:
- It creates the system user
oracle-node, with no login shell, if the user does not exist. - It makes
/var/lib/oracle-node, mode0700, owned byoracle-node. It writes the key, a generated passphrase, the config and the registry entry there. - It installs
/etc/systemd/system/oracle-node.service, enables it at boot, and starts it. - If ufw or firewalld is active, it opens the listen port for TCP. For any other firewall, such as the cloud provider's, it tells you to open the port.
- It prints the node's address, the
add_nodecall for governance, and the ed25519 publisher key.
The node starts at once, and waits until the registry lists it. See Start the node.
For a script, give the endpoint with --endpoint. Without a terminal, the flag is necessary:
sudo oracle-node init --endpoint ws://203.0.113.7:8791/v2/ws--canister, --governance, --gateway and --root-key work as in From flags.
The system install stops before it writes anything in these cases:
- systemd does not run the machine;
/var/lib/oracle-nodeis not empty, or the unit exists: the machine already has a node;- the binary is under a home folder.
If a later step fails, the node is not in the registry yet, so nothing is lost. The error shows the two commands that remove what the install made. Then run init again.
The wizard
Without root, at a terminal, oracle-node init is a wizard. Its first note names the registry canister and the governance address. It then asks one question at a time:
- Where the files go. The default shows as an absolute path. A leading
~is your home directory. The wizard refuses a directory that already holds a node key. - The public endpoint. The WebSocket URL that clients dial. On mainnet, a loopback address such as
127.0.0.1gets a warning at once, because no client elsewhere can dial it. - How the node opens its key at start.
- A generated passphrase in a
0600file. systemd needs this, because nobody can type at a service. - A passphrase that you type at each start. Nothing to steal lies next to the key.
- A generated passphrase in a
- Whether to write a systemd unit, and the user that runs it. The wizard asks this only for a passphrase file.
The wizard writes nothing until you confirm the summary. Esc or Ctrl-C leaves nothing behind. The wizard checks each answer with the canister's own rules, so the registry accepts the entry that it writes.
The wizard runs only when stdin, stdout and stderr are all terminals.
From flags
With --out DIR, every value comes from flags, and the output is plain text that a script can read.
oracle-node init --out /srv/oracle-node \
--endpoint wss://node1.example.com/v2/ws \
--passphrase-file /srv/oracle-node/passphrase \
--service-user oracle| Flag | Meaning | Default |
|---|---|---|
--endpoint URL |
The WebSocket URL that clients dial. It must start with ws:// or wss://. |
ws://127.0.0.1:8791/v2/ws; as root, init asks |
--out DIR |
Write the files to DIR. As root, this skips the system install. |
none: the wizard at a terminal |
--passphrase-file FILE |
An existing 0600 file with the passphrase that encrypts the key. Without it, --out prompts at the terminal. Needs --out. |
none |
--service-user USER |
Also write a systemd unit that runs the node as USER. Needs --out and --passphrase-file. |
none |
--canister ID |
A registry canister other than the mainnet one. Needs --governance. |
the mainnet canister |
--governance ADDRESS |
The governance address of --canister. Needs --canister. |
the mainnet address |
--gateway URL |
The HTTP gateway of a local network. Needs --canister and --root-key. |
none |
--root-key HEX |
The root key of a local network. Needs --gateway. |
none |
--out and --passphrase-file expand a leading ~.
Caution
Without --out, and not at a terminal, init prints the secret seed to stdout. The secret then sits in scrollback and shell history. Use --out.
init never writes over an existing key. A new key would drop the node out of the registry while it kept running.
What init writes
| File | Content |
|---|---|
signing.key |
The seed, as an age file encrypted with the passphrase. Mode 0600. |
passphrase |
The generated passphrase, mode 0600. Only when the wizard generates one. |
node-entry.json |
The registry entry for governance: address, endpoints and weight (1). |
config.toml |
A config with absolute paths and every feed of the network. It needs no edit. |
oracle-node.service |
The systemd unit. On request, or in /etc/systemd/system for a system install. |
The registry canister
A node always fetches the registry and the client list from the registry canister. init does not ask where they come from. The mainnet values are built in:
| Value | Built-in mainnet value |
|---|---|
| Canister id | xb5gv-diaaa-aaaaf-qg5qa-cai |
| Governance address | 0x7FB990Cc1111a00931B6396E5780C79574e36897 |
| Registry URL | https://<canister>.raw.icp0.io/registry.json |
| Client list URL | https://<canister>.raw.icp0.io/clients.json |
The raw domain hands over the canister's response unchanged. The node checks the IC certificate itself. See Registry.
For a local network, pass --canister and --governance together, with --gateway and --root-key. icp network status --json prints the gateway URL and the root key:
oracle-node init --canister <CANISTER_ID> --governance <GOVERNANCE> \
--gateway http://127.0.0.1:8000 --root-key <root_key from icp network status --json>Register with governance
init prints the call that adds the node:
icp canister call <CANISTER_ID> add_node '("{\"address\":\"0x…\",\"endpoints\":[\"wss://…/v2/ws\"],\"weight\":1}")' -n icSend node-entry.json to governance. Governance runs the call. Governance is the only caller of the canister. The node never calls it and holds no IC identity.
The config file
One file describes the whole node. The node derives its venue subscriptions from the feed sources, so there is no separate instrument list. The node refuses unknown keys.
This is the shape of the config that init writes. The binary holds the network's feed set, about 225 feeds, and init writes all of them. Every node then serves the same feeds, so a quorum can form on each one.
tick_interval_ms = 25
[server]
listen = "0.0.0.0:8791"
max_pending_feeds = 512
[signing]
key_file = "/srv/oracle-node/signing.key"
passphrase_file = "/srv/oracle-node/passphrase"
[registry]
url = "https://<canister>.raw.icp0.io/registry.json"
canister_id = "<canister>"
governance_address = "0x…"
[clients]
url = "https://<canister>.raw.icp0.io/clients.json"
poll_secs = 30
# The network's 225 feeds, from oracle-node 0.1.0. …
[[feeds]]
symbol = "CRYPTO.BTC/USD"
impact_notional = "20000"
peg = "CRYPTO.USDT/USD"
min_sources = 2
sources = [
{ venue = "coinbase", market = "spot", symbol = "BTC-USD" },
{ venue = "kraken", market = "spot", symbol = "BTC/USD" },
{ venue = "binance", market = "spot", symbol = "BTCUSDT", converted = true },
{ venue = "bybit", market = "spot", symbol = "BTCUSDT", converted = true },
{ venue = "okx", market = "spot", symbol = "BTC-USDT", converted = true },
]
# … and the other feedsThe listen port is the port of the endpoint, or 8791 when the endpoint names no port. init sets max_pending_feeds to twice the feed count, rounded up to a power of two, and at least 512.
| Key | Meaning | Default |
|---|---|---|
tick_interval_ms |
The timer that evaluates every feed, for heartbeats and stale venues. A price move is evaluated when it arrives. | 25 |
server.listen |
The address to listen on. | required |
server.max_pending_feeds |
The pending feed slots a client can have before the node disconnects it as lagged. See Fanout and backpressure. | 512 |
signing.key_file |
The age key file. |
— |
signing.passphrase_file |
A 0600 file with the passphrase. Without it, the node prompts at startup, and it refuses to start without a terminal. |
prompt |
signing.key_env |
An environment variable with a plaintext hex key. Only for a local test setup with seeded keys. | — |
registry.url |
The canister's /registry.json. The node fetches it at startup. |
— |
registry.path |
A signed registry file, in place of url. It stays pinned until a restart. For local runs. |
— |
registry.canister_id |
The canister whose IC certificate each fetched document must carry. | — |
registry.root_key |
The root key of the canister's network, DER in hex. Only off mainnet. | mainnet key |
registry.governance_address |
The pinned governance address. The node verifies the registry and the client list against it. | required |
clients.url |
The canister's /clients.json, polled every poll_secs. |
— |
clients.path |
A signed client list file, in place of url. Read once. For local runs. |
— |
clients.poll_secs |
Seconds between client list fetches. This limits how long a revocation takes. | 30 |
[[feeds]] |
The feeds that the node publishes. See Feeds and Aggregation. | — |
[[endpoints]] |
A WebSocket URL in place of a venue market's built-in one. | — |
Give exactly one of url and path in [registry] and in [clients].
The pinned governance address lives in the config, not in the registry. A document that named its own author would vouch for itself.
[[endpoints]] replaces the built-in URL of one venue market: a regional host, a proxy, or a local feed for tests. The node refuses an endpoint that no feed reads. For Binance, the URL is the combined-stream base, and the node adds the stream names:
[[endpoints]]
venue = "binance"
market = "spot"
url = "wss://data-stream.binance.vision/stream"Start the node
oracle-node start /srv/oracle-node/config.toml| Flag | Meaning |
|---|---|
--tail |
Log every decoded observation that goes into the aggregator, and every signed update that comes out, under the tail target. |
--record FILE |
Write every raw venue frame, with its receipt time, to FILE as JSON lines. oracle-replay plays the file back through the same pipeline. |
--log-format FORMAT |
auto, pretty, plain, journald or json. The default is auto. |
At startup the node proves everything that would make it useless, before it opens its port:
- It reads the config and resolves the feeds.
- It opens the key with the passphrase file, or with a prompt.
- It fetches the registry, checks the IC certificate, and verifies the governance signature.
- It checks that the registry lists its own address. If not, it waits, and fetches the registry again every 30 seconds.
- It fetches and verifies the client list.
- It opens the listen port and starts to serve.
Important
A node that the registry does not list does not ingest or serve. Otherwise it would sign, every local metric would look healthy, and every consumer would discard its output. It logs one warning, the registry does not list this node; waiting for governance to add it, and waits. SIGTERM stops the wait cleanly.
The node stops cleanly on SIGTERM or SIGINT.
| Exit code | Meaning |
|---|---|
| 0 | Clean stop. |
| 78 | A config or key file error. Only an edit fixes it, so systemd does not restart the node. |
| 1 | Any other error, such as a failed registry or client list fetch. |
| 130 | You cancelled the init wizard. Nothing was written. |
Console, status lines and logs
At a terminal, the node prints a banner once it serves. The banner shows:
- the node's address and the governance address;
- the registry version;
- the number of listed clients, and the client list version;
- the feeds, and the number of venue connections.
Log formats
Logs go to stderr. --log-format auto picks the format from where stderr goes:
| Format | Chosen when | Looks like |
|---|---|---|
journald |
stderr is the journal stream that systemd connected | <6> INFO status feeds=12/12 ...: no time, no colour, a syslog priority |
pretty |
stderr is a terminal | local time, colour, compact, and the banner |
plain |
anything else, such as a file | UTC time, no colour |
json |
only on request | one JSON object on each line, for log shippers |
RUST_LOG filters the logs as usual. The default level is info.
The status line
Once a minute the node logs a status line. The first one comes one minute after startup.
| Field | Meaning |
|---|---|
feeds |
Feeds that published in the last minute, out of all feeds. |
clients |
Open client sessions. |
connections |
Venue connections up, out of all venue connections. |
updates |
Price updates signed in the last minute. |
client_list |
The client list version in use. |
signed_p50_ms, signed_p99_ms |
The time from venue data to the signed update. |
The latency fields count only updates that a price move or a flag change caused. The time runs from the newest data at the first evaluation that fired. A wait in the rate cap counts. A heartbeat repeats old data, so it does not count. See Latency.
A venue lag line follows. For each venue it shows p50 and p99 from the venue's own timestamp to the node's receipt. This lag also holds the offset between the venue clock and the node clock. The line shows the share of venue timestamps ahead of the node clock. A large share means that the node clock is behind. A lag below zero reads as zero.
Other log lines
| Message | Meaning |
|---|---|
serving subscribers |
The node opened its port. The fields repeat the banner. |
client session opened |
A client passed the handshake. |
client session closed |
The node ended a session, with the reason. |
adopted client list |
A newer client list arrived. revoked counts the sessions it closed. |
client sessions revoked; broadcast keys rotated |
A revocation forced new broadcast keys. |
client list fetch failed, client list refused |
The poll failed, or the list did not verify. |
ingest stopped |
A venue adapter stopped with an error. |
pipeline stopped |
The node shut down. The fields count observations, updates, connects and disconnects. |
See what a venue sends
--tail is the fastest way to see what a venue sends. It shows the data after normalisation, and before any weight, filter or aggregation:
12:03:04 INFO observation venue=deribit market=inverse_perp symbol=BTC-PERPETUAL
kind="book_top" price=64972.25 bid="64972" ask="64972.5"
bid_qty="2.292679923659" ask_qty="1.610373619608" lag_ms="49"
12:03:04 INFO published status="ok" price="64971.86250214" conf="6.49718625"
seq=1 sources=2 venues={Kraken, Deribit}lag_ms is local_recv − exchange_time. It is for diagnosis only. Staleness always uses the node clock, never the venue clock.
Run under systemd
sudo oracle-node init installs and starts the unit. Without root, init writes oracle-node.service on request: --service-user USER, or yes in the wizard. The unit needs a passphrase file, because nobody can type at a service.
| Unit setting | Effect |
|---|---|
Type=notify |
systemctl start returns once the node serves. systemctl status shows what the node does. |
Restart=on-failure, RestartSec=10s |
systemd restarts the node after a failure. |
RestartPreventExitStatus=78 |
No restart after a config error. Only an edit fixes it. |
TimeoutStopSec=20s |
The node gets SIGTERM and stops cleanly. |
LimitCORE=0, MemorySwapMax=0, UMask=0077 |
No core dumps and no swap, because the key is in memory. |
ProtectSystem=strict, ProtectHome=read-only and more |
A sandbox: a read-only system, its port, and outbound connections. |
Wants= and After=network-online.target |
The node fetches the registry at start. |
To install and start a unit that init wrote without root:
sudo install -m 0644 /srv/oracle-node/oracle-node.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now oracle-node
journalctl -u oracle-node -f # add -p warning for warnings onlyUnder journald, the node writes plain lines with a syslog priority prefix. journalctl -p thus filters by level.
The node tells systemd its state through sd-notify:
| Stage | systemctl status shows |
|---|---|
| Startup | loading the config and the key, fetching the registry, fetching the client list |
| Unlisted | waiting for governance to add <address> to the registry. The unit stays activating. |
| Ready | serving on <address>, with READY=1 |
| Each minute | The status line, for example 11/12 feeds published, 3 clients, 7/8 venue connections up, … |
| Shutdown | STOPPING=1 |
init warns about three cases:
- a loopback endpoint on mainnet;
- a unit that starts a binary from a
targetbuild folder. Install the binary, for example in/usr/local/bin, and editExecStart; - a port below 1024. Uncomment the two capability lines in the unit.
start --record FILE writes outside the read-only system. Add ReadWritePaths= for that folder. The unit has a commented example.
Keep the passphrase encrypted
The 0600 passphrase file protects the key against someone who copies the key file alone. For more protection, encrypt the passphrase with systemd-creds encrypt and give it to the unit:
LoadCredentialEncrypted=passphrase:/etc/oracle-node/passphrase.credThen set passphrase_file = "/run/credentials/oracle-node.service/passphrase" in the config. oracle-node rotate then needs --passphrase-file with the plain passphrase.
The clock
The node uses one clock for receipt times and publish times. The clock never goes back. It starts from the wall clock and then follows the monotonic system clock.
The monotonic clock drifts from wall time: about 1.4 ms a minute on one macOS machine. That is about 2 s a day, more than the SDK's 2 s max_age. Thus the node re-anchors its clock to wall time every second:
- When the node clock is behind wall time, it steps forward.
- When the node clock is ahead, it runs at 90% speed until it matches. A step back would date the next update before the last one.
When one correction is larger than 100 ms, the host clock stepped, and the node logs a warning:
| Warning | Meaning |
|---|---|
the wall clock stepped forward; the node clock followed |
The node clock jumped forward by step_ms. |
the wall clock stepped back; the node clock runs slow until it matches |
The node clock is lead_ms ahead and slews back. |
Run the host with a synced clock, such as chrony or systemd-timesyncd. Consumers compare publish_time_ns with their own clocks. The venue lag means nothing without a synced clock. Check the host clock with chronyc tracking (Linux) or sntp time.apple.com (macOS).
The sequence numbers start from the clock in milliseconds at startup. A restart thus continues above the previous run.
Health and metrics
GET /v2/health returns the node's address, the number of feeds, the number of open subscribers, and the number of feeds that published at least once. See REST routes.
The node has no metrics endpoint. The metrics export interface is not chosen yet. The status line, the venue lag line and /v2/health are the node's own figures today.
Run an internal consumer that subscribes to all nodes and watches the divergence between nodes. It catches a subtly wrong node that every node-local figure reports as healthy.