//! 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, /// 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, } impl OracleNode { pub fn new(node_id: impl Into, public_key: Vec, 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, /// Cryptographic signature pub signature: Vec, /// 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, /// Submission deadline pub deadline: DateTime, /// Collected submissions pub submissions: Vec, /// Whether round is finalized pub finalized: bool, /// Final aggregated price (if finalized) pub final_price: Option, } impl AggregationRound { pub fn new(round: u64, pair: impl Into, 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, /// Current aggregation rounds by pair current_rounds: HashMap, /// Historical rounds history: Vec, /// 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 { 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) -> 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 { // 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 { 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 { 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 { 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 { // 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 { 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 { 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 { 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 { (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()); } }