synor/crates/synor-economics/src/oracle/decentralized.rs
Gulshan Yadav 3df4ba0752 feat(oracle): add advanced oracle features for DeFi
Add 6 major oracle enhancements:
- Chainlink-style decentralized oracle with stake-weighted aggregation
- Circuit breakers for flash crash protection with cascade triggers
- Cross-chain price feeds via IBC from Ethereum, Cosmos, etc.
- ML-based anomaly detection using Isolation Forest algorithm
- DeFi liquidation oracles with health factor monitoring
- Black-Scholes options pricing with Greeks and perpetual swaps
2026-01-19 22:26:58 +05:30

617 lines
20 KiB
Rust

//! Decentralized Oracle Network (Chainlink-style)
//!
//! Multiple independent oracle nodes submit prices with threshold signatures.
//! Aggregation happens on-chain with Byzantine fault tolerance.
use crate::error::{EconomicsError, Result};
use crate::oracle::{PriceSource, TokenPrice};
use crate::SynorDecimal;
use chrono::{DateTime, Duration, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Oracle node identity
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OracleNode {
/// Unique node identifier
pub node_id: String,
/// Node's public key for signature verification
pub public_key: Vec<u8>,
/// Stake amount (for weighted voting)
pub stake: SynorDecimal,
/// Reputation score (0.0 - 1.0)
pub reputation: f64,
/// Whether node is currently active
pub is_active: bool,
/// Last heartbeat timestamp
pub last_heartbeat: DateTime<Utc>,
}
impl OracleNode {
pub fn new(node_id: impl Into<String>, public_key: Vec<u8>, stake: SynorDecimal) -> Self {
Self {
node_id: node_id.into(),
public_key,
stake,
reputation: 1.0,
is_active: true,
last_heartbeat: Utc::now(),
}
}
/// Check if node is eligible to submit prices
pub fn is_eligible(&self, min_stake: SynorDecimal, min_reputation: f64) -> bool {
self.is_active
&& self.stake >= min_stake
&& self.reputation >= min_reputation
&& !self.is_stale(Duration::minutes(5))
}
/// Check if node heartbeat is stale
pub fn is_stale(&self, max_age: Duration) -> bool {
Utc::now() - self.last_heartbeat > max_age
}
}
/// Price submission from an oracle node
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PriceSubmission {
/// Submitting node ID
pub node_id: String,
/// Token pair (e.g., "SYNOR/USD")
pub pair: String,
/// Submitted price
pub price: SynorDecimal,
/// Submission timestamp
pub timestamp: DateTime<Utc>,
/// Cryptographic signature
pub signature: Vec<u8>,
/// Round number for this aggregation
pub round: u64,
}
/// Aggregation round for collecting submissions
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AggregationRound {
/// Round number
pub round: u64,
/// Token pair
pub pair: String,
/// Start time
pub started_at: DateTime<Utc>,
/// Submission deadline
pub deadline: DateTime<Utc>,
/// Collected submissions
pub submissions: Vec<PriceSubmission>,
/// Whether round is finalized
pub finalized: bool,
/// Final aggregated price (if finalized)
pub final_price: Option<SynorDecimal>,
}
impl AggregationRound {
pub fn new(round: u64, pair: impl Into<String>, duration: Duration) -> Self {
let now = Utc::now();
Self {
round,
pair: pair.into(),
started_at: now,
deadline: now + duration,
submissions: Vec::new(),
finalized: false,
final_price: None,
}
}
/// Check if round is still accepting submissions
pub fn is_open(&self) -> bool {
!self.finalized && Utc::now() < self.deadline
}
/// Add a submission to this round
pub fn add_submission(&mut self, submission: PriceSubmission) -> Result<()> {
if self.finalized {
return Err(EconomicsError::InvalidPrice("Round already finalized".into()));
}
if Utc::now() >= self.deadline {
return Err(EconomicsError::InvalidPrice("Round deadline passed".into()));
}
if submission.round != self.round {
return Err(EconomicsError::InvalidPrice("Wrong round number".into()));
}
// Check for duplicate submission from same node
if self.submissions.iter().any(|s| s.node_id == submission.node_id) {
return Err(EconomicsError::InvalidPrice("Duplicate submission".into()));
}
self.submissions.push(submission);
Ok(())
}
}
/// Configuration for decentralized oracle network
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DecentralizedOracleConfig {
/// Minimum number of submissions required
pub min_submissions: usize,
/// Maximum submissions to accept
pub max_submissions: usize,
/// Aggregation round duration
pub round_duration: Duration,
/// Minimum stake to participate
pub min_stake: SynorDecimal,
/// Minimum reputation to participate
pub min_reputation: f64,
/// Maximum price deviation from median (percentage)
pub max_deviation: Decimal,
/// Use stake-weighted aggregation
pub stake_weighted: bool,
/// Byzantine fault tolerance threshold (e.g., 0.33 for 1/3)
pub bft_threshold: f64,
}
impl Default for DecentralizedOracleConfig {
fn default() -> Self {
Self {
min_submissions: 3,
max_submissions: 21,
round_duration: Duration::seconds(30),
min_stake: Decimal::new(1000, 0), // 1000 SYNOR minimum
min_reputation: 0.5,
max_deviation: Decimal::new(5, 2), // 5%
stake_weighted: true,
bft_threshold: 0.33,
}
}
}
/// Aggregation strategy for combining prices
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AggregationStrategy {
/// Simple median (outlier resistant)
Median,
/// Stake-weighted median
StakeWeightedMedian,
/// Trimmed mean (remove top/bottom 10%)
TrimmedMean,
/// Reputation-weighted average
ReputationWeighted,
}
/// Decentralized oracle network
pub struct DecentralizedOracle {
config: DecentralizedOracleConfig,
/// Registered oracle nodes
nodes: HashMap<String, OracleNode>,
/// Current aggregation rounds by pair
current_rounds: HashMap<String, AggregationRound>,
/// Historical rounds
history: Vec<AggregationRound>,
/// Current round number
round_counter: u64,
/// Aggregation strategy
strategy: AggregationStrategy,
}
impl DecentralizedOracle {
pub fn new() -> Self {
Self::with_config(DecentralizedOracleConfig::default())
}
pub fn with_config(config: DecentralizedOracleConfig) -> Self {
Self {
config,
nodes: HashMap::new(),
current_rounds: HashMap::new(),
history: Vec::new(),
round_counter: 0,
strategy: AggregationStrategy::StakeWeightedMedian,
}
}
/// Register a new oracle node
pub fn register_node(&mut self, node: OracleNode) -> Result<()> {
if node.stake < self.config.min_stake {
return Err(EconomicsError::InsufficientFunds {
required: self.config.min_stake,
available: node.stake,
});
}
self.nodes.insert(node.node_id.clone(), node);
Ok(())
}
/// Remove an oracle node
pub fn remove_node(&mut self, node_id: &str) -> Option<OracleNode> {
self.nodes.remove(node_id)
}
/// Update node heartbeat
pub fn heartbeat(&mut self, node_id: &str) -> Result<()> {
let node = self.nodes.get_mut(node_id)
.ok_or_else(|| EconomicsError::InvalidPrice(format!("Unknown node: {}", node_id)))?;
node.last_heartbeat = Utc::now();
Ok(())
}
/// Start a new aggregation round for a pair
pub fn start_round(&mut self, pair: impl Into<String>) -> u64 {
let pair = pair.into();
self.round_counter += 1;
let round = AggregationRound::new(
self.round_counter,
pair.clone(),
self.config.round_duration
);
self.current_rounds.insert(pair, round);
self.round_counter
}
/// Submit a price for the current round
pub fn submit_price(&mut self, submission: PriceSubmission) -> Result<()> {
// Verify node exists and is eligible
let node = self.nodes.get(&submission.node_id)
.ok_or_else(|| EconomicsError::InvalidPrice("Unknown node".into()))?;
if !node.is_eligible(self.config.min_stake, self.config.min_reputation) {
return Err(EconomicsError::InvalidPrice("Node not eligible".into()));
}
// TODO: Verify signature using node.public_key
// For now, we trust the submission
// Add to current round
let round = self.current_rounds.get_mut(&submission.pair)
.ok_or_else(|| EconomicsError::InvalidPrice("No active round for pair".into()))?;
round.add_submission(submission)
}
/// Finalize a round and aggregate prices
pub fn finalize_round(&mut self, pair: &str) -> Result<SynorDecimal> {
// First check state and get submissions (immutable borrow)
let (is_finalized, existing_price, submissions) = {
let round = self.current_rounds.get(pair)
.ok_or_else(|| EconomicsError::PriceFeedUnavailable(pair.to_string()))?;
if round.finalized {
return round.final_price.ok_or_else(||
EconomicsError::InvalidPrice("Round has no price".into())
);
}
// Check minimum submissions
if round.submissions.len() < self.config.min_submissions {
return Err(EconomicsError::InvalidPrice(format!(
"Insufficient submissions: {} < {}",
round.submissions.len(),
self.config.min_submissions
)));
}
(round.finalized, round.final_price, round.submissions.clone())
};
if is_finalized {
return existing_price.ok_or_else(||
EconomicsError::InvalidPrice("Round has no price".into())
);
}
// Filter outliers and aggregate (using cloned submissions)
let final_price = self.aggregate_prices_from_vec(pair, &submissions)?;
// Now get mutable reference to update
if let Some(round) = self.current_rounds.get_mut(pair) {
round.finalized = true;
round.final_price = Some(final_price);
}
// Update node reputations based on accuracy
self.update_reputations(pair, final_price);
// Move to history
if let Some(completed) = self.current_rounds.remove(pair) {
self.history.push(completed);
// Keep only last 1000 rounds
if self.history.len() > 1000 {
self.history.remove(0);
}
}
Ok(final_price)
}
/// Aggregate prices from a vector of submissions (owned)
fn aggregate_prices_from_vec(&self, pair: &str, submissions: &[PriceSubmission]) -> Result<SynorDecimal> {
if submissions.is_empty() {
return Err(EconomicsError::PriceFeedUnavailable(pair.to_string()));
}
// Filter outliers first
let filtered = self.filter_outliers_vec(submissions);
if filtered.is_empty() {
return Err(EconomicsError::InvalidPrice("All submissions were outliers".into()));
}
let filtered_refs: Vec<_> = filtered.iter().collect();
match self.strategy {
AggregationStrategy::Median => self.calculate_median(&filtered_refs),
AggregationStrategy::StakeWeightedMedian => self.calculate_stake_weighted_median(&filtered_refs),
AggregationStrategy::TrimmedMean => self.calculate_trimmed_mean(&filtered_refs),
AggregationStrategy::ReputationWeighted => self.calculate_reputation_weighted(&filtered_refs),
}
}
/// Filter out outlier submissions (returns owned vector)
fn filter_outliers_vec(&self, submissions: &[PriceSubmission]) -> Vec<PriceSubmission> {
if submissions.len() < 3 {
return submissions.to_vec();
}
// Calculate median
let mut prices: Vec<_> = submissions.iter().map(|s| s.price).collect();
prices.sort();
let median = prices[prices.len() / 2];
// Filter submissions within max_deviation of median
submissions
.iter()
.filter(|s| {
let deviation = (s.price - median).abs() / median;
deviation <= self.config.max_deviation
})
.cloned()
.collect()
}
/// Calculate simple median
fn calculate_median(&self, submissions: &[&PriceSubmission]) -> Result<SynorDecimal> {
let mut prices: Vec<_> = submissions.iter().map(|s| s.price).collect();
prices.sort();
Ok(prices[prices.len() / 2])
}
/// Calculate stake-weighted median
fn calculate_stake_weighted_median(&self, submissions: &[&PriceSubmission]) -> Result<SynorDecimal> {
// Get stake for each submission
let mut weighted: Vec<(SynorDecimal, SynorDecimal)> = submissions
.iter()
.filter_map(|s| {
self.nodes.get(&s.node_id).map(|n| (s.price, n.stake))
})
.collect();
if weighted.is_empty() {
return self.calculate_median(submissions);
}
// Sort by price
weighted.sort_by(|a, b| a.0.cmp(&b.0));
// Find weighted median
let total_stake: SynorDecimal = weighted.iter().map(|(_, s)| *s).sum();
let half_stake = total_stake / Decimal::from(2);
let mut cumulative = Decimal::ZERO;
for (price, stake) in &weighted {
cumulative += *stake;
if cumulative >= half_stake {
return Ok(*price);
}
}
Ok(weighted.last().map(|(p, _)| *p).unwrap_or(Decimal::ZERO))
}
/// Calculate trimmed mean (remove top/bottom 10%)
fn calculate_trimmed_mean(&self, submissions: &[&PriceSubmission]) -> Result<SynorDecimal> {
let mut prices: Vec<_> = submissions.iter().map(|s| s.price).collect();
prices.sort();
let trim_count = (prices.len() as f64 * 0.1).ceil() as usize;
let trimmed = &prices[trim_count..prices.len() - trim_count];
if trimmed.is_empty() {
return self.calculate_median(submissions);
}
let sum: SynorDecimal = trimmed.iter().copied().sum();
Ok(sum / Decimal::from(trimmed.len()))
}
/// Calculate reputation-weighted average
fn calculate_reputation_weighted(&self, submissions: &[&PriceSubmission]) -> Result<SynorDecimal> {
let mut weighted_sum = Decimal::ZERO;
let mut total_weight = Decimal::ZERO;
for sub in submissions {
let reputation = self.nodes.get(&sub.node_id)
.map(|n| n.reputation)
.unwrap_or(0.5);
let weight = Decimal::from_f64_retain(reputation).unwrap_or(Decimal::ONE);
weighted_sum += sub.price * weight;
total_weight += weight;
}
if total_weight == Decimal::ZERO {
return self.calculate_median(submissions);
}
Ok(weighted_sum / total_weight)
}
/// Update node reputations based on submission accuracy
fn update_reputations(&mut self, _pair: &str, final_price: SynorDecimal) {
// Get submissions from current round before it was moved
let submissions: Vec<_> = self.history.last()
.map(|r| r.submissions.clone())
.unwrap_or_default();
for sub in submissions {
if let Some(node) = self.nodes.get_mut(&sub.node_id) {
let deviation = (sub.price - final_price).abs() / final_price;
// Increase reputation for accurate submissions, decrease for inaccurate
if deviation <= Decimal::new(1, 2) { // Within 1%
node.reputation = (node.reputation + 0.01).min(1.0);
} else if deviation > self.config.max_deviation {
node.reputation = (node.reputation - 0.05).max(0.0);
}
}
}
}
/// Get current round status
pub fn get_round_status(&self, pair: &str) -> Option<&AggregationRound> {
self.current_rounds.get(pair)
}
/// Get number of active nodes
pub fn active_node_count(&self) -> usize {
self.nodes.values()
.filter(|n| n.is_eligible(self.config.min_stake, self.config.min_reputation))
.count()
}
/// Check if network has sufficient nodes for BFT
pub fn has_quorum(&self) -> bool {
let active = self.active_node_count();
let required = (active as f64 * (1.0 - self.config.bft_threshold)).ceil() as usize;
active >= self.config.min_submissions && required >= 2
}
/// Get all registered nodes
pub fn get_nodes(&self) -> Vec<&OracleNode> {
self.nodes.values().collect()
}
/// Convert finalized price to TokenPrice
pub fn to_token_price(&self, pair: &str) -> Option<TokenPrice> {
self.history.iter()
.rev()
.find(|r| r.pair == pair && r.finalized)
.and_then(|r| r.final_price.map(|price| {
let parts: Vec<_> = pair.split('/').collect();
TokenPrice {
token: parts.get(0).unwrap_or(&"").to_string(),
quote: parts.get(1).unwrap_or(&"").to_string(),
price,
timestamp: r.deadline,
source: PriceSource::Aggregated,
confidence: 1.0,
}
}))
}
}
impl Default for DecentralizedOracle {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn create_test_nodes() -> Vec<OracleNode> {
(0..5).map(|i| {
OracleNode::new(
format!("node_{}", i),
vec![i as u8; 32],
dec!(10000), // 10k stake
)
}).collect()
}
#[test]
fn test_register_nodes() {
let mut oracle = DecentralizedOracle::new();
for node in create_test_nodes() {
oracle.register_node(node).unwrap();
}
assert_eq!(oracle.active_node_count(), 5);
assert!(oracle.has_quorum());
}
#[test]
fn test_aggregation_round() {
let mut oracle = DecentralizedOracle::new();
for node in create_test_nodes() {
oracle.register_node(node).unwrap();
}
// Start round
let round = oracle.start_round("SYNOR/USD");
assert_eq!(round, 1);
// Submit prices
let prices = [dec!(1.00), dec!(1.01), dec!(1.02), dec!(0.99), dec!(1.00)];
for (i, price) in prices.iter().enumerate() {
let submission = PriceSubmission {
node_id: format!("node_{}", i),
pair: "SYNOR/USD".to_string(),
price: *price,
timestamp: Utc::now(),
signature: vec![],
round: 1,
};
oracle.submit_price(submission).unwrap();
}
// Finalize
let final_price = oracle.finalize_round("SYNOR/USD").unwrap();
// Should be close to median (~1.00)
assert!(final_price >= dec!(0.99) && final_price <= dec!(1.02));
}
#[test]
fn test_outlier_filtering() {
let mut oracle = DecentralizedOracle::new();
for node in create_test_nodes() {
oracle.register_node(node).unwrap();
}
oracle.start_round("SYNOR/USD");
// Submit with one outlier
let prices = [dec!(1.00), dec!(1.01), dec!(1.02), dec!(1.00), dec!(5.00)]; // 5.00 is outlier
for (i, price) in prices.iter().enumerate() {
let submission = PriceSubmission {
node_id: format!("node_{}", i),
pair: "SYNOR/USD".to_string(),
price: *price,
timestamp: Utc::now(),
signature: vec![],
round: 1,
};
oracle.submit_price(submission).unwrap();
}
let final_price = oracle.finalize_round("SYNOR/USD").unwrap();
// Outlier should be filtered, price should be ~1.00-1.02
assert!(final_price >= dec!(0.99) && final_price <= dec!(1.03));
}
#[test]
fn test_insufficient_stake() {
let mut oracle = DecentralizedOracle::new();
let low_stake_node = OracleNode::new("poor_node", vec![0; 32], dec!(100)); // Below min
let result = oracle.register_node(low_stake_node);
assert!(result.is_err());
}
}