Skip to content

Price oracle & data feeds

Applications need prices (or any external number: weather, scores, rates) that no single server can quietly manipulate. A typical oracle collects reports from several sources and publishes a robust aggregate. The weak point is the aggregation: if one machine computes it, you have to trust that machine.

  • The aggregation is a pure function of the reports, so a committee of miners can each compute it and must agree on the receipt hash; validators audit random rounds.
  • The output carries a digest of the exact reports used, so anyone can re-check a published price with ndsr run.
  • Miners are paid per verified aggregation from your vault; you set the price per unit.

What Necter does not do: fetch prices from the internet. Modules have no network access. Your backend (or your reporters) fetch and sign data; Necter verifies and aggregates it deterministically.

flowchart LR
  S1[Source A] --> B[Your oracle backend]
  S2[Source B] --> B
  S3[Source C] --> B
  B -- "POST /v1/projects/{id}/tasks<br/>aggregate(reports)" --> H[Hub]
  H --> C[Committee of miners<br/>stateless worker]
  C --> V[Validators: finality + audits]
  H -- finalized output --> B
  B -- "POST /v1/execute<br/>submit(price)" --> O[(Stateful price module<br/>on validators)]
  D[Dapps and services] -- read --> O
oracle_aggregator/src/lib.rs
//! Stateless price aggregator: a committee-friendly oracle worker.
use hivekit::prelude::*;
use serde::Deserialize;
const MAX_REPORTS: usize = 64;
const MAX_PRICE: u64 = (1 << 53) - 1;
#[derive(Deserialize)]
struct Report {
source: String,
price: u64, // integer minor units, e.g. 1e-6 USD: 3200.12 USD = 3200120000
}
#[derive(Deserialize)]
struct Batch {
pair: String,
round: u64,
reports: Vec<Report>,
#[serde(default = "default_min_sources")]
min_sources: usize,
#[serde(default = "default_max_deviation_bp")]
max_deviation_bp: u64,
}
fn default_min_sources() -> usize {
3
}
fn default_max_deviation_bp() -> u64 {
200 // 2 %
}
fn median(sorted: &[u64]) -> u64 {
let n = sorted.len();
if n % 2 == 1 {
sorted[n / 2]
} else {
// average of the two middle values, rounded down, without overflow
sorted[n / 2 - 1] / 2 + sorted[n / 2] / 2 + (sorted[n / 2 - 1] % 2 + sorted[n / 2] % 2) / 2
}
}
fn deviation_bp(p: u64, m: u64) -> u64 {
let diff = p.abs_diff(m) as u128;
(diff * 10_000 / m as u128) as u64
}
/// Aggregate one round of reports for a pair.
#[hive_export]
fn aggregate(mut b: Batch) -> Result<Value, String> {
if b.pair.is_empty() || b.pair.len() > 32 {
return Err("pair must be 1..=32 bytes".into());
}
if b.reports.is_empty() || b.reports.len() > MAX_REPORTS {
return Err(format!("reports must hold 1..={MAX_REPORTS} entries"));
}
if b.min_sources == 0 || b.max_deviation_bp == 0 {
return Err("min_sources and max_deviation_bp must be positive".into());
}
// Canonical order: by source name. Duplicate sources are rejected.
b.reports.sort_by(|x, y| x.source.cmp(&y.source));
for w in b.reports.windows(2) {
if w[0].source == w[1].source {
return Err(format!("duplicate source {}", w[0].source));
}
}
for r in &b.reports {
if r.price == 0 || r.price > MAX_PRICE {
return Err(format!("price of {} out of range", r.source));
}
}
let mut prices: Vec<u64> = b.reports.iter().map(|r| r.price).collect();
prices.sort_unstable();
let first = median(&prices);
let (kept, rejected): (Vec<&Report>, Vec<&Report>) = b
.reports
.iter()
.partition(|r| deviation_bp(r.price, first) <= b.max_deviation_bp);
if kept.len() < b.min_sources {
return Err(format!(
"only {} of {} sources within {} bp of the median (need {})",
kept.len(),
b.reports.len(),
b.max_deviation_bp,
b.min_sources
));
}
let mut kept_prices: Vec<u64> = kept.iter().map(|r| r.price).collect();
kept_prices.sort_unstable();
let price = median(&kept_prices);
// Digest of exactly the reports that were used, in canonical order.
let used: Vec<String> = kept.iter().map(|r| format!("{}={}", r.source, r.price)).collect();
let reports_hash = hash(format!("{}|{}|{}", b.pair, b.round, used.join(",")).as_bytes());
emit(
"price.aggregated",
&json!({ "pair": b.pair, "round": b.round, "price": price, "sources": kept.len() }),
);
Ok(json!({
"pair": b.pair,
"round": b.round,
"price": price,
"sources_used": kept.len(),
"rejected": rejected.iter().map(|r| r.source.as_str()).collect::<Vec<_>>(),
"reports_hash": reports_hash,
}))
}
hive_module!(aggregate);

Cargo.toml is the standard one from the Rust guide (crate-type = ["cdylib", "rlib"], hivekit and serde). The module imports only crypto.hash, hive.abort and hive.emit: it is stateless.

Terminal window
hivec build # → dist/oracle_aggregator.hbc
ndsr run dist/oracle_aggregator.hbc aggregate --input '{"pair":"ETH/USD","round":7,"reports":[
{"source":"a","price":3200120000},{"source":"b","price":3199870000},
{"source":"c","price":3201000000},{"source":"d","price":2500000000}]}'

Recorded result (success: true, gas_used: 69709):

{"pair":"ETH/USD","price":3200120000,"rejected":["d"],"reports_hash":"0x52e8c247ec17d5edbee7ad80297d97671b28cd6fb4b56cca3cbf3a79e747bea9","round":7,"sources_used":3}

with the event price.aggregated {"pair":"ETH/USD","price":3200120000,"round":7,"sources":3}. Source d was more than 2 % from the median and was dropped.

The published price: a stateful module (Go)

Section titled “The published price: a stateful module (Go)”

Results that apps read live in a small stateful module on the validators. The SDK’s Go price_oracle example keeps the latest price per source and serves the median:

hivekit-go/examples/price_oracle/main.go (excerpt)
type submission struct {
Pair string `json:"pair"`
Source string `json:"source"`
Price int64 `json:"price"`
}
func init() {
hivekit.DefineJSON("submit", func(s submission) (quote, error) {
// validate, load the pair's book, set book[source] = price, store, emit "price.submitted"
// returns {"pair", "median", "sources"}
})
hivekit.DefineJSON("price", func(q query) (quote, error) { /* median of the stored book */ })
hivekit.DefineJSON("reset", func(q query) (map[string]bool, error) { /* delete the pair */ })
}

Run with persistent state, three sources:

Terminal window
hivec run -data-dir ./state dist/price_oracle.hbc submit '{"pair":"ETH/USD","source":"a","price":320012}'
hivec run -data-dir ./state dist/price_oracle.hbc submit '{"pair":"ETH/USD","source":"b","price":319990}'
hivec run -data-dir ./state dist/price_oracle.hbc submit '{"pair":"ETH/USD","source":"c","price":320100}'
hivec run -data-dir ./state dist/price_oracle.hbc price '{"pair":"ETH/USD"}'
Call Output Gas
submit a {"pair":"ETH/USD","median":320012,"sources":1} 227 606
submit b {"pair":"ETH/USD","median":320001,"sources":2} 250 386
submit c {"pair":"ETH/USD","median":320012,"sources":3} 263 131
price {"pair":"ETH/USD","median":320012,"sources":3} 178 873

In production, only your backend should write: calls carry no caller identity, so either keep the module’s address private to your backend and treat it as a cache of committee-verified values, or require a signature from your oracle key inside submit (see attestation for in-module signature checks).

  1. Upload oracle_aggregator.hbc and register a project with an API task source:

    "work": {
    "class": "deterministic", "verification": "redundant-execution", "execution": "committee",
    "functions": ["aggregate"],
    "task_source": { "kind": "api" },
    "max_gas_limit": 2000000,
    "committee": { "size": 5, "backups": 3, "min_size": 3,
    "selection": "collateral-reputation-sortition-v1", "weight_cap_multiple": 10 },
    "round_secs": 30, "lease_secs": 15, "epoch_secs": 3600, "dispute_window_secs": 86400
    }

    Project manifest · Register

  2. Create and fund the vault. Vault

  3. Upload the stateful price module and note its address. Upload and call

Your backend submits each round and writes the verified result:

oracle-round.mjs (Node 18+)
import { Hive } from 'hivejs'
const RPC = 'https://testnet-rpc.necter.network'
const res = await fetch(`${RPC}/v1/projects/${process.env.PROJECT_ID}/tasks?wait=90`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${process.env.NECTER_API_KEY}` },
body: JSON.stringify({
function: 'aggregate',
input: { pair: 'ETH/USD', round: 7, reports }, // collected and checked by your backend
idempotency_key: 'eth-usd-7',
}),
})
const task = await res.json()
if (task.status !== 'finalized' || !task.success) throw new Error(`round not final: ${task.status} ${task.error}`)
const agg = JSON.parse(task.output)
const store = new Hive(process.env.PRICE_MODULE, { gatewayUrl: RPC })
await store.call('submit', { pair: agg.pair, source: 'necter-committee', price: agg.price })

Readers query the latest value through validators:

Terminal window
curl -s -X POST https://testnet-rpc.necter.network/v1/execute -H 'content-type: application/json' \
-d '{"module":"0x<price module>","function":"price","input":{"pair":"ETH/USD"}}'
Item Value
Gas per aggregation (4 reports) ~70k → 1 compute unit per agreeing member
Miner cost per round committee size × 1 unit × reward_per_unit (e.g. 5 × 1 NECTA with a 1 NECTA unit price), plus developer and treasury shares from the same budget
Latency round_secs + validator finality; use accept=agreed for faster, optimistic reads
Prices integers only (micro-units); never floats in events
Freshness the module has no clock: include round (and a timestamp your backend sets) in the input

Alternatives: write the aggregator in Go (TinyGo, ~5–10× the gas of Rust for this kind of code) or AssemblyScript (cheapest per call, but no built-in JSON).