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
617 lines
20 KiB
Rust
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());
|
|
}
|
|
}
|