#![no_std]
//! oracle-network — multi-source oracle feed with explicit failure modes.
//!
//! Consumers must not treat missing, delayed, or inconsistent readings as
//! current. Aggregation either returns a fresh consensus value or rejects.
use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, String, Vec};
/// A reading older than this many seconds is stale and cannot be current.
pub const STALE_THRESHOLD_SECS: u64 = 300;
/// Minimum fresh sources required before a quote is accepted.
pub const MIN_FRESH_SOURCES: u32 = 2;
/// Maximum allowed deviation from the median, in basis points (5%).
pub const MAX_DEVIATION_BPS: i128 = 500;
#[contracttype]
#[derive(Clone)]
pub enum DataKey {
Admin,
Sources,
Registered(Address),
Reading(String, Address),
FeedSources(String),
}
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct OracleReading {
pub source: Address,
pub feed_id: String,
pub value: i128,
pub observed_at: u64,
}
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AggregatedQuote {
pub feed_id: String,
pub value: i128,
pub source_count: u32,
pub observed_at: u64,
}
#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
#[repr(u32)]
pub enum Error {
NotInitialized = 1,
AlreadyInitialized = 2,
Unauthorized = 3,
SourceUnavailable = 4,
StaleData = 5,
InsufficientSources = 6,
ConflictingSources = 7,
AlreadyRegistered = 8,
}
#[contract]
pub struct Contract;
#[contractimpl]
impl Contract {
pub fn initialize(env: Env, admin: Address) -> Result<(), Error> {
if env.storage().instance().has(&DataKey::Admin) {
return Err(Error::AlreadyInitialized);
}
admin.require_auth();
env.storage().instance().set(&DataKey::Admin, &admin);
let sources: Vec
= Vec::new(&env);
env.storage().instance().set(&DataKey::Sources, &sources);
Ok(())
}
pub fn stale_threshold_secs(_env: Env) -> u64 {
STALE_THRESHOLD_SECS
}
/// Register an oracle source. Admin only.
pub fn register_source(env: Env, caller: Address, source: Address) -> Result<(), Error> {
require_admin(&env, &caller)?;
if env
.storage()
.instance()
.has(&DataKey::Registered(source.clone()))
{
return Err(Error::AlreadyRegistered);
}
env.storage()
.instance()
.set(&DataKey::Registered(source.clone()), &true);
let mut sources: Vec = env
.storage()
.instance()
.get(&DataKey::Sources)
.unwrap_or_else(|| Vec::new(&env));
sources.push_back(source);
env.storage().instance().set(&DataKey::Sources, &sources);
Ok(())
}
/// Store a source reading. Rejects unregistered sources.
pub fn submit_reading(
env: Env,
source: Address,
feed_id: String,
value: i128,
observed_at: u64,
) -> Result {
source.require_auth();
if !env
.storage()
.instance()
.has(&DataKey::Registered(source.clone()))
{
return Err(Error::Unauthorized);
}
let reading = OracleReading {
source: source.clone(),
feed_id: feed_id.clone(),
value,
observed_at,
};
env.storage()
.persistent()
.set(&DataKey::Reading(feed_id.clone(), source.clone()), &reading);
let mut feed_sources: Vec = env
.storage()
.persistent()
.get(&DataKey::FeedSources(feed_id.clone()))
.unwrap_or_else(|| Vec::new(&env));
if !contains_address(&feed_sources, &source) {
feed_sources.push_back(source);
env.storage()
.persistent()
.set(&DataKey::FeedSources(feed_id), &feed_sources);
}
Ok(reading)
}
pub fn get_reading(env: Env, feed_id: String, source: Address) -> Option {
env.storage()
.persistent()
.get(&DataKey::Reading(feed_id, source))
}
/// Aggregate fresh, agreeing sources. Never treats stale data as current.
///
/// Rejects when sources are missing, all stale, below quorum, or in conflict.
pub fn aggregate(env: Env, feed_id: String) -> Result {
let feed_sources: Vec = env
.storage()
.persistent()
.get(&DataKey::FeedSources(feed_id.clone()))
.unwrap_or_else(|| Vec::new(&env));
if feed_sources.is_empty() {
return Err(Error::SourceUnavailable);
}
let now = env.ledger().timestamp();
let mut fresh_values: Vec = Vec::new(&env);
let mut fresh_observed: Vec = Vec::new(&env);
let mut stale_count: u32 = 0;
let mut i: u32 = 0;
while i < feed_sources.len() {
let source = feed_sources.get(i).unwrap();
if let Some(reading) = env
.storage()
.persistent()
.get::(&DataKey::Reading(feed_id.clone(), source))
{
if is_stale(now, reading.observed_at) {
stale_count = stale_count.saturating_add(1);
} else {
fresh_values.push_back(reading.value);
fresh_observed.push_back(reading.observed_at);
}
}
i += 1;
}
if fresh_values.is_empty() {
if stale_count > 0 {
return Err(Error::StaleData);
}
return Err(Error::SourceUnavailable);
}
if fresh_values.len() < MIN_FRESH_SOURCES {
return Err(Error::InsufficientSources);
}
sort_i128(&mut fresh_values);
let median = fresh_values.get(fresh_values.len() / 2).unwrap();
if has_conflict(&fresh_values, median) {
return Err(Error::ConflictingSources);
}
let mut newest: u64 = 0;
let mut j: u32 = 0;
while j < fresh_observed.len() {
let ts = fresh_observed.get(j).unwrap();
if ts > newest {
newest = ts;
}
j += 1;
}
Ok(AggregatedQuote {
feed_id,
value: median,
source_count: fresh_values.len(),
observed_at: newest,
})
}
}
fn require_admin(env: &Env, caller: &Address) -> Result<(), Error> {
let admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.ok_or(Error::NotInitialized)?;
if caller != &admin {
return Err(Error::Unauthorized);
}
caller.require_auth();
Ok(())
}
fn is_stale(now: u64, observed_at: u64) -> bool {
now.saturating_sub(observed_at) > STALE_THRESHOLD_SECS
}
fn contains_address(list: &Vec, target: &Address) -> bool {
let mut i: u32 = 0;
while i < list.len() {
if list.get(i).unwrap() == *target {
return true;
}
i += 1;
}
false
}
fn sort_i128(values: &mut Vec) {
let n = values.len();
let mut i: u32 = 0;
while i < n {
let mut j: u32 = 0;
while j + 1 < n.saturating_sub(i) {
let a = values.get(j).unwrap();
let b = values.get(j + 1).unwrap();
if a > b {
values.set(j, b);
values.set(j + 1, a);
}
j += 1;
}
i += 1;
}
}
fn has_conflict(sorted_values: &Vec, median: i128) -> bool {
let mut i: u32 = 0;
while i < sorted_values.len() {
let value = sorted_values.get(i).unwrap();
if exceeds_tolerance(value, median) {
return true;
}
i += 1;
}
false
}
fn exceeds_tolerance(value: i128, median: i128) -> bool {
let delta = (value - median).abs();
let scale = median.abs();
if scale == 0 {
return delta > 0;
}
delta.saturating_mul(10_000) / scale > MAX_DEVIATION_BPS
}
#[cfg(test)]
mod test {
use super::*;
use soroban_sdk::testutils::{Address as _, Ledger};
fn feed(env: &Env) -> String {
String::from_str(env, "credit-score")
}
fn set_time(env: &Env, ts: u64) {
env.ledger().with_mut(|info| {
info.timestamp = ts;
});
}
fn setup(env: &Env) -> (Address, ContractClient<'_>) {
let contract_id = env.register(Contract, ());
let client = ContractClient::new(env, &contract_id);
let admin = Address::generate(env);
env.mock_all_auths();
client.initialize(&admin);
(admin, client)
}
fn register_pair(
env: &Env,
admin: &Address,
client: &ContractClient<'_>,
) -> (Address, Address) {
let a = Address::generate(env);
let b = Address::generate(env);
client.register_source(admin, &a);
client.register_source(admin, &b);
(a, b)
}
#[test]
fn threshold_is_explicit() {
let env = Env::default();
let (_, client) = setup(&env);
assert_eq!(client.stale_threshold_secs(), STALE_THRESHOLD_SECS);
assert_eq!(STALE_THRESHOLD_SECS, 300);
}
#[test]
fn unavailable_feed_is_rejected() {
let env = Env::default();
let (_, client) = setup(&env);
assert_eq!(
client.try_aggregate(&feed(&env)),
Err(Ok(Error::SourceUnavailable))
);
}
#[test]
fn unregistered_source_cannot_submit() {
let env = Env::default();
let (_, client) = setup(&env);
let ghost = Address::generate(&env);
assert_eq!(
client.try_submit_reading(&ghost, &feed(&env), &100, &1_000),
Err(Ok(Error::Unauthorized))
);
assert!(client.get_reading(&feed(&env), &ghost).is_none());
}
#[test]
fn stale_readings_are_not_treated_as_current() {
let env = Env::default();
let (admin, client) = setup(&env);
let (a, b) = register_pair(&env, &admin, &client);
let id = feed(&env);
set_time(&env, 10_000);
client.submit_reading(&a, &id, &100, &9_000);
client.submit_reading(&b, &id, &101, &9_000);
// Age 10_000 - 9_000 = 1_000s > 300s stale threshold.
assert_eq!(client.try_aggregate(&id), Err(Ok(Error::StaleData)));
let stored = client.get_reading(&id, &a).unwrap();
assert_eq!(stored.value, 100);
assert_eq!(stored.observed_at, 9_000);
}
#[test]
fn stale_source_cannot_fill_quorum() {
let env = Env::default();
let (admin, client) = setup(&env);
let (a, b) = register_pair(&env, &admin, &client);
let id = feed(&env);
set_time(&env, 10_000);
// Fresh
client.submit_reading(&a, &id, &100, &9_900);
// Stale — must not count toward MIN_FRESH_SOURCES.
client.submit_reading(&b, &id, &100, &9_000);
assert_eq!(
client.try_aggregate(&id),
Err(Ok(Error::InsufficientSources))
);
}
#[test]
fn exactly_threshold_age_is_still_current() {
let env = Env::default();
let (admin, client) = setup(&env);
let (a, b) = register_pair(&env, &admin, &client);
let id = feed(&env);
set_time(&env, 10_000);
client.submit_reading(&a, &id, &100, &(10_000 - STALE_THRESHOLD_SECS));
client.submit_reading(&b, &id, &100, &(10_000 - STALE_THRESHOLD_SECS));
let quote = client.aggregate(&id);
assert_eq!(quote.value, 100);
assert_eq!(quote.source_count, 2);
assert_eq!(quote.observed_at, 10_000 - STALE_THRESHOLD_SECS);
}
#[test]
fn one_second_past_threshold_age_is_rejected_as_stale() {
let env = Env::default();
let (admin, client) = setup(&env);
let (a, b) = register_pair(&env, &admin, &client);
let id = feed(&env);
set_time(&env, 10_000);
let just_stale = 10_000 - STALE_THRESHOLD_SECS - 1;
client.submit_reading(&a, &id, &100, &just_stale);
client.submit_reading(&b, &id, &100, &just_stale);
assert_eq!(client.try_aggregate(&id), Err(Ok(Error::StaleData)));
}
#[test]
fn agreeing_fresh_sources_aggregate_to_median() {
let env = Env::default();
let (admin, client) = setup(&env);
let (a, b) = register_pair(&env, &admin, &client);
let c = Address::generate(&env);
client.register_source(&admin, &c);
let id = feed(&env);
set_time(&env, 5_000);
client.submit_reading(&a, &id, &100, &4_900);
client.submit_reading(&b, &id, &101, &4_950);
client.submit_reading(&c, &id, &102, &4_980);
let quote = client.aggregate(&id);
assert_eq!(quote.value, 101);
assert_eq!(quote.source_count, 3);
assert_eq!(quote.observed_at, 4_980);
assert_eq!(quote.feed_id, id);
}
#[test]
fn conflicting_sources_are_rejected_deterministically() {
let env = Env::default();
let (admin, client) = setup(&env);
let (a, b) = register_pair(&env, &admin, &client);
let id = feed(&env);
set_time(&env, 5_000);
client.submit_reading(&a, &id, &100, &4_900);
client.submit_reading(&b, &id, &200, &4_900);
assert_eq!(
client.try_aggregate(&id),
Err(Ok(Error::ConflictingSources))
);
// Storage is unchanged; neither value is promoted as current.
assert_eq!(client.get_reading(&id, &a).unwrap().value, 100);
assert_eq!(client.get_reading(&id, &b).unwrap().value, 200);
assert_eq!(
client.try_aggregate(&id),
Err(Ok(Error::ConflictingSources))
);
}
#[test]
fn outlier_against_median_is_conflict_not_fallback() {
let env = Env::default();
let (admin, client) = setup(&env);
let (a, b) = register_pair(&env, &admin, &client);
let c = Address::generate(&env);
client.register_source(&admin, &c);
let id = feed(&env);
set_time(&env, 5_000);
client.submit_reading(&a, &id, &100, &4_900);
client.submit_reading(&b, &id, &101, &4_900);
client.submit_reading(&c, &id, &10_000, &4_900);
assert_eq!(
client.try_aggregate(&id),
Err(Ok(Error::ConflictingSources))
);
}
}