Integrate
Rust SDK
Use the oracle-client crate to verify signed updates from many nodes, resolve one price per feed, and read books and venue reports.
The oracle-client crate is the consumer SDK. It connects to every node in the registry, verifies each signed frame, and resolves one price for each feed across the nodes. Use it when your Rust service must not trust one node or one operator.
Important
A signature proves authorship, not truth. The protection against a bad node is the median across independent nodes. The SDK gives that protection only with a quorum of 3 or more. Read the trust model before you integrate.
The crate has two pure, synchronous parts and a thin async layer:
| Part | Job |
|---|---|
Verifier |
Checks one frame at a time: signature, registry membership, sequence, staleness, coherent timestamps. |
FeedView |
Resolves one feed across nodes: median price, and a confidence floored at half the spread between nodes. |
OracleClient |
Connects to the nodes, runs the transport, and feeds frames to the two parts above. |
Add the crate
The crate name is oracle-client. It is not on crates.io, so add it as a git dependency on a release tag. The API also uses types from oracle-wire and oracle-types, so add those two crates from the same tag.
[dependencies]
oracle-client = { git = "https://github.com/markoutlabs/oracle-node", tag = "v0.1.0" }
oracle-wire = { git = "https://github.com/markoutlabs/oracle-node", tag = "v0.1.0" } # Signer, Address, Registry
oracle-types = { git = "https://github.com/markoutlabs/oracle-node", tag = "v0.1.0" } # Fixed, Symbol, InstrumentId
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }Use the same tag for the three crates. Different tags give two copies of the shared types, and the compiler rejects the mix.
Requirements:
- Rust 1.85 or newer. The workspace uses edition 2024.
- A Tokio runtime.
subscribestarts one task for each node on the current runtime. - TLS through rustls with the
ringprovider.HttpRegistrySource::newinstallsringas the process default, unless your application installed a provider first.
Connect to mainnet
The module oracle_client::mainnet holds the trust root of the mainnet registry canister.
| Constant | Value |
|---|---|
CANISTER_ID |
Some("xb5gv-diaaa-aaaaf-qg5qa-cai") |
GOVERNANCE_ADDRESS |
Some("0x7FB990Cc1111a00931B6396E5780C79574e36897") |
ROOT_KEY |
The Internet Computer mainnet root key, a re-export of IC_MAINNET_ROOT_KEY |
The first two constants are Options. A build without a mainnet deployment has None.
use std::sync::Arc;
use oracle_client::{HttpRegistrySource, OracleClient, Status, mainnet};
use oracle_wire::{Address, Signer};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let canister = mainnet::CANISTER_ID.ok_or("this build has no mainnet canister")?;
let governance: Address = mainnet::GOVERNANCE_ADDRESS
.ok_or("this build has no mainnet governance address")?
.parse()?;
let source = Arc::new(HttpRegistrySource::canister(canister)?);
// Hex, as scripts/client-key.sh writes it.
let api_key = Signer::from_hex(std::fs::read_to_string("client.key")?.trim())?;
let mut client = OracleClient::builder_from_source(source, governance)
.await?
.client_key(api_key)
.build()?;
client.subscribe(&["CRYPTO.BTC/USD"]).await?;
while let Some(price) = client.next().await {
match price.status {
Status::Ok | Status::Degraded => {
// Act on `price.conf`, not only on `price.price`.
println!("{:?} ± {:?}", price.price, price.conf);
}
Status::Halted => println!("halted: {:?}", price.concerns),
}
}
Ok(())
}The example does these steps:
HttpRegistrySource::canisterpoints athttps://<canister>.raw.icp0.io/registry.json. Each fetch checks the IC certificate against the mainnet root key. The certificate must be at most 5 minutes old.builder_from_sourcefetches the registry and verifies the governance signature against the pinned address.buildchecks the quorum against the registry.subscribeconnects to each node, runs the handshake with the client key, and sends the subscription.
Pin the governance address in your code or your config. Never read it from the document that you fetch. The certificate shows that the document is current. The signature shows that the document is genuine.
Connect to a local network
On a local network, give the canister id, the gateway URL and the root key of that network. icp network status --json prints the gateway URL and the root key.
use std::sync::Arc;
use oracle_client::{Certification, HttpRegistrySource, OracleClient};
use oracle_wire::{Address, Signer};
async fn local_client(
canister_id: &str,
root_key: &str,
governance: Address,
api_key: Signer,
) -> Result<OracleClient, Box<dyn std::error::Error>> {
let certification = Certification::new(canister_id, root_key)?;
let url = format!("http://127.0.0.1:8000/registry.json?canisterId={canister_id}");
let source = Arc::new(HttpRegistrySource::new(&url, certification)?);
let client = OracleClient::builder_from_source(source, governance)
.await?
.client_key(api_key)
// One local node: no protection against a bad node.
.quorum(1)
.i_understand_this_provides_no_protection_against_a_bad_node()
.build()?;
Ok(client)
}You can also pin a registry file. A file has no source, so the client does not refresh it. The client then loses a node that rotates its key, until you call refresh_registry with the new document.
let registry: oracle_wire::Registry =
serde_json::from_str(&std::fs::read_to_string("registry.json")?)?;
let builder = OracleClient::builder(registry.verify(governance, 0)?);The client API key
Nodes serve only listed clients. Without a key, subscribe, subscribe_venues, subscribe_books and subscribe_funding return ClientError::NoClientKey.
- The key is a secp256k1 key, an
oracle_wire::Signer. It only signs. - Governance lists the address of the key with
add_clienton the registry canister. Signer::from_hexreads the hex file thatscripts/client-key.shwrites.- Use the key only for this protocol.
The SDK runs the handshake on each connection. It checks that the node signed auth_ok with the address that the client dialled. Only then does it send the subscription. Every frame that it decrypts still goes through the Verifier. Decryption proves nothing, because every listed client holds the broadcast key.
Client access tells how to get a key listed and what the transport does.
Builder settings
OracleClient::builder(registry) and OracleClient::builder_from_source(source, governance) return an OracleClientBuilder.
| Method | Default | Effect |
|---|---|---|
client_key(Signer) |
none | The client API key. Necessary to subscribe. |
quorum(usize) |
3 | The number of distinct nodes necessary for a price. |
i_understand_this_provides_no_protection_against_a_bad_node() |
off | Allows a quorum below 3. |
max_age(Duration) |
2 s | The oldest update that the client accepts. At this age, a node also leaves the quorum, and book() no longer returns its book. |
slow_max_age(Duration) |
25 s | The same limit for a slow feed: funding and open interest. Nodes publish these feeds at least every 10 s. |
max_skew(Duration) |
500 ms | How far a node clock can run ahead of your clock. |
max_divergence_bps(Fixed) |
50 | The spread between nodes, in basis points of the median, above which a feed is Degraded. |
refresh_interval(Duration) |
10 s | The minimum time between two registry fetches that an unknown signer causes. |
registry_poll_interval(Duration) |
60 s | The maximum time between two scheduled registry fetches. |
idle_timeout(Duration) |
30 s | Dials a node again after this long with no message from it. The client pings each node three times in this time, and the node answers each ping, so only a dead connection stays quiet. |
max_registry_age(Duration) |
48 h | The time without a confirmed registry before each feed reports RegistryStale. |
channel_capacity(usize) |
4096 | The capacity of the queue between the node connections and next_event. |
registry_source(Arc<dyn RegistrySource>) |
set by builder_from_source |
Where the client fetches newer registry versions. |
use std::time::Duration;
use oracle_types::Fixed;
let mut client = OracleClient::builder_from_source(source, governance)
.await?
.client_key(api_key)
.quorum(3)
.max_age(Duration::from_secs(2))
.max_skew(Duration::from_millis(500))
.max_divergence_bps(Fixed::from_int(50)?)
.refresh_interval(Duration::from_secs(10))
.registry_poll_interval(Duration::from_secs(60))
.max_registry_age(Duration::from_secs(48 * 3_600))
.build()?;build returns one of these errors:
| Error | Cause |
|---|---|
ClientError::UnsafeQuorum |
The quorum is below 3, and you did not call the acknowledgement. |
ClientError::UnreachableQuorum |
The registry lists fewer nodes than the quorum. |
Quorum
The quorum is the number of distinct nodes that must report a feed before the SDK gives a usable price. The default is 3.
build refuses a quorum below 3 unless you call i_understand_this_provides_no_protection_against_a_bad_node(). Signatures from one node confirm who is talking. They tell you nothing about whether to believe that node. One compromised node is then one compromised price. This mistake is easy to make during integration and easy to never revisit, so the API makes it loud. Use the acknowledgement only for local development against one node.
Other quorum rules:
- The registry carries
quorum.min_nodesandquorum.recommendedas guidance from governance. The SDK does not apply them. You set the quorum. refresh_registryrefuses a new registry version that lists fewer nodes than your quorum. The client keeps its current registry.- Five nodes give a quorum of 3 room for one planned and one unplanned outage.
Why a quorum below 3 is refused has the full argument.
Subscribe and read events
There are four subscriptions. Each one takes feed symbols.
| Method | Asks each node for | next_event yields |
|---|---|---|
subscribe(&[&str]) |
The prices of the feeds | Event::Price(Resolved) |
subscribe_venues(&[&str]) |
The venue reports of the feeds | Event::Venues(AcceptedVenues) |
subscribe_books(&[&str]) |
The books of the sources of the feeds | Event::Book(AcceptedBook) |
subscribe_funding(&[&str]) |
The funding of the feeds' perpetual sources; "*" for every perpetual |
Event::Funding(AcceptedFunding) |
Each call adds symbols and reconnects every node with the full set. subscribe_all takes every list in one Subscription, with one reconnect of each node. A bad symbol in any list changes nothing.
next_event() returns the next Event. next() returns only prices and skips venue reports, books and funding. Keep one of them in a loop at all times. The client adopts scheduled registry fetches inside that loop. Both return None only after shutdown.
next_event() is cancel-safe: a call that you drop loses no frame. A registry fetch, for a key that the client does not know yet, runs on its own task. So next_event() can share a tokio::select! with a timer.
use oracle_client::{Event, Subscription};
use oracle_types::Fixed;
// One reconnect of each node for every list.
client
.subscribe_all(Subscription {
prices: &["CRYPTO.BTC/USD"],
venues: &["CRYPTO.BTC/USDT"],
books: &["CRYPTO.BTC/USDT"],
..Subscription::default()
})
.await?;
while let Some(event) = client.next_event().await {
match event {
Event::Price(resolved) => {
if !resolved.is_usable() {
// Halted: never settle or liquidate on this feed now.
continue;
}
// Ok or Degraded: size the risk from `conf`.
}
Event::Venues(accepted) => {
// One node's view. The SDK does not combine reports.
let report = &accepted.report;
for entry in &report.entries {
let bid = entry
.bid
.map(|mantissa| Fixed::from_scaled(mantissa, report.expo))
.transpose()?;
println!(
"{} {}:{} {} {} bps bid={bid:?}",
accepted.signer, entry.venue, entry.market, entry.status, entry.weight_bps
);
}
}
Event::Book(_) => {
// Already applied. Read the book with `client.book()`.
}
}
}The Event enum:
| Variant | Contents |
|---|---|
Price(Resolved) |
A price resolved across the nodes. |
Venues(AcceptedVenues) |
One node's verified venue report: report, signer and node_index. |
Book(AcceptedBook) |
One node's verified book frame: update, signer and node_index. The SDK already applied it to that node's book. |
Rules for the event loop:
- The client yields one resolution for each accepted frame. You see a change as soon as one node reports it.
- You observe state, not transitions. A node conflates its fanout, so a halt shorter than one second can disappear. Read
statuson each event. Do not count transitions. - A rejected frame never reaches you. The client logs it at debug level and counts it in
stats(). resolve(&symbol, now_ns)resolves a feed again without a new frame. Use it on a timer, so that a silent upstream shows asHalted.
Resolved
Resolved is the price for one feed, resolved across the nodes. It serializes with serde.
| Field | Rust type | JSON | Meaning |
|---|---|---|---|
feed_id |
FeedId |
0x hex string |
The keccak256 hash of the symbol. See Feed IDs. |
symbol |
Option<Symbol> |
string or null |
The feed symbol. The client sets it for every subscribed feed. |
status |
Status |
"ok", "degraded" or "halted" |
How much to trust the price. |
price |
Option<Fixed> |
decimal string or null |
The median of the node prices. None when the feed is halted. |
conf |
Option<Fixed> |
decimal string or null |
The half-width of the confidence interval. It is the larger of the median node conf and half the spread between nodes. None when the feed is halted. |
nodes |
usize |
number | The number of nodes that contributed. |
halted_nodes |
usize |
number | The number of nodes left out because they reported their own feed halted. |
concerns |
Vec<Concern> |
array of strings | Why the status is not ok. |
publish_time_ns |
u64 |
string | The newest publish time of the contributing nodes, in unix nanoseconds. 0 when the feed is halted. |
spread_bps |
Option<Fixed> |
decimal string or null |
The spread between nodes, in basis points of the median. Present when there is a price. |
Resolved::is_usable() returns true for Ok and Degraded.
A healthy price as JSON:
{
"feed_id": "0x0523e79c7ed211911a19afaa4def68c896bc72d674bf33c5c64d4d7089e320e2",
"symbol": "CRYPTO.BTC/USD",
"status": "ok",
"price": "86599.95",
"conf": "19.71359135",
"nodes": 3,
"halted_nodes": 0,
"concerns": [],
"publish_time_ns": "1791299498663252000",
"spread_bps": "0"
}A halted price as JSON. One node reported its feed halted, and too few nodes remain:
{
"feed_id": "0x0523e79c7ed211911a19afaa4def68c896bc72d674bf33c5c64d4d7089e320e2",
"symbol": "CRYPTO.BTC/USD",
"status": "halted",
"price": null,
"conf": null,
"nodes": 2,
"halted_nodes": 1,
"concerns": ["node_reported_halted", "insufficient_nodes"],
"publish_time_ns": "0",
"spread_bps": null
}Decimals and publish_time_ns are strings. A JavaScript number loses precision on a nanosecond timestamp. A Fixed prints with up to 12 decimal places, without trailing zeros.
Status and concerns
| Status | Meaning | What your app does |
|---|---|---|
Ok |
Enough nodes agree, and there is no concern. | Use price and conf. |
Degraded |
The price is usable, but the confidence interval does real work. | Use price. Size the risk from conf. Read concerns. |
Halted |
Fewer live nodes than the quorum. price and conf are None. |
Do not settle or liquidate. Wait for the next event. |
| Concern | JSON | Cause | Effect | What your app does |
|---|---|---|---|---|
InsufficientNodes |
insufficient_nodes |
Fewer live nodes than the quorum. A node counts while its newest update is younger than max_age and not halted. |
Halted |
Stop. Check the node connections and the registry. |
NodeDivergence |
node_divergence |
spread_bps is above max_divergence_bps. |
Degraded. conf is at least half the spread. |
Act on conf. Alert if it stays. |
NodeReportedDegraded |
node_reported_degraded |
At least one live node set DEGRADED on its own update. |
Degraded |
Act on conf. |
NodeReportedHalted |
node_reported_halted |
At least one node set HALTED. The SDK left it out of the median. |
Degraded, or Halted with InsufficientNodes if too few nodes remain. |
Act on conf. |
RegistryStale |
registry_stale |
No certified fetch confirmed the registry within max_registry_age. |
Ok becomes Degraded. |
The prices are still verified. The node list can be out of date. Repair the path to the registry canister. |
Concern is #[non_exhaustive]. A match outside the crate needs a wildcard arm:
use oracle_client::{Concern, Resolved};
fn review(resolved: &Resolved) {
for concern in &resolved.concerns {
match concern {
Concern::InsufficientNodes => { /* halted: stop */ }
Concern::NodeDivergence => { /* watch spread_bps */ }
Concern::NodeReportedDegraded => { /* a node degraded its own feed */ }
Concern::NodeReportedHalted => { /* a node halted and was left out */ }
Concern::RegistryStale => { /* check the path to the canister */ }
// `Concern` is `#[non_exhaustive]`.
_ => {}
}
}
}Warning
A halt is not automatically safe. A halted feed stops a liquidation engine, which can be worse than a slightly wrong price. Decide what your app does for each status before you go live.
Status flags explains why a node sets DEGRADED or HALTED on its own update.
Resolved and PriceUpdate
A PriceUpdate is the payload that one node signs. A Resolved is what the SDK makes from the latest accepted update of each node. Use Resolved for decisions.
Resolved |
PriceUpdate |
|
|---|---|---|
| Comes from | The SDK, across the nodes | One node |
| Trust | The quorum of nodes | The word of one node |
| Price | Option<Fixed>, the median |
price: i64, a mantissa at expo |
| Confidence | Option<Fixed>, floored at half the spread between nodes |
conf: u64, a mantissa at expo |
| State | status and concerns |
flags: HALTED, DEGRADED, SYNTHETIC_QUOTE |
| Only in this type | nodes, halted_nodes, spread_bps |
observation_time_ns, prev_publish_time_ns, seq, num_sources, sources |
client.view(&symbol) returns the FeedView behind a feed. FeedView::latest() gives each node's latest accepted PriceUpdate, keyed by node address. Use it to monitor which node says what. Do not use one node's update as a price.
use oracle_client::{OracleClient, now_unix_nanos};
use oracle_types::Symbol;
fn per_node(client: &OracleClient) -> Result<(), Box<dyn std::error::Error>> {
let symbol: Symbol = "CRYPTO.BTC/USD".parse()?;
if let Some(view) = client.view(&symbol) {
for (node, update) in view.latest() {
println!("{node} seq={} price={}", update.seq, update.price_fixed()?);
}
}
// The same feed, resolved now without a new frame.
let _now = client.resolve(&symbol, now_unix_nanos());
Ok(())
}Books
Each node sends the venue books that it keeps. A snapshot of the top 50 levels goes out every second. The changed levels go out in between, at most every 50 ms. The format is the same for every venue, because the node computes the changes from its own book. Streams describes the frames.
The SDK keeps one Book for each pair of node and venue instrument:
- It verifies each frame like a price: signature, registry, a rising
seq, and the clock. - A missed
seqmakes that node's book invalid until its next snapshot. client.book(&instrument, now_ns)returns the freshest valid book whose last frame is younger thanmax_age. It is always one node's whole book, never a mix of two nodes.- No quorum applies to a book. It is the view of one node.
use oracle_client::{Book, OracleClient, now_unix_nanos};
use oracle_types::{InstrumentId, MarketKind, Venue};
fn top_of_book(client: &OracleClient) -> Result<(), Box<dyn std::error::Error>> {
let binance = InstrumentId::new(Venue::Binance, MarketKind::Spot, "BTCUSDT");
if let Some(book) = client.book(&binance, now_unix_nanos()) {
if let (Some(bid), Some(ask)) = (book.best_bid(), book.best_ask()) {
let (bid_price, bid_size) = Book::level_fixed(bid)?;
let (ask_price, ask_size) = Book::level_fixed(ask)?;
println!(
"{binance} from {}: {bid_size} @ {bid_price} / {ask_size} @ {ask_price}",
book.node
);
}
}
// Every book that a node has sent.
for instrument in client.book_instruments() {
println!("{instrument}");
}
Ok(())
}An instrument is a venue, a market and the venue's own symbol, such as BTCUSDT on Binance or BTC-USDT on OKX. The node config sets them. book_instruments() lists the instruments that arrived.
Book item |
Meaning |
|---|---|
node |
The node whose frames built the book. |
instrument |
The venue instrument. |
seq |
The seq of the last frame applied. |
publish_time_ns |
When the node signed the last frame. |
local_recv_ns |
When the node received the newest book data. |
exchange_time_ns |
The venue's own timestamp on the newest book data, if the venue sends one. |
bids(), asks() |
The levels, best price first. |
best_bid(), best_ask() |
The best level, or None. |
is_valid() |
true after a snapshot, while no frame is missing. |
Book::level_fixed(level) |
Converts a BookLevel to a price and a size as Fixed. |
A BookLevel holds price: i64 and qty: u64. Both are mantissas at BOOK_EXPO, which is −8. Sizes are in base units.
Venue reports
A venue report shows what one node saw for each source of a feed. The node signs it with its node key. It contains:
- the best bid and ask, with their sizes;
- the impact prices for the feed's
impact_notional; - the mark, the index and the funding rate, when the venue sends them;
- the status of the source in the price, and its weight.
The SDK verifies each report like a price: signature, registry, the report's own seq, and the clock. It does not combine reports across nodes, because each node reports what it saw.
Each VenueEntry in report.entries has these fields:
| Field | Meaning |
|---|---|
venue, market |
The source. |
status |
SourceStatus: Used, Outlier, Stale, Crossed, NoData, Invalid or FeedHalted. |
weight_bps |
The share of the total weight of the price, 0 to 10,000. It is 0 unless the status is Used. |
bid, ask |
Mantissas at report.expo. |
bid_qty, ask_qty |
Mantissas at QTY_EXPO (−8), in base units. |
impact_bid, impact_ask |
Mantissas at report.expo, from the node's book. |
mark, index |
Mantissas at report.expo. |
funding |
The venue's own rate over its own interval, a mantissa at RATE_EXPO (−9). |
exchange_time_ns |
The venue's own timestamp on its newest observation. |
local_recv_ns |
When the node received the newest observation, or 0 with no data. |
A field that the venue does not send for the instrument is None. The prices are the venue's own, before the USDT to USD conversion. The status and the weight refer to the converted price.
A node signs reports only for a feed that some client subscribed to. The first report comes at the next evaluation after the subscription.
Latency
client.latency() returns a LatencyReport for the updates that the client accepted. It has three spans. The client stamps each frame when it arrives, before it decrypts the frame.
| Span | From | To |
|---|---|---|
delivery |
The node signs | The client receives |
data_age |
The node receives the newest venue data | The client receives |
processing |
The client receives | next() returns the resolved price |
Each span is a Percentiles value with count, p50, p90, p99 and max. Its Display prints a line such as p50 0.28 ms, p90 0.59 ms, p99 1.51 ms, max 6.33 ms (n=1059).
let latency = client.latency();
println!("delivery {}", latency.delivery);
println!("data age {}", latency.data_age);
println!("processing {}", latency.processing);
// Start again after a warm-up.
client.reset_latency();deliverycompares two clocks. It is exact only on one machine, or with synchronized clocks.data_ageincludes heartbeats, which send old data on purpose. It skips halted updates.- A value above one minute counts as one minute.
Latency gives measured values.
Counters and the registry
client.stats() returns a ClientStats value. Export it to your monitoring.
| Counter | Counts |
|---|---|
accepted |
Price frames accepted. |
venue_reports |
Venue report frames accepted. |
book_updates |
Book frames accepted. |
funding_updates |
Funding frames accepted. |
untrusted |
Frames refused: malformed, or not signed by a listed node. |
replayed |
Frames refused because the seq did not increase. |
out_of_time |
Frames refused as too old or from the future. |
incoherent |
Frames refused because their timestamps contradict each other. |
retired |
Frames refused because a retired key signed them. |
refreshes |
Registry fetches outside the schedule: unknown signers and manual calls. |
polls |
Scheduled registry fetches. |
refresh_failures |
Registry fetches that failed or did not verify. |
tap_dropped |
Frame copies that the frames() channel had no room for. |
A sudden rise in replayed or out_of_time means that something sits between you and the node. No signature check tells you that. A few retired frames right after a rotation are frames in flight. A steady stream of them is a stolen key in use.
Registry state:
| Method | Returns |
|---|---|
registry_version() |
The registry version that the client trusts now. |
registry_age(now_ns) |
The time since a source last confirmed the registry. |
registry_is_stale(now_ns) |
true when that time is above max_registry_age. A registry without a source is never stale. |
refresh_from_source() |
Fetches now and adopts a newer version. This call has no rate limit. |
refresh_registry(&Registry) |
Adopts a newer document that you fetched yourself. |
A node rotation reaches the client without a restart. An unknown signer causes one registry fetch, at most once each refresh_interval. The client adopts the newer version in place and moves the node's quorum seat to the new address. Key rotation has the full sequence.
Funding
Each node sends the funding of each perpetual source. A frame holds the rate as the venue states it, with its basis. Where the venue sends them, it also holds the predicted rate, the interval, the next funding time, the last settled rate and the open interest. A frame goes out on a change, at most once a second, and at least once a minute. FundingUpdate v1 lists the fields of each venue.
- The SDK verifies each frame like a book frame: signature, registry, a rising
seq, and the clock. The age limit is two minutes, because a new subscriber gets the latest frame, which can be a minute old. - Each frame carries the whole state.
client.funding(&perpetual, now_ns)returns the freshest frame of any node, asAcceptedFunding, with the node that signed it. - No quorum applies. It is the view of one node.
use oracle_client::{OracleClient, now_unix_nanos};
use oracle_types::{FUNDING_EXPO, InstrumentId, MarketKind, Venue};
fn okx_funding(client: &OracleClient) {
let okx = InstrumentId::new(Venue::Okx, MarketKind::LinearPerp, "BTC-USDT-SWAP");
if let Some(funding) = client.funding(&okx, now_unix_nanos()) {
let update = &funding.update;
// A mantissa at FUNDING_EXPO (−12), over the venue's interval.
println!("{} e{FUNDING_EXPO}, every {:?} ns", update.rate, update.interval_ns);
}
// Every perpetual that a node has sent funding for.
for perpetual in client.funding_instruments() {
println!("{perpetual}");
}
}Frame copies for an archive
client.frames(capacity) opens a bounded channel. It carries a copy of each frame whose signature and registry check pass, also when the client then refuses the frame. Each copy is a TappedFrame:
| Field | Contents |
|---|---|
bytes |
The signed frame, byte for byte as the node sent it. |
signer |
The node whose key signed it. |
kind |
Price, VenueReport, Book or Funding. |
received_ns |
When the client received it, by this host's wall clock. |
verdict |
Accepted, OutOfTime, Replayed or Incoherent. |
The client never waits on the channel. When the channel is full, the client drops the copy and counts it in tap_dropped, so a slow archive never slows the prices. client.frames_of(capacity, &[FrameKind::VenueReport]) copies only the frames of the listed kinds. tap_dropped then counts only those. A Replayed copy with other bytes than the stored frame is a node that signed two frames with one seq.
Node reports
client.nodes(now_ns) reports each node in the registry, for monitoring. The quorum, not this report, decides a price.
use oracle_client::{ConnectionState, NodeFeedState, now_unix_nanos};
for node in client.nodes(now_unix_nanos()) {
let connected = matches!(node.connection, ConnectionState::Connected { .. });
for feed in &node.feeds {
if feed.state == Some(NodeFeedState::Stale) {
println!("{} is stale on {}", node.address, feed.symbol);
}
}
println!("{} connected={connected} accepted={}", node.address, node.frames.accepted);
}| Field | Contents |
|---|---|
address, weight, endpoints |
The node's registry entry. |
connection |
Idle, Connecting, Connected { since_ns } or Backoff { retry_at_ns }. |
connects, failures, last_error |
Handshakes that passed, attempts and sessions that ended with an error, and the last error. |
last_frame_ns, frames |
When the last accepted frame arrived, and the node's accepted and rejected frames. |
delivery |
Node signature to receipt, in microseconds, over the last full minute. |
feeds |
For each subscribed price feed: the NodeFeedState by the quorum rules, the node's price, and its distance from the median in basis points. |
A frame from an unknown or retired key belongs to no node, so only stats() counts it. NodeReport serializes with serde. oracle-proxy sends it as the nodes message.
Test your integration
The module oracle_client::testing has fake nodes and registries. Use it to test how your app handles halted and degraded feeds without five live nodes. Its keys come from small integers, so anyone can guess them. Never use them in production.
use oracle_client::testing::{REGISTRY_ISSUED_AT, TestNode, registry_with, update_at};
use oracle_client::{OracleClient, Status};
use oracle_types::NANOS_PER_SEC;
#[test]
fn three_agreeing_nodes_resolve_to_ok() -> Result<(), Box<dyn std::error::Error>> {
let nodes: Vec<TestNode> = (1..=3_u8).map(TestNode::new).collect();
let registry = registry_with(&nodes.iter().collect::<Vec<_>>());
let mut client = OracleClient::builder(registry).quorum(3).build()?;
// Test registries are issued at this time. Use it as the clock.
let now = REGISTRY_ISSUED_AT * NANOS_PER_SEC;
let mut last = None;
for node in &nodes {
last = Some(client.ingest(&node.sign(&update_at(now, 1)), now)?);
}
assert_eq!(last.map(|resolved| resolved.status), Some(Status::Ok));
Ok(())
}ingest and ingest_event feed one frame in directly, without the network. They return the RejectReason when a frame fails verification.