Operate
Run the web proxy
Install and configure oracle-proxy, get its client key listed, set the quorum and limits, and run it behind a TLS reverse proxy.
oracle-proxy gives web apps verified oracle data. It is one listed client of the nodes. It runs the Rust SDK, with the signature checks and the quorum, and serves the result as JSON over one plain WebSocket, /v1/ws. A reverse proxy in front of it terminates TLS. Web proxy API describes what web clients send and receive.
Important
Web clients trust you, the proxy operator. The node signatures stop at the proxy. Run the proxy on a machine that you would trust with the prices of your users.
Install
The proxy runs as the Docker image brinzi/oracle-proxy on Docker Hub, for Linux amd64. Each tag is a commit of the markoutlabs/oracle-proxy repository. That repository's deploy/README.md runs the image on Docker Swarm, behind a Cloudflare tunnel.
The image holds only the binary. It reads its config, its client key and its database URLs from files that you mount, such as Swarm configs and secrets.
Note
The release build is reproducible: make release on the same commit gives the same binaries, with the same SHA256SUMS. make image puts those binaries into the image.
To build the binary on your own machine:
git clone https://github.com/markoutlabs/oracle-proxy && cd oracle-proxy
make build # cargo build --release --lockedThe binary is target/release/oracle-proxy. It needs Rust 1.85 or newer. The build fetches the oracle crates from the price-oracle repository with git.
Get a client key listed
The proxy connects to the nodes as one client, with its own client API key. All web clients share it. The two scripts are in the price-oracle repository:
scripts/client-key.sh --out proxy.key # the proxy's own client key
scripts/add-client.sh --identity <controller> --address <its address> --expires <date>- Run the first command on the proxy machine. It prints the address of the key.
- Governance runs the second command. It lists the address on the registry canister.
- The key file must have mode
0600. The proxy refuses to start when the group or others can read it. - When the expiry passes, the nodes close the sessions of the proxy. Every price then goes
haltedfor every web client. Ask governance forset_client_expirybefore that date.
Client access explains the key, the client list and the transport.
Configure
The proxy reads one TOML file. proxy.example.toml in the oracle-proxy repository is a commented example:
listen = "127.0.0.1:8792"
[upstream]
# The mainnet canister and governance address when absent.
# canister_id = "xb5gv-diaaa-aaaaf-qg5qa-cai"
# governance = "0x7FB990Cc1111a00931B6396E5780C79574e36897"
# This proxy's client API key, as hex, readable only by its owner (chmod 600).
client_key_file = "proxy.key"
# Nodes that must agree on a price.
quorum = 3
[feeds]
prices = ["CRYPTO.BTC/USD", "CRYPTO.ETH/USD", "CRYPTO.BTC/USDT"]
venues = ["CRYPTO.BTC/USDT"]
books = ["CRYPTO.BTC/USDT"]
funding = ["*"]
[limits]
# Behind the reverse proxy every socket comes from 127.0.0.1.
trust_forwarded_for = trueThe proxy refuses an unknown setting in every section.
Top level
| Key | Default | Meaning |
|---|---|---|
listen |
127.0.0.1:8792 |
The address where web clients connect. Keep it on loopback, behind the reverse proxy. |
[upstream]
How the proxy reaches the nodes.
| Key | Default | Meaning |
|---|---|---|
canister_id |
the mainnet canister | The registry canister. |
governance |
the mainnet governance address | The pinned governance address. The proxy verifies the registry against it. |
registry_url |
https://<canister_id>.raw.icp0.io/registry.json |
The URL of /registry.json, for a local network. Set it together with root_key. |
root_key |
the mainnet root key | The root key of the network, in hex, for a local network. Set it together with registry_url. |
client_key_file |
none, required | The file with the proxy's client API key, in hex. |
quorum |
3 |
The number of nodes that must agree on a price. At least 1. |
allow_low_quorum |
false |
Allows a quorum below 3. |
The mainnet values come from oracle_client::mainnet in the build.
[feeds]
What the proxy pulls from the nodes and serves. Each list takes feed symbols. At least one list must not be empty.
| Key | Default | Meaning |
|---|---|---|
prices |
[] |
Feeds whose prices the proxy serves. |
venues |
[] |
Feeds whose venue reports the proxy serves. |
books |
[] |
Feeds whose sources' books the proxy serves. |
funding |
[] |
Feeds whose perpetual sources' funding the proxy serves, or "*" for every perpetual. |
books takes feed symbols, not book names. For CRYPTO.BTC/USDT, the proxy serves the book of each source of that feed, as the node config defines the sources. Web clients find the book names in /v1/catalog.
[limits]
Limits on web clients. The socket is open to anyone, so these limits are the whole defence against a client that asks for too much. Every value except trust_forwarded_for must be above zero.
| Key | Default | Meaning |
|---|---|---|
max_connections |
1024 |
Open sockets, in total. |
connections_per_ip |
8 |
Open sockets from one IP address. An IPv6 address counts by its /64. |
connects_per_minute |
30 |
New sockets from one IP address each minute. |
messages_per_second |
10 |
Messages from one socket each second, pings and pongs too. Past it, the socket gets rate_limited and closes. |
routes_per_minute |
120 |
Requests to /v1/catalog and /v1/nodes from one IP address each minute. |
max_subscriptions |
256 |
Prices, venue feeds, books and perpetuals that one socket can hold. |
max_book_queue |
64 |
Book messages that can wait for one book on one socket. Past it, the queue becomes one snapshot. |
book_interval_ms |
100 |
How often the proxy compares each book and sends its changes, in milliseconds. |
trust_forwarded_for |
false |
Takes the client address from X-Forwarded-For. See trust_forwarded_for. |
A socket over a connection limit gets HTTP 429, or 503 when the proxy is full, with a Retry-After header. Limits and errors lists what a web client sees for each limit.
Local network
For a registry canister on a local network, set all four trust values. The certificate check needs the canister id even with an explicit URL. icp network status --json prints the gateway URL and the root key.
[upstream]
canister_id = "<canister id>"
registry_url = "http://127.0.0.1:8000/registry.json?canisterId=<canister id>"
root_key = "<root_key from icp network status --json>"
governance = "0x…"
client_key_file = "proxy.key"
# One local node: no protection against a bad node.
quorum = 1
allow_low_quorum = trueQuorum
The proxy applies the same quorum rules as the SDK:
- The default is 3.
- A quorum below 3 needs
allow_low_quorum = true. One node proves who is talking, not that the price is right. - The proxy does not start when the registry lists fewer nodes than the quorum.
- With fewer than three nodes listed, set
quorumto the number that exists andallow_low_quorum = true.
Five nodes give a quorum of 3 room for one planned and one unplanned outage. Quorum has the full rules.
Run
target/release/oracle-proxy proxy.tomlAt startup the proxy does these steps:
- It reads and checks the config file.
- It reads the client key file and checks its mode.
- It fetches the registry and verifies it against the governance address. It logs
registry verified. - It connects to every node and subscribes to the configured feeds.
- It listens for web clients. It logs
serving web clients on /v1/ws.
The proxy does not start when:
- the config file does not parse, or a value is out of range;
- the key file is missing, is not valid hex, or the group or others can read it;
- the registry does not verify against the governance address;
- the registry lists fewer nodes than the quorum, or no node endpoint.
Logs go to stderr. RUST_LOG filters them, and the default level is info. The proxy stops cleanly on SIGTERM, which systemd sends to stop a service, and on SIGINT.
TLS reverse proxy
The proxy speaks plain ws on loopback. Put a reverse proxy in front of it that terminates TLS and passes WebSocket upgrades. Caddy does both:
oracle.example.com {
reverse_proxy 127.0.0.1:8792
}Web clients then connect to wss://oracle.example.com/v1/ws. The same host serves /v1/catalog and /health.
trust_forwarded_for
Behind the reverse proxy, every socket comes from 127.0.0.1. Without this setting, the per-IP limits count all web clients as one address. With the defaults, only 8 sockets can then be open in total, and only 30 new sockets can open each minute.
With trust_forwarded_for = true, the proxy takes the client address from the last X-Forwarded-For entry. That is the entry that your reverse proxy added. A client can forge the entries before it, but not the last one. When the header is absent, the proxy uses the address of the TCP peer.
Warning
Set trust_forwarded_for = true only behind a reverse proxy that sets X-Forwarded-For. If clients can reach the proxy directly, a client can send its own header and pick any address for the limits. Keep listen on loopback.
Health checks
GET /health shows the state of the SDK client inside the proxy. It answers 200 while the nodes deliver, and 503 when they do not, so a load balancer or an orchestrator can use it as it is:
curl -s http://127.0.0.1:8792/health{
"status": "ok",
"registry_version": 7,
"quorum": 3,
"nodes": 3,
"connected": 3,
"last_frame_ns": "1791299498663252000",
"clients": 12,
"accepted": 10452,
"venue_reports": 881,
"book_updates": 20311,
"rejected": 0
}| Field | Meaning |
|---|---|
status |
ok, degraded, starting or down. See the list below. |
registry_version |
The registry version that the SDK client trusts. |
quorum |
Nodes that must agree on a price. |
nodes |
Nodes in the registry. |
connected |
Nodes with a connection now. |
last_frame_ns |
When the newest frame from any node arrived. null before the first. |
clients |
Open web sockets. |
accepted |
Price frames accepted since start. |
venue_reports |
Venue report frames accepted since start. |
book_updates |
Book frames accepted since start. |
rejected |
Frames refused since start: untrusted, replayed, out of time, incoherent, or from a retired key. |
The values update once a second. The status values:
ok(200): a quorum of nodes is connected, and the newest frame is at most 10 s old.degraded(200): the newest frame is fresh, but fewer nodes than the quorum are connected. Prices can halt for want of nodes.starting(503): no frame has arrived yet.down(503): no frame has arrived in the last 10 s.
Also:
- Watch
rejected. A steady rise means that something sits between the proxy and a node, or that a stolen key is in use. - Subscribe to one price on
/v1/wsand check thatstatusis nothalted. The proxy resolves each price feed again every second, so a silent upstream shows ashalted.
Archive
The archive stores the past prices that /v1/history serves to charts. It writes to PostgreSQL with TimescaleDB. An [archive] section in the config turns it on. The proxy repository's README describes the tables and the setup.
The history is for charts, not for proof. The archive keeps no signed frame and no signature. It stores:
- the SDK's result for each feed: each second, and at each change of status;
- the entries of one venue report of each feed in
[feeds] venueseach second: bid, ask, sizes, mark, index and funding.
Each feed takes one node's venue reports: the first node in market_nodes that sent a report in the last 3 s, or 30 s for a slow feed. Each row names its node.
- Run the archive as its own proxy instance with
web = false. It then serves/healthalone. Web load and web deploys then cause no gaps, and the database password stays out of the process that faces the internet. - The archive never slows the prices. While the database is down, a journal on local disk keeps the records, up to
journal_max_bytes. retention_days(default 7) deletes older rows. Each table compresses one day after a chunk ends. With 225 feeds and 3 nodes, the archive uses about 2.6 GB a day after compression, and the database stays below about 80 GB with the default./healthgets anarchiveobject.lostcounts every record that the archive dropped, for any reason.unloaded_age_msis the age of the oldest record that is not in the database yet.
History for charts
The web instance can serve the archive's past prices on /v1/history. A [history] section turns the route on:
[history]
# The URL of a role that can only read, in a file that only its owner reads.
database_url_file = "history.db-url"
# max_range_hours = 24
# requests_per_minute = 30
# max_concurrent = 4Make the role as the database owner, with the proxy repository's script, after the archive has started:
shpsql "$ARCHIVE_URL" -v ON_ERROR_STOP=1 -v role=history \ -v password="$(openssl rand -hex 24)" -f scripts/history-role.sqlIf the script says that the archive has no tables yet, wait one minute and run it again. A second run only sets the password again.
Put
postgres://history:<password>@<host>/archivein the URL file, andchmod 600it.
The role can read only the tables that the route needs. The proxy refuses a role that can write, sets each connection to read only, and cancels a query after 5 s. A leaked URL thus shows only what the dashboard shows already. The archive instance keeps the only credential that can write.