Price oracle & data feeds
The problem
Section titled “The problem”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.
Why Necter fits
Section titled “Why Necter fits”- 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.
Architecture
Section titled “Architecture”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
The worker: a stateless aggregator (Rust)
Section titled “The worker: a stateless aggregator (Rust)”//! 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.
Build and run it
Section titled “Build and run it”hivec build # → dist/oracle_aggregator.hbcndsr 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:
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:
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).
Publish it
Section titled “Publish it”-
Upload
oracle_aggregator.hbcand 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} -
Create and fund the vault. Vault
-
Upload the stateful price module and note its address. Upload and call
Consume it
Section titled “Consume it”Your backend submits each round and writes the verified result:
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:
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"}}'Costs and considerations
Section titled “Costs and considerations”| 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).