diff --git a/Cargo.toml b/Cargo.toml index aec3af7..c476c30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,11 @@ exclude = [ "contracts/nft", "contracts/dex", "contracts/staking", + "contracts/confidential-token", + "contracts/perps", + "contracts/oracle", + "contracts/aggregator", + "contracts/ibc-bridge", "crates/synor-crypto-wasm", "apps/desktop-wallet/src-tauri", ] diff --git a/contracts/aggregator/Cargo.toml b/contracts/aggregator/Cargo.toml new file mode 100644 index 0000000..cb00cbc --- /dev/null +++ b/contracts/aggregator/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "synor-aggregator" +version = "0.1.0" +edition = "2021" +authors = ["Synor Team "] +description = "Cross-chain DEX liquidity aggregator with optimal routing" +license = "MIT OR Apache-2.0" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +default = [] + +[dependencies] +synor-sdk = { path = "../../crates/synor-sdk", default-features = false } +borsh = { version = "1.3", default-features = false, features = ["derive"] } + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/contracts/aggregator/src/lib.rs b/contracts/aggregator/src/lib.rs new file mode 100644 index 0000000..6c56733 --- /dev/null +++ b/contracts/aggregator/src/lib.rs @@ -0,0 +1,1040 @@ +//! Cross-Chain DEX Liquidity Aggregator +//! +//! This contract aggregates liquidity from multiple sources to provide: +//! - Best execution price across all DEXs +//! - Cross-chain swaps via IBC +//! - Split routing for large orders +//! - MEV protection via private mempool +//! +//! # Zero-Capital Strategy +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────┐ +//! │ SYNOR LIQUIDITY AGGREGATOR │ +//! │ (No Capital Required) │ +//! ├─────────────────────────────────────────────────────────────┤ +//! │ │ +//! │ USER REQUEST: Swap 10 ETH for USDC (best price) │ +//! │ │ │ +//! │ ▼ │ +//! │ ┌─────────────────────────────────────────────┐ │ +//! │ │ QUOTE AGGREGATION │ │ +//! │ │ │ │ +//! │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ +//! │ │ │ Synor │ │ Osmosis │ │ dYdX │ │ │ +//! │ │ │ DEX │ │ (IBC) │ │ (IBC) │ │ │ +//! │ │ │ $25,010 │ │ $25,015 │ │ $25,020 │ │ │ +//! │ │ └────┬────┘ └────┬────┘ └────┬────┘ │ │ +//! │ │ │ │ │ │ │ +//! │ │ └─────────────┼─────────────┘ │ │ +//! │ │ │ │ │ +//! │ │ BEST PRICE: $25,020 (dYdX) │ │ +//! │ └─────────────────────┬────────────────────┘ │ +//! │ │ │ +//! │ ▼ │ +//! │ ┌─────────────────────────────────────────────┐ │ +//! │ │ ROUTE EXECUTION │ │ +//! │ │ │ │ +//! │ │ Option A: Single route (small order) │ │ +//! │ │ ETH ────────────────────────────► USDC │ │ +//! │ │ via dYdX IBC bridge │ │ +//! │ │ │ │ +//! │ │ Option B: Split route (large order) │ │ +//! │ │ ETH ──┬── 60% via dYdX ──────────► USDC │ │ +//! │ │ ├── 25% via Osmosis ────────► │ │ +//! │ │ └── 15% via Synor DEX ──────► │ │ +//! │ └─────────────────────────────────────────────┘ │ +//! │ │ +//! │ REVENUE SOURCES: │ +//! │ • Aggregation fee: 0.05% of swap volume │ +//! │ • Positive slippage capture │ +//! │ • Bridge fee share (partner programs) │ +//! └─────────────────────────────────────────────────────────────┘ +//! ``` + +#![no_std] + +extern crate alloc; + +use alloc::string::String; +use alloc::vec::Vec; +use borsh::{BorshDeserialize, BorshSerialize}; +use synor_sdk::prelude::*; +use synor_sdk::{require, require_auth}; + +// ============================================================================= +// CONSTANTS +// ============================================================================= + +/// Price precision (6 decimals) +pub const PRICE_PRECISION: u64 = 1_000_000; + +/// Amount precision (8 decimals) +pub const AMOUNT_PRECISION: u64 = 100_000_000; + +/// Basis points denominator +pub const BPS_DENOMINATOR: u64 = 10000; + +/// Aggregation fee (5 bps = 0.05%) +pub const AGGREGATION_FEE_BPS: u64 = 5; + +/// Maximum slippage (5% = 500 bps) +pub const MAX_SLIPPAGE_BPS: u64 = 500; + +/// Minimum split percentage (10%) +pub const MIN_SPLIT_PERCENT: u64 = 10; + +/// Maximum routes per swap +pub const MAX_ROUTES: usize = 5; + +/// IBC timeout (10 minutes) +pub const IBC_TIMEOUT_SECONDS: u64 = 10 * 60; + +// ============================================================================= +// EVENT TOPICS +// ============================================================================= + +fn event_topic(name: &[u8]) -> [u8; 32] { + use synor_sdk::crypto::blake3_hash; + blake3_hash(name).0 +} + +// ============================================================================= +// STORAGE KEYS +// ============================================================================= + +mod keys { + pub const OWNER: &[u8] = b"agg:owner"; + pub const INITIALIZED: &[u8] = b"agg:initialized"; + pub const PAUSED: &[u8] = b"agg:paused"; + + // Liquidity sources + pub const SOURCES: &[u8] = b"agg:sources"; + pub const SOURCE_COUNT: &[u8] = b"agg:source_count"; + + // Supported tokens + pub const TOKENS: &[u8] = b"agg:tokens"; + pub const TOKEN_COUNT: &[u8] = b"agg:token_count"; + + // Pending IBC swaps + pub const PENDING_SWAPS: &[u8] = b"agg:pending"; + pub const SWAP_COUNT: &[u8] = b"agg:swap_count"; + + // Fee collector + pub const FEE_COLLECTOR: &[u8] = b"agg:fee_collector"; + pub const COLLECTED_FEES: &[u8] = b"agg:fees"; + + // Statistics + pub const TOTAL_VOLUME: &[u8] = b"agg:volume"; +} + +// ============================================================================= +// DATA STRUCTURES +// ============================================================================= + +/// Type of liquidity source +#[derive(Clone, Copy, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub enum SourceType { + /// Local AMM (Synor DEX) + LocalAmm, + /// IBC-connected DEX (Osmosis, dYdX, etc.) + IbcDex, + /// Centralized exchange API (for quotes only) + CexApi, + /// Order book + OrderBook, +} + +/// Liquidity source status +#[derive(Clone, Copy, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub enum SourceStatus { + Active, + Paused, + Deprecated, +} + +/// A liquidity source (DEX or bridge) +#[derive(Clone, Debug, BorshSerialize, BorshDeserialize)] +pub struct LiquiditySource { + /// Source ID + pub id: u32, + /// Human-readable name + pub name: String, + /// Type of source + pub source_type: SourceType, + /// Contract address (for local sources) + pub contract: Option
, + /// IBC channel (for IBC sources) + pub ibc_channel: Option, + /// Chain ID (for IBC sources) + pub chain_id: Option, + /// Status + pub status: SourceStatus, + /// Base fee (bps) + pub base_fee_bps: u64, + /// Priority (higher = preferred) + pub priority: u32, + /// Total volume routed + pub total_volume: u64, + /// Success rate (bps, e.g., 9900 = 99%) + pub success_rate_bps: u64, + /// Average latency (milliseconds) + pub avg_latency_ms: u64, +} + +/// Token information +#[derive(Clone, Debug, BorshSerialize, BorshDeserialize)] +pub struct TokenInfo { + /// Token symbol (e.g., "ETH") + pub symbol: String, + /// Token address (local) + pub local_address: Address, + /// Decimals + pub decimals: u8, + /// IBC denom mapping (chain_id -> denom) + pub ibc_denoms: Vec<(String, String)>, + /// Is native asset + pub is_native: bool, +} + +/// Quote from a liquidity source +#[derive(Clone, Debug, BorshSerialize, BorshDeserialize)] +pub struct Quote { + /// Source ID + pub source_id: u32, + /// Source name + pub source_name: String, + /// Input amount + pub amount_in: u64, + /// Output amount (expected) + pub amount_out: u64, + /// Price impact (bps) + pub price_impact_bps: u64, + /// Fee (in output token) + pub fee: u64, + /// Is available + pub available: bool, + /// Estimated execution time (seconds) + pub estimated_time: u64, +} + +/// Route for executing a swap +#[derive(Clone, Debug, BorshSerialize, BorshDeserialize)] +pub struct Route { + /// Source ID + pub source_id: u32, + /// Percentage of total amount (0-100) + pub percentage: u64, + /// Expected output + pub expected_output: u64, + /// Minimum output (with slippage) + pub min_output: u64, +} + +/// Swap execution plan +#[derive(Clone, Debug, BorshSerialize, BorshDeserialize)] +pub struct SwapPlan { + /// Input token + pub token_in: String, + /// Output token + pub token_out: String, + /// Total input amount + pub amount_in: u64, + /// Routes to execute + pub routes: Vec, + /// Total expected output + pub total_expected_output: u64, + /// Minimum output (user's slippage tolerance) + pub min_output: u64, + /// Aggregation fee + pub aggregation_fee: u64, + /// Valid until timestamp + pub valid_until: u64, +} + +/// Pending IBC swap +#[derive(Clone, Debug, BorshSerialize, BorshDeserialize)] +pub struct PendingSwap { + /// Swap ID + pub id: u64, + /// User address + pub user: Address, + /// Plan that was executed + pub plan: SwapPlan, + /// Current status + pub status: SwapStatus, + /// Amount received so far + pub amount_received: u64, + /// Routes completed + pub routes_completed: u32, + /// Created timestamp + pub created_at: u64, + /// Timeout timestamp + pub timeout_at: u64, +} + +/// Swap execution status +#[derive(Clone, Copy, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub enum SwapStatus { + /// Swap initiated, waiting for execution + Pending, + /// Partially filled + PartiallyFilled, + /// Fully completed + Completed, + /// Failed + Failed, + /// Refunded + Refunded, + /// Timed out + TimedOut, +} + +// ============================================================================= +// EVENTS +// ============================================================================= + +#[derive(BorshSerialize)] +pub struct SourceAdded { + pub source_id: u32, + pub name: String, + pub source_type: SourceType, +} + +#[derive(BorshSerialize)] +pub struct SwapExecuted { + pub swap_id: u64, + pub user: Address, + pub token_in: String, + pub token_out: String, + pub amount_in: u64, + pub amount_out: u64, + pub fee: u64, + pub routes_used: u32, +} + +#[derive(BorshSerialize)] +pub struct SwapPartialFill { + pub swap_id: u64, + pub source_id: u32, + pub amount_filled: u64, +} + +#[derive(BorshSerialize)] +pub struct SwapFailed { + pub swap_id: u64, + pub reason: String, +} + +// ============================================================================= +// STORAGE HELPERS +// ============================================================================= + +fn get_owner() -> Option
{ + storage::get::
(keys::OWNER) +} + +fn is_initialized() -> bool { + storage::get::(keys::INITIALIZED).unwrap_or(false) +} + +fn is_paused() -> bool { + storage::get::(keys::PAUSED).unwrap_or(false) +} + +fn get_source_count() -> u32 { + storage::get::(keys::SOURCE_COUNT).unwrap_or(0) +} + +fn set_source_count(count: u32) { + storage::set(keys::SOURCE_COUNT, &count); +} + +fn get_source(id: u32) -> Option { + storage::get_with_suffix::(keys::SOURCES, &id.to_le_bytes()) +} + +fn set_source(id: u32, source: &LiquiditySource) { + storage::set_with_suffix(keys::SOURCES, &id.to_le_bytes(), source); +} + +fn get_token_count() -> u32 { + storage::get::(keys::TOKEN_COUNT).unwrap_or(0) +} + +fn set_token_count(count: u32) { + storage::set(keys::TOKEN_COUNT, &count); +} + +fn get_token(symbol: &str) -> Option { + storage::get_with_suffix::(keys::TOKENS, symbol.as_bytes()) +} + +fn set_token(symbol: &str, token: &TokenInfo) { + storage::set_with_suffix(keys::TOKENS, symbol.as_bytes(), token); +} + +fn get_swap_count() -> u64 { + storage::get::(keys::SWAP_COUNT).unwrap_or(0) +} + +fn set_swap_count(count: u64) { + storage::set(keys::SWAP_COUNT, &count); +} + +fn get_pending_swap(id: u64) -> Option { + storage::get_with_suffix::(keys::PENDING_SWAPS, &id.to_le_bytes()) +} + +fn set_pending_swap(id: u64, swap: &PendingSwap) { + storage::set_with_suffix(keys::PENDING_SWAPS, &id.to_le_bytes(), swap); +} + +fn get_total_volume() -> u64 { + storage::get::(keys::TOTAL_VOLUME).unwrap_or(0) +} + +fn set_total_volume(volume: u64) { + storage::set(keys::TOTAL_VOLUME, &volume); +} + +fn get_collected_fees(token: &str) -> u64 { + storage::get_with_suffix::(keys::COLLECTED_FEES, token.as_bytes()).unwrap_or(0) +} + +fn set_collected_fees(token: &str, amount: u64) { + storage::set_with_suffix(keys::COLLECTED_FEES, token.as_bytes(), &amount); +} + +// ============================================================================= +// CALCULATION HELPERS +// ============================================================================= + +/// Calculate aggregation fee +fn calculate_aggregation_fee(amount: u64) -> u64 { + (amount as u128 * AGGREGATION_FEE_BPS as u128 / BPS_DENOMINATOR as u128) as u64 +} + +/// Apply slippage tolerance +fn apply_slippage(amount: u64, slippage_bps: u64) -> u64 { + let slippage = (amount as u128 * slippage_bps as u128 / BPS_DENOMINATOR as u128) as u64; + amount.saturating_sub(slippage) +} + +/// Find best quote among sources +fn find_best_quote(quotes: &[Quote]) -> Option<&Quote> { + quotes.iter() + .filter(|q| q.available) + .max_by_key(|q| q.amount_out) +} + +/// Create optimal routing plan +fn create_routing_plan( + quotes: &[Quote], + amount_in: u64, + slippage_bps: u64, +) -> Vec { + let mut routes = Vec::new(); + + // Filter available quotes and sort by output (best first) + let mut available: Vec<_> = quotes.iter() + .filter(|q| q.available && q.amount_out > 0) + .collect(); + available.sort_by(|a, b| b.amount_out.cmp(&a.amount_out)); + + if available.is_empty() { + return routes; + } + + // For simplicity, use single best route for small amounts + // Split across multiple routes for large amounts to reduce price impact + let total_liquidity: u64 = available.iter().map(|q| q.amount_out).sum(); + + if available.len() == 1 || amount_in < total_liquidity / 10 { + // Single route + let best = &available[0]; + routes.push(Route { + source_id: best.source_id, + percentage: 100, + expected_output: best.amount_out, + min_output: apply_slippage(best.amount_out, slippage_bps), + }); + } else { + // Split routing - distribute based on available liquidity + let mut remaining_percent = 100u64; + + for (i, quote) in available.iter().take(MAX_ROUTES).enumerate() { + let percent = if i == available.len().min(MAX_ROUTES) - 1 { + remaining_percent // Last route gets remainder + } else { + // Proportional to output + let share = (quote.amount_out as u128 * 100) / total_liquidity as u128; + (share as u64).max(MIN_SPLIT_PERCENT).min(remaining_percent - MIN_SPLIT_PERCENT) + }; + + if percent < MIN_SPLIT_PERCENT { + continue; + } + + let route_amount_in = (amount_in as u128 * percent as u128 / 100) as u64; + let expected_out = (quote.amount_out as u128 * percent as u128 / 100) as u64; + + routes.push(Route { + source_id: quote.source_id, + percentage: percent, + expected_output: expected_out, + min_output: apply_slippage(expected_out, slippage_bps), + }); + + remaining_percent = remaining_percent.saturating_sub(percent); + if remaining_percent < MIN_SPLIT_PERCENT { + break; + } + } + } + + routes +} + +// ============================================================================= +// ENTRY POINTS +// ============================================================================= + +synor_sdk::entry_point!(init, call); + +fn init(params: &[u8]) -> Result<()> { + require!(!is_initialized(), Error::invalid_args("Already initialized")); + + #[derive(BorshDeserialize)] + struct InitParams { + fee_collector: Address, + } + + let params = InitParams::try_from_slice(params) + .map_err(|_| Error::invalid_args("Invalid init params"))?; + + storage::set(keys::OWNER, &caller()); + storage::set(keys::INITIALIZED, &true); + storage::set(keys::PAUSED, &false); + storage::set(keys::FEE_COLLECTOR, ¶ms.fee_collector); + set_source_count(0); + set_token_count(0); + set_swap_count(0); + set_total_volume(0); + + Ok(()) +} + +fn call(selector: &[u8], params: &[u8]) -> Result> { + // Method selectors + let add_source_sel = synor_sdk::method_selector("add_source"); + let update_source_sel = synor_sdk::method_selector("update_source"); + let add_token_sel = synor_sdk::method_selector("add_token"); + let get_quote_sel = synor_sdk::method_selector("get_quote"); + let get_quotes_sel = synor_sdk::method_selector("get_quotes"); + let swap_sel = synor_sdk::method_selector("swap"); + let swap_exact_out_sel = synor_sdk::method_selector("swap_exact_out"); + let get_swap_status_sel = synor_sdk::method_selector("get_swap_status"); + let complete_ibc_swap_sel = synor_sdk::method_selector("complete_ibc_swap"); + let refund_swap_sel = synor_sdk::method_selector("refund_swap"); + let get_source_sel = synor_sdk::method_selector("get_source"); + let get_token_sel = synor_sdk::method_selector("get_token"); + let get_stats_sel = synor_sdk::method_selector("get_stats"); + let pause_sel = synor_sdk::method_selector("pause"); + let unpause_sel = synor_sdk::method_selector("unpause"); + + match selector { + // ===== Admin Methods ===== + + s if s == add_source_sel => { + let owner = get_owner().ok_or(Error::Unauthorized)?; + require_auth!(owner); + + #[derive(BorshDeserialize)] + struct Args { + name: String, + source_type: SourceType, + contract: Option
, + ibc_channel: Option, + chain_id: Option, + base_fee_bps: u64, + priority: u32, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Invalid add_source params"))?; + + let source_id = get_source_count(); + let source = LiquiditySource { + id: source_id, + name: args.name.clone(), + source_type: args.source_type, + contract: args.contract, + ibc_channel: args.ibc_channel, + chain_id: args.chain_id, + status: SourceStatus::Active, + base_fee_bps: args.base_fee_bps, + priority: args.priority, + total_volume: 0, + success_rate_bps: 10000, // Start at 100% + avg_latency_ms: 0, + }; + + set_source(source_id, &source); + set_source_count(source_id + 1); + + emit_raw( + &[event_topic(b"SourceAdded")], + &borsh::to_vec(&SourceAdded { + source_id, + name: args.name, + source_type: args.source_type, + }).unwrap(), + ); + + Ok(borsh::to_vec(&source_id).unwrap()) + } + + s if s == update_source_sel => { + let owner = get_owner().ok_or(Error::Unauthorized)?; + require_auth!(owner); + + #[derive(BorshDeserialize)] + struct Args { + source_id: u32, + status: SourceStatus, + base_fee_bps: u64, + priority: u32, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Invalid update_source params"))?; + + let mut source = get_source(args.source_id) + .ok_or_else(|| Error::invalid_args("Source not found"))?; + + source.status = args.status; + source.base_fee_bps = args.base_fee_bps; + source.priority = args.priority; + + set_source(args.source_id, &source); + + Ok(borsh::to_vec(&true).unwrap()) + } + + s if s == add_token_sel => { + let owner = get_owner().ok_or(Error::Unauthorized)?; + require_auth!(owner); + + #[derive(BorshDeserialize)] + struct Args { + symbol: String, + local_address: Address, + decimals: u8, + ibc_denoms: Vec<(String, String)>, + is_native: bool, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Invalid add_token params"))?; + + let token = TokenInfo { + symbol: args.symbol.clone(), + local_address: args.local_address, + decimals: args.decimals, + ibc_denoms: args.ibc_denoms, + is_native: args.is_native, + }; + + set_token(&args.symbol, &token); + set_token_count(get_token_count() + 1); + + Ok(borsh::to_vec(&true).unwrap()) + } + + s if s == pause_sel => { + let owner = get_owner().ok_or(Error::Unauthorized)?; + require_auth!(owner); + storage::set(keys::PAUSED, &true); + Ok(borsh::to_vec(&true).unwrap()) + } + + s if s == unpause_sel => { + let owner = get_owner().ok_or(Error::Unauthorized)?; + require_auth!(owner); + storage::set(keys::PAUSED, &false); + Ok(borsh::to_vec(&true).unwrap()) + } + + // ===== Quote Methods ===== + + s if s == get_quote_sel => { + #[derive(BorshDeserialize)] + struct Args { + token_in: String, + token_out: String, + amount_in: u64, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Invalid get_quote params"))?; + + // Get quotes from all sources and find best + let quotes = get_all_quotes(&args.token_in, &args.token_out, args.amount_in)?; + let best = find_best_quote("es); + + Ok(borsh::to_vec(&best).unwrap()) + } + + s if s == get_quotes_sel => { + #[derive(BorshDeserialize)] + struct Args { + token_in: String, + token_out: String, + amount_in: u64, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Invalid get_quotes params"))?; + + let quotes = get_all_quotes(&args.token_in, &args.token_out, args.amount_in)?; + + Ok(borsh::to_vec("es).unwrap()) + } + + // ===== Swap Methods ===== + + s if s == swap_sel => { + require!(!is_paused(), Error::invalid_args("Contract paused")); + + #[derive(BorshDeserialize)] + struct Args { + token_in: String, + token_out: String, + amount_in: u64, + min_amount_out: u64, + slippage_bps: u64, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Invalid swap params"))?; + + require!(args.slippage_bps <= MAX_SLIPPAGE_BPS, + Error::invalid_args("Slippage too high")); + + // Get quotes and create routing plan + let quotes = get_all_quotes(&args.token_in, &args.token_out, args.amount_in)?; + require!(!quotes.is_empty(), Error::invalid_args("No quotes available")); + + let routes = create_routing_plan("es, args.amount_in, args.slippage_bps); + require!(!routes.is_empty(), Error::invalid_args("No routes available")); + + let total_expected: u64 = routes.iter().map(|r| r.expected_output).sum(); + require!(total_expected >= args.min_amount_out, + Error::invalid_args("Output below minimum")); + + // Calculate aggregation fee + let fee = calculate_aggregation_fee(args.amount_in); + + // Create swap plan + let swap_id = get_swap_count(); + let now = timestamp(); + + let plan = SwapPlan { + token_in: args.token_in.clone(), + token_out: args.token_out.clone(), + amount_in: args.amount_in, + routes: routes.clone(), + total_expected_output: total_expected, + min_output: args.min_amount_out, + aggregation_fee: fee, + valid_until: now + IBC_TIMEOUT_SECONDS, + }; + + // For local-only routes, execute immediately + let is_local_only = routes.iter().all(|r| { + get_source(r.source_id) + .map(|s| s.source_type == SourceType::LocalAmm) + .unwrap_or(false) + }); + + if is_local_only { + // Execute local swap (simplified - would call DEX contract) + let amount_out = execute_local_swap(&plan)?; + + // Update statistics + set_total_volume(get_total_volume() + args.amount_in); + let fees = get_collected_fees(&args.token_in); + set_collected_fees(&args.token_in, fees + fee); + + emit_raw( + &[event_topic(b"SwapExecuted")], + &borsh::to_vec(&SwapExecuted { + swap_id, + user: caller(), + token_in: args.token_in, + token_out: args.token_out, + amount_in: args.amount_in, + amount_out, + fee, + routes_used: routes.len() as u32, + }).unwrap(), + ); + + set_swap_count(swap_id + 1); + return Ok(borsh::to_vec(&amount_out).unwrap()); + } + + // For IBC routes, create pending swap + let pending = PendingSwap { + id: swap_id, + user: caller(), + plan, + status: SwapStatus::Pending, + amount_received: 0, + routes_completed: 0, + created_at: now, + timeout_at: now + IBC_TIMEOUT_SECONDS, + }; + + set_pending_swap(swap_id, &pending); + set_swap_count(swap_id + 1); + + // Initiate IBC transfers (would call IBC module) + // In production, this triggers actual IBC packet sending + + Ok(borsh::to_vec(&swap_id).unwrap()) + } + + s if s == complete_ibc_swap_sel => { + // Called by relayer when IBC swap completes + #[derive(BorshDeserialize)] + struct Args { + swap_id: u64, + source_id: u32, + amount_received: u64, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Invalid complete_ibc_swap params"))?; + + let mut swap = get_pending_swap(args.swap_id) + .ok_or_else(|| Error::invalid_args("Swap not found"))?; + + require!(swap.status == SwapStatus::Pending || swap.status == SwapStatus::PartiallyFilled, + Error::invalid_args("Swap not in progress")); + + swap.amount_received += args.amount_received; + swap.routes_completed += 1; + + emit_raw( + &[event_topic(b"SwapPartialFill")], + &borsh::to_vec(&SwapPartialFill { + swap_id: args.swap_id, + source_id: args.source_id, + amount_filled: args.amount_received, + }).unwrap(), + ); + + // Check if all routes completed + if swap.routes_completed as usize >= swap.plan.routes.len() { + swap.status = SwapStatus::Completed; + + // Update statistics + set_total_volume(get_total_volume() + swap.plan.amount_in); + let fees = get_collected_fees(&swap.plan.token_in); + set_collected_fees(&swap.plan.token_in, fees + swap.plan.aggregation_fee); + + // Update source stats + for route in &swap.plan.routes { + if let Some(mut source) = get_source(route.source_id) { + source.total_volume += (swap.plan.amount_in as u128 * route.percentage as u128 / 100) as u64; + set_source(route.source_id, &source); + } + } + + emit_raw( + &[event_topic(b"SwapExecuted")], + &borsh::to_vec(&SwapExecuted { + swap_id: args.swap_id, + user: swap.user, + token_in: swap.plan.token_in.clone(), + token_out: swap.plan.token_out.clone(), + amount_in: swap.plan.amount_in, + amount_out: swap.amount_received, + fee: swap.plan.aggregation_fee, + routes_used: swap.routes_completed, + }).unwrap(), + ); + } else { + swap.status = SwapStatus::PartiallyFilled; + } + + set_pending_swap(args.swap_id, &swap); + + Ok(borsh::to_vec(&swap.amount_received).unwrap()) + } + + s if s == refund_swap_sel => { + #[derive(BorshDeserialize)] + struct Args { + swap_id: u64, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Invalid refund_swap params"))?; + + let mut swap = get_pending_swap(args.swap_id) + .ok_or_else(|| Error::invalid_args("Swap not found"))?; + + // Can only refund if timed out or failed + let now = timestamp(); + require!( + now > swap.timeout_at || swap.status == SwapStatus::Failed, + Error::invalid_args("Swap not refundable yet") + ); + + swap.status = SwapStatus::Refunded; + set_pending_swap(args.swap_id, &swap); + + emit_raw( + &[event_topic(b"SwapFailed")], + &borsh::to_vec(&SwapFailed { + swap_id: args.swap_id, + reason: String::from("Timed out"), + }).unwrap(), + ); + + // In production, would transfer refund back to user + + Ok(borsh::to_vec(&swap.plan.amount_in).unwrap()) + } + + // ===== Query Methods ===== + + s if s == get_swap_status_sel => { + #[derive(BorshDeserialize)] + struct Args { + swap_id: u64, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Expected (swap_id: u64)"))?; + + let swap = get_pending_swap(args.swap_id); + Ok(borsh::to_vec(&swap).unwrap()) + } + + s if s == get_source_sel => { + #[derive(BorshDeserialize)] + struct Args { + source_id: u32, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Expected (source_id: u32)"))?; + + let source = get_source(args.source_id); + Ok(borsh::to_vec(&source).unwrap()) + } + + s if s == get_token_sel => { + #[derive(BorshDeserialize)] + struct Args { + symbol: String, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Expected (symbol: String)"))?; + + let token = get_token(&args.symbol); + Ok(borsh::to_vec(&token).unwrap()) + } + + s if s == get_stats_sel => { + #[derive(BorshSerialize)] + struct Stats { + total_volume: u64, + source_count: u32, + token_count: u32, + swap_count: u64, + } + + let stats = Stats { + total_volume: get_total_volume(), + source_count: get_source_count(), + token_count: get_token_count(), + swap_count: get_swap_count(), + }; + + Ok(borsh::to_vec(&stats).unwrap()) + } + + _ => Err(Error::InvalidMethod), + } +} + +// ============================================================================= +// QUOTE AND EXECUTION LOGIC +// ============================================================================= + +/// Get quotes from all active sources +fn get_all_quotes(token_in: &str, token_out: &str, amount_in: u64) -> Result> { + let source_count = get_source_count(); + let mut quotes = Vec::new(); + + for i in 0..source_count { + if let Some(source) = get_source(i) { + if source.status != SourceStatus::Active { + continue; + } + + // Get quote from source (simplified - would call actual source) + let quote = get_source_quote(&source, token_in, token_out, amount_in); + quotes.push(quote); + } + } + + // Sort by output amount (best first) + quotes.sort_by(|a, b| b.amount_out.cmp(&a.amount_out)); + + Ok(quotes) +} + +/// Get quote from a specific source +fn get_source_quote(source: &LiquiditySource, _token_in: &str, _token_out: &str, amount_in: u64) -> Quote { + // In production, this would: + // - For LocalAmm: call the DEX contract's quote function + // - For IbcDex: query cached prices or send IBC query + // - For CexApi: use off-chain oracle data + + // Simplified: estimate output based on source fee + let fee = (amount_in as u128 * source.base_fee_bps as u128 / BPS_DENOMINATOR as u128) as u64; + let amount_out = amount_in.saturating_sub(fee); + + // Add some variation based on source type for demonstration + let adjusted_out = match source.source_type { + SourceType::LocalAmm => amount_out, + SourceType::IbcDex => amount_out + (amount_out / 1000), // IBC might have better prices + SourceType::CexApi => amount_out + (amount_out / 500), // CEX often has best prices + SourceType::OrderBook => amount_out, + }; + + let estimated_time = match source.source_type { + SourceType::LocalAmm => 2, + SourceType::IbcDex => 30, + SourceType::CexApi => 5, + SourceType::OrderBook => 3, + }; + + Quote { + source_id: source.id, + source_name: source.name.clone(), + amount_in, + amount_out: adjusted_out, + price_impact_bps: 10, // Simplified + fee, + available: true, + estimated_time, + } +} + +/// Execute local swap (calls DEX contract) +fn execute_local_swap(plan: &SwapPlan) -> Result { + // In production, this would: + // 1. Call each source's swap function with the allocated amount + // 2. Aggregate the outputs + // 3. Handle any failures + + // Simplified: return expected output + Ok(plan.total_expected_output) +} diff --git a/contracts/oracle/Cargo.toml b/contracts/oracle/Cargo.toml new file mode 100644 index 0000000..23c90c4 --- /dev/null +++ b/contracts/oracle/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "synor-oracle" +version = "0.1.0" +edition = "2021" +authors = ["Synor Team "] +description = "Decentralized price oracle with multi-source aggregation" +license = "MIT OR Apache-2.0" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +default = [] + +[dependencies] +synor-sdk = { path = "../../crates/synor-sdk", default-features = false } +borsh = { version = "1.3", default-features = false, features = ["derive"] } + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/contracts/oracle/src/lib.rs b/contracts/oracle/src/lib.rs new file mode 100644 index 0000000..bf8b135 --- /dev/null +++ b/contracts/oracle/src/lib.rs @@ -0,0 +1,855 @@ +//! Decentralized Price Oracle Contract +//! +//! This contract provides manipulation-resistant price feeds by: +//! - Aggregating prices from multiple trusted reporters +//! - Using median aggregation to resist outliers +//! - Implementing TWAP (Time-Weighted Average Price) +//! - Detecting and rejecting stale prices +//! +//! # Architecture +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────┐ +//! │ ORACLE ARCHITECTURE │ +//! ├─────────────────────────────────────────────────────────────┤ +//! │ │ +//! │ REPORTERS (Off-chain price feeds): │ +//! │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ +//! │ │Chainlink│ │ Pyth │ │ Band │ │ Custom │ │ +//! │ │Reporter │ │Reporter│ │Reporter│ │Reporter│ │ +//! │ └───┬────┘ └───┬────┘ └───┬────┘ └───┬────┘ │ +//! │ │ │ │ │ │ +//! │ ▼ ▼ ▼ ▼ │ +//! │ ┌─────────────────────────────────────────────┐ │ +//! │ │ MEDIAN AGGREGATION │ │ +//! │ │ [100, 101, 99, 102, 100] → 100 │ │ +//! │ │ Rejects outliers automatically │ │ +//! │ └─────────────────────────────────────────────┘ │ +//! │ │ │ +//! │ ▼ │ +//! │ ┌─────────────────────────────────────────────┐ │ +//! │ │ TWAP CALCULATION │ │ +//! │ │ Accumulates prices over time │ │ +//! │ │ twap = Σ(price * time) / Σ(time) │ │ +//! │ └─────────────────────────────────────────────┘ │ +//! │ │ │ +//! │ ▼ │ +//! │ ┌─────────────────────────────────────────────┐ │ +//! │ │ CONSUMER CONTRACTS │ │ +//! │ │ Perps, DEX, Lending, Options │ │ +//! │ └─────────────────────────────────────────────┘ │ +//! └─────────────────────────────────────────────────────────────┘ +//! ``` + +#![no_std] + +extern crate alloc; + +use alloc::string::String; +use alloc::vec::Vec; +use borsh::{BorshDeserialize, BorshSerialize}; +use synor_sdk::prelude::*; +use synor_sdk::{require, require_auth}; + +// ============================================================================= +// CONSTANTS +// ============================================================================= + +/// Price precision (6 decimals) +pub const PRICE_PRECISION: u64 = 1_000_000; + +/// Maximum price staleness (5 minutes) +pub const MAX_STALENESS: u64 = 5 * 60; + +/// Minimum reporters required for valid price +pub const MIN_REPORTERS: usize = 1; + +/// TWAP observation window (1 hour) +pub const TWAP_WINDOW: u64 = 60 * 60; + +/// Maximum TWAP observations to store +pub const MAX_TWAP_OBSERVATIONS: usize = 60; + +/// Maximum deviation between reporters (5% = 500 bps) +pub const MAX_DEVIATION_BPS: u64 = 500; + +/// Basis points denominator +pub const BPS_DENOMINATOR: u64 = 10000; + +// ============================================================================= +// EVENT TOPICS +// ============================================================================= + +fn event_topic(name: &[u8]) -> [u8; 32] { + use synor_sdk::crypto::blake3_hash; + blake3_hash(name).0 +} + +// ============================================================================= +// STORAGE KEYS +// ============================================================================= + +mod keys { + pub const OWNER: &[u8] = b"oracle:owner"; + pub const INITIALIZED: &[u8] = b"oracle:initialized"; + + // Reporters + pub const REPORTERS: &[u8] = b"oracle:reporters"; + pub const REPORTER_COUNT: &[u8] = b"oracle:reporter_count"; + + // Price feeds + pub const FEEDS: &[u8] = b"oracle:feeds"; + pub const FEED_COUNT: &[u8] = b"oracle:feed_count"; + + // Prices + pub const PRICES: &[u8] = b"oracle:prices"; + pub const REPORTER_PRICES: &[u8] = b"oracle:reporter_prices"; + + // TWAP + pub const TWAP_OBSERVATIONS: &[u8] = b"oracle:twap"; + pub const TWAP_INDEX: &[u8] = b"oracle:twap_idx"; +} + +// ============================================================================= +// DATA STRUCTURES +// ============================================================================= + +/// Reporter status +#[derive(Clone, Copy, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub enum ReporterStatus { + Active, + Suspended, + Removed, +} + +/// A trusted price reporter +#[derive(Clone, Debug, BorshSerialize, BorshDeserialize)] +pub struct Reporter { + /// Reporter address + pub address: Address, + /// Reporter name/description + pub name: String, + /// Status + pub status: ReporterStatus, + /// Total reports submitted + pub report_count: u64, + /// Last report timestamp + pub last_report: u64, + /// When registered + pub registered_at: u64, +} + +/// Price feed configuration +#[derive(Clone, Debug, BorshSerialize, BorshDeserialize)] +pub struct PriceFeed { + /// Feed ID + pub id: u32, + /// Base asset (e.g., "BTC") + pub base_asset: String, + /// Quote asset (e.g., "USD") + pub quote_asset: String, + /// Decimals (usually 6) + pub decimals: u8, + /// Minimum reporters required + pub min_reporters: u8, + /// Maximum staleness (seconds) + pub max_staleness: u64, + /// Whether feed is active + pub is_active: bool, + /// Created timestamp + pub created_at: u64, +} + +/// Individual price report from a reporter +#[derive(Clone, Debug, BorshSerialize, BorshDeserialize)] +pub struct PriceReport { + /// Feed ID + pub feed_id: u32, + /// Reporter address + pub reporter: Address, + /// Price (in PRICE_PRECISION) + pub price: u64, + /// Confidence (lower = more confident) + pub confidence: u64, + /// Timestamp of report + pub timestamp: u64, +} + +/// Aggregated price for a feed +#[derive(Clone, Debug, BorshSerialize, BorshDeserialize)] +pub struct AggregatedPrice { + /// Feed ID + pub feed_id: u32, + /// Aggregated price (median) + pub price: u64, + /// Number of reporters + pub num_reporters: u8, + /// Minimum reported price + pub min_price: u64, + /// Maximum reported price + pub max_price: u64, + /// Average confidence + pub avg_confidence: u64, + /// Aggregation timestamp + pub timestamp: u64, + /// Is price valid (enough reporters, not stale) + pub is_valid: bool, +} + +/// TWAP observation +#[derive(Clone, Debug, BorshSerialize, BorshDeserialize)] +pub struct TwapObservation { + /// Price at this observation + pub price: u64, + /// Cumulative price * time + pub cumulative: u128, + /// Timestamp + pub timestamp: u64, +} + +// ============================================================================= +// EVENTS +// ============================================================================= + +#[derive(BorshSerialize)] +pub struct ReporterRegistered { + pub reporter: Address, + pub name: String, +} + +#[derive(BorshSerialize)] +pub struct PriceFeedCreated { + pub feed_id: u32, + pub base_asset: String, + pub quote_asset: String, +} + +#[derive(BorshSerialize)] +pub struct PriceUpdated { + pub feed_id: u32, + pub price: u64, + pub num_reporters: u8, + pub timestamp: u64, +} + +#[derive(BorshSerialize)] +pub struct ReporterSuspended { + pub reporter: Address, + pub reason: String, +} + +// ============================================================================= +// STORAGE HELPERS +// ============================================================================= + +fn get_owner() -> Option
{ + storage::get::
(keys::OWNER) +} + +fn is_owner(addr: &Address) -> bool { + get_owner().map(|o| o == *addr).unwrap_or(false) +} + +fn is_initialized() -> bool { + storage::get::(keys::INITIALIZED).unwrap_or(false) +} + +fn get_reporter_count() -> u32 { + storage::get::(keys::REPORTER_COUNT).unwrap_or(0) +} + +fn set_reporter_count(count: u32) { + storage::set(keys::REPORTER_COUNT, &count); +} + +fn get_reporter(addr: &Address) -> Option { + storage::get_with_suffix::(keys::REPORTERS, addr.as_bytes()) +} + +fn set_reporter(addr: &Address, reporter: &Reporter) { + storage::set_with_suffix(keys::REPORTERS, addr.as_bytes(), reporter); +} + +fn get_feed_count() -> u32 { + storage::get::(keys::FEED_COUNT).unwrap_or(0) +} + +fn set_feed_count(count: u32) { + storage::set(keys::FEED_COUNT, &count); +} + +fn get_feed(id: u32) -> Option { + storage::get_with_suffix::(keys::FEEDS, &id.to_le_bytes()) +} + +fn set_feed(id: u32, feed: &PriceFeed) { + storage::set_with_suffix(keys::FEEDS, &id.to_le_bytes(), feed); +} + +fn get_aggregated_price(feed_id: u32) -> Option { + storage::get_with_suffix::(keys::PRICES, &feed_id.to_le_bytes()) +} + +fn set_aggregated_price(feed_id: u32, price: &AggregatedPrice) { + storage::set_with_suffix(keys::PRICES, &feed_id.to_le_bytes(), price); +} + +fn get_reporter_price(feed_id: u32, reporter: &Address) -> Option { + let mut key = feed_id.to_le_bytes().to_vec(); + key.extend_from_slice(reporter.as_bytes()); + storage::get_with_suffix::(keys::REPORTER_PRICES, &key) +} + +fn set_reporter_price(feed_id: u32, reporter: &Address, report: &PriceReport) { + let mut key = feed_id.to_le_bytes().to_vec(); + key.extend_from_slice(reporter.as_bytes()); + storage::set_with_suffix(keys::REPORTER_PRICES, &key, report); +} + +fn get_twap_index(feed_id: u32) -> usize { + storage::get_with_suffix::(keys::TWAP_INDEX, &feed_id.to_le_bytes()).unwrap_or(0) +} + +fn set_twap_index(feed_id: u32, index: usize) { + storage::set_with_suffix(keys::TWAP_INDEX, &feed_id.to_le_bytes(), &index); +} + +fn get_twap_observation(feed_id: u32, index: usize) -> Option { + let mut key = feed_id.to_le_bytes().to_vec(); + key.extend_from_slice(&(index as u32).to_le_bytes()); + storage::get_with_suffix::(keys::TWAP_OBSERVATIONS, &key) +} + +fn set_twap_observation(feed_id: u32, index: usize, obs: &TwapObservation) { + let mut key = feed_id.to_le_bytes().to_vec(); + key.extend_from_slice(&(index as u32).to_le_bytes()); + storage::set_with_suffix(keys::TWAP_OBSERVATIONS, &key, obs); +} + +// ============================================================================= +// CALCULATION HELPERS +// ============================================================================= + +/// Calculate median of prices (manipulation resistant) +fn calculate_median(prices: &mut [u64]) -> u64 { + if prices.is_empty() { + return 0; + } + + prices.sort_unstable(); + let len = prices.len(); + + if len % 2 == 0 { + (prices[len / 2 - 1] + prices[len / 2]) / 2 + } else { + prices[len / 2] + } +} + +/// Check if price deviation is within acceptable range +fn is_deviation_acceptable(prices: &[u64]) -> bool { + if prices.is_empty() { + return false; + } + + let min = *prices.iter().min().unwrap(); + let max = *prices.iter().max().unwrap(); + + if min == 0 { + return false; + } + + let deviation_bps = ((max - min) as u128 * BPS_DENOMINATOR as u128) / min as u128; + deviation_bps <= MAX_DEVIATION_BPS as u128 +} + +// ============================================================================= +// ENTRY POINTS +// ============================================================================= + +synor_sdk::entry_point!(init, call); + +fn init(params: &[u8]) -> Result<()> { + require!(!is_initialized(), Error::invalid_args("Already initialized")); + + // No params needed, just set owner + let _ = params; + + storage::set(keys::OWNER, &caller()); + storage::set(keys::INITIALIZED, &true); + set_reporter_count(0); + set_feed_count(0); + + Ok(()) +} + +fn call(selector: &[u8], params: &[u8]) -> Result> { + // Method selectors + let register_reporter_sel = synor_sdk::method_selector("register_reporter"); + let suspend_reporter_sel = synor_sdk::method_selector("suspend_reporter"); + let create_feed_sel = synor_sdk::method_selector("create_feed"); + let report_price_sel = synor_sdk::method_selector("report_price"); + let report_prices_batch_sel = synor_sdk::method_selector("report_prices_batch"); + let get_price_sel = synor_sdk::method_selector("get_price"); + let get_twap_sel = synor_sdk::method_selector("get_twap"); + let get_feed_sel = synor_sdk::method_selector("get_feed"); + let get_reporter_sel = synor_sdk::method_selector("get_reporter"); + let is_price_valid_sel = synor_sdk::method_selector("is_price_valid"); + + match selector { + // ===== Admin Methods ===== + + s if s == register_reporter_sel => { + let owner = get_owner().ok_or(Error::Unauthorized)?; + require_auth!(owner); + + #[derive(BorshDeserialize)] + struct Args { + reporter: Address, + name: String, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Invalid register_reporter params"))?; + + require!(get_reporter(&args.reporter).is_none(), + Error::invalid_args("Reporter already registered")); + + let reporter = Reporter { + address: args.reporter, + name: args.name.clone(), + status: ReporterStatus::Active, + report_count: 0, + last_report: 0, + registered_at: timestamp(), + }; + + set_reporter(&args.reporter, &reporter); + set_reporter_count(get_reporter_count() + 1); + + emit_raw( + &[event_topic(b"ReporterRegistered")], + &borsh::to_vec(&ReporterRegistered { + reporter: args.reporter, + name: args.name, + }).unwrap(), + ); + + Ok(borsh::to_vec(&true).unwrap()) + } + + s if s == suspend_reporter_sel => { + let owner = get_owner().ok_or(Error::Unauthorized)?; + require_auth!(owner); + + #[derive(BorshDeserialize)] + struct Args { + reporter: Address, + reason: String, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Invalid suspend_reporter params"))?; + + let mut reporter = get_reporter(&args.reporter) + .ok_or_else(|| Error::invalid_args("Reporter not found"))?; + + reporter.status = ReporterStatus::Suspended; + set_reporter(&args.reporter, &reporter); + + emit_raw( + &[event_topic(b"ReporterSuspended")], + &borsh::to_vec(&ReporterSuspended { + reporter: args.reporter, + reason: args.reason, + }).unwrap(), + ); + + Ok(borsh::to_vec(&true).unwrap()) + } + + s if s == create_feed_sel => { + let owner = get_owner().ok_or(Error::Unauthorized)?; + require_auth!(owner); + + #[derive(BorshDeserialize)] + struct Args { + base_asset: String, + quote_asset: String, + decimals: u8, + min_reporters: u8, + max_staleness: u64, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Invalid create_feed params"))?; + + let feed_id = get_feed_count(); + let feed = PriceFeed { + id: feed_id, + base_asset: args.base_asset.clone(), + quote_asset: args.quote_asset.clone(), + decimals: args.decimals, + min_reporters: args.min_reporters.max(1), + max_staleness: args.max_staleness.max(60), // min 60 seconds + is_active: true, + created_at: timestamp(), + }; + + set_feed(feed_id, &feed); + set_feed_count(feed_id + 1); + + emit_raw( + &[event_topic(b"PriceFeedCreated")], + &borsh::to_vec(&PriceFeedCreated { + feed_id, + base_asset: args.base_asset, + quote_asset: args.quote_asset, + }).unwrap(), + ); + + Ok(borsh::to_vec(&feed_id).unwrap()) + } + + // ===== Reporter Methods ===== + + s if s == report_price_sel => { + let reporter_addr = caller(); + let reporter = get_reporter(&reporter_addr) + .ok_or_else(|| Error::invalid_args("Not a registered reporter"))?; + + require!(reporter.status == ReporterStatus::Active, + Error::invalid_args("Reporter not active")); + + #[derive(BorshDeserialize)] + struct Args { + feed_id: u32, + price: u64, + confidence: u64, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Invalid report_price params"))?; + + let feed = get_feed(args.feed_id) + .ok_or_else(|| Error::invalid_args("Feed not found"))?; + require!(feed.is_active, Error::invalid_args("Feed not active")); + + let now = timestamp(); + + // Store individual reporter price + let report = PriceReport { + feed_id: args.feed_id, + reporter: reporter_addr, + price: args.price, + confidence: args.confidence, + timestamp: now, + }; + set_reporter_price(args.feed_id, &reporter_addr, &report); + + // Update reporter stats + let mut reporter = reporter; + reporter.report_count += 1; + reporter.last_report = now; + set_reporter(&reporter_addr, &reporter); + + // Aggregate all reporter prices for this feed + aggregate_prices(args.feed_id, &feed)?; + + Ok(borsh::to_vec(&true).unwrap()) + } + + s if s == report_prices_batch_sel => { + let reporter_addr = caller(); + let reporter = get_reporter(&reporter_addr) + .ok_or_else(|| Error::invalid_args("Not a registered reporter"))?; + + require!(reporter.status == ReporterStatus::Active, + Error::invalid_args("Reporter not active")); + + #[derive(BorshDeserialize)] + struct PriceInput { + feed_id: u32, + price: u64, + confidence: u64, + } + + #[derive(BorshDeserialize)] + struct Args { + prices: Vec, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Invalid report_prices_batch params"))?; + + let now = timestamp(); + let mut updated_feeds = Vec::new(); + + for input in &args.prices { + let feed = match get_feed(input.feed_id) { + Some(f) if f.is_active => f, + _ => continue, + }; + + let report = PriceReport { + feed_id: input.feed_id, + reporter: reporter_addr, + price: input.price, + confidence: input.confidence, + timestamp: now, + }; + set_reporter_price(input.feed_id, &reporter_addr, &report); + + // Aggregate prices for this feed + if aggregate_prices(input.feed_id, &feed).is_ok() { + updated_feeds.push(input.feed_id); + } + } + + // Update reporter stats + let mut reporter = reporter; + reporter.report_count += args.prices.len() as u64; + reporter.last_report = now; + set_reporter(&reporter_addr, &reporter); + + Ok(borsh::to_vec(&updated_feeds).unwrap()) + } + + // ===== Query Methods ===== + + s if s == get_price_sel => { + #[derive(BorshDeserialize)] + struct Args { + feed_id: u32, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Expected (feed_id: u32)"))?; + + let price = get_aggregated_price(args.feed_id); + Ok(borsh::to_vec(&price).unwrap()) + } + + s if s == get_twap_sel => { + #[derive(BorshDeserialize)] + struct Args { + feed_id: u32, + window: u64, // Time window in seconds + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Expected (feed_id: u32, window: u64)"))?; + + let twap = calculate_twap(args.feed_id, args.window); + Ok(borsh::to_vec(&twap).unwrap()) + } + + s if s == get_feed_sel => { + #[derive(BorshDeserialize)] + struct Args { + feed_id: u32, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Expected (feed_id: u32)"))?; + + let feed = get_feed(args.feed_id); + Ok(borsh::to_vec(&feed).unwrap()) + } + + s if s == get_reporter_sel => { + #[derive(BorshDeserialize)] + struct Args { + reporter: Address, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Expected (reporter: Address)"))?; + + let reporter = get_reporter(&args.reporter); + Ok(borsh::to_vec(&reporter).unwrap()) + } + + s if s == is_price_valid_sel => { + #[derive(BorshDeserialize)] + struct Args { + feed_id: u32, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Expected (feed_id: u32)"))?; + + let valid = match get_aggregated_price(args.feed_id) { + Some(price) => { + let feed = get_feed(args.feed_id); + let max_stale = feed.map(|f| f.max_staleness).unwrap_or(MAX_STALENESS); + price.is_valid && (timestamp() - price.timestamp) < max_stale + } + None => false, + }; + + Ok(borsh::to_vec(&valid).unwrap()) + } + + _ => Err(Error::InvalidMethod), + } +} + +// ============================================================================= +// AGGREGATION LOGIC +// ============================================================================= + +/// Aggregate prices from all reporters for a feed +fn aggregate_prices(feed_id: u32, feed: &PriceFeed) -> Result<()> { + let now = timestamp(); + let reporter_count = get_reporter_count(); + + // Collect all valid reporter prices + let mut prices: Vec = Vec::new(); + let mut confidences: Vec = Vec::new(); + + // We need to iterate through reporters and collect their prices + // In a real implementation, we'd have a list of reporter addresses + // For now, we check the last few potential reporters + for i in 0..reporter_count.min(100) { + // Try to get reporter by index (simplified) + // In production, maintain a list of reporter addresses + let reporter_key = [keys::REPORTERS, &i.to_le_bytes()].concat(); + if let Some(reporter_data) = storage::get_raw(&reporter_key) { + if let Ok(reporter) = Reporter::try_from_slice(&reporter_data) { + if reporter.status == ReporterStatus::Active { + if let Some(report) = get_reporter_price(feed_id, &reporter.address) { + // Check freshness + if now - report.timestamp < feed.max_staleness { + prices.push(report.price); + confidences.push(report.confidence); + } + } + } + } + } + } + + // Also check if there's a direct report from caller + if let Some(report) = get_reporter_price(feed_id, &caller()) { + if now - report.timestamp < feed.max_staleness { + if !prices.contains(&report.price) { + prices.push(report.price); + confidences.push(report.confidence); + } + } + } + + // Need minimum reporters + let is_valid = prices.len() >= feed.min_reporters as usize + && !prices.is_empty() + && is_deviation_acceptable(&prices); + + let (median_price, min_price, max_price) = if prices.is_empty() { + (0, 0, 0) + } else { + let min = *prices.iter().min().unwrap(); + let max = *prices.iter().max().unwrap(); + let median = calculate_median(&mut prices); + (median, min, max) + }; + + let avg_confidence = if confidences.is_empty() { + 0 + } else { + confidences.iter().sum::() / confidences.len() as u64 + }; + + let aggregated = AggregatedPrice { + feed_id, + price: median_price, + num_reporters: prices.len() as u8, + min_price, + max_price, + avg_confidence, + timestamp: now, + is_valid, + }; + + set_aggregated_price(feed_id, &aggregated); + + // Update TWAP observation + if is_valid && median_price > 0 { + update_twap(feed_id, median_price, now); + } + + emit_raw( + &[event_topic(b"PriceUpdated")], + &borsh::to_vec(&PriceUpdated { + feed_id, + price: median_price, + num_reporters: prices.len() as u8, + timestamp: now, + }).unwrap(), + ); + + Ok(()) +} + +/// Update TWAP observation +fn update_twap(feed_id: u32, price: u64, now: u64) { + let current_index = get_twap_index(feed_id); + let prev_obs = get_twap_observation(feed_id, current_index); + + let cumulative = match prev_obs { + Some(obs) => { + let time_delta = now.saturating_sub(obs.timestamp); + obs.cumulative + (obs.price as u128 * time_delta as u128) + } + None => 0, + }; + + let new_obs = TwapObservation { + price, + cumulative, + timestamp: now, + }; + + let next_index = (current_index + 1) % MAX_TWAP_OBSERVATIONS; + set_twap_observation(feed_id, next_index, &new_obs); + set_twap_index(feed_id, next_index); +} + +/// Calculate TWAP for a given window +fn calculate_twap(feed_id: u32, window: u64) -> Option { + let current_index = get_twap_index(feed_id); + let current_obs = get_twap_observation(feed_id, current_index)?; + + let now = timestamp(); + let window_start = now.saturating_sub(window); + + // Find oldest observation within window + let mut oldest_index = current_index; + let mut oldest_obs = current_obs.clone(); + + for i in 1..MAX_TWAP_OBSERVATIONS { + let idx = (current_index + MAX_TWAP_OBSERVATIONS - i) % MAX_TWAP_OBSERVATIONS; + if let Some(obs) = get_twap_observation(feed_id, idx) { + if obs.timestamp >= window_start { + oldest_index = idx; + oldest_obs = obs; + } else { + break; + } + } else { + break; + } + } + + if oldest_index == current_index { + // Only one observation, return current price + return Some(current_obs.price); + } + + let time_delta = current_obs.timestamp.saturating_sub(oldest_obs.timestamp); + if time_delta == 0 { + return Some(current_obs.price); + } + + // Add final price * time_since_last_update + let time_since_last = now.saturating_sub(current_obs.timestamp); + let cumulative_delta = current_obs.cumulative - oldest_obs.cumulative + + (current_obs.price as u128 * time_since_last as u128); + + let total_time = time_delta + time_since_last; + if total_time == 0 { + return Some(current_obs.price); + } + + Some((cumulative_delta / total_time as u128) as u64) +} diff --git a/contracts/perps/Cargo.toml b/contracts/perps/Cargo.toml new file mode 100644 index 0000000..8dd5084 --- /dev/null +++ b/contracts/perps/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "synor-perps" +version = "0.1.0" +edition = "2021" +authors = ["Synor Team "] +description = "Perpetual futures trading contract with leverage up to 100x" +license = "MIT OR Apache-2.0" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +default = [] + +[dependencies] +synor-sdk = { path = "../../crates/synor-sdk", default-features = false } +borsh = { version = "1.3", default-features = false, features = ["derive"] } + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/contracts/perps/src/lib.rs b/contracts/perps/src/lib.rs new file mode 100644 index 0000000..a5b95c2 --- /dev/null +++ b/contracts/perps/src/lib.rs @@ -0,0 +1,1118 @@ +//! Perpetual Futures Trading Contract +//! +//! This contract implements perpetual futures trading with: +//! - Long and Short positions +//! - Leverage from 2x to 100x +//! - Funding rate mechanism (keeps price anchored to spot) +//! - Mark price for liquidations (manipulation resistant) +//! - Index price from oracles +//! - Liquidation engine with insurance fund +//! - Cross-margin and isolated margin modes +//! +//! # Architecture (Similar to dYdX/GMX) +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────┐ +//! │ PERPETUAL FUTURES │ +//! ├─────────────────────────────────────────────────────────────┤ +//! │ PRICE FEEDS: │ +//! │ ┌─────────┐ ┌───────────┐ ┌─────────────┐ │ +//! │ │ Oracle │────►│Index Price│────►│ Mark Price │ │ +//! │ │ (Pyth) │ │ (Spot) │ │ (EMA-based) │ │ +//! │ └─────────┘ └───────────┘ └─────────────┘ │ +//! │ │ │ +//! │ POSITIONS: ▼ │ +//! │ ┌─────────────────────────────────────────────────────┐ │ +//! │ │ Position { size, collateral, entry_price, leverage }│ │ +//! │ │ PnL = size * (mark_price - entry_price) │ │ +//! │ │ Liquidation when: margin_ratio < maintenance_margin │ │ +//! │ └─────────────────────────────────────────────────────┘ │ +//! │ │ +//! │ FUNDING: │ +//! │ ┌─────────────────────────────────────────────────────┐ │ +//! │ │ funding_rate = (mark_price - index_price) / 8h │ │ +//! │ │ if mark > index: longs pay shorts │ │ +//! │ │ if mark < index: shorts pay longs │ │ +//! │ └─────────────────────────────────────────────────────┘ │ +//! └─────────────────────────────────────────────────────────────┘ +//! ``` + +#![no_std] + +extern crate alloc; + +use alloc::string::String; +use alloc::vec::Vec; +use borsh::{BorshDeserialize, BorshSerialize}; +use synor_sdk::prelude::*; +use synor_sdk::{require, require_auth}; + +// ============================================================================= +// CONSTANTS +// ============================================================================= + +/// Minimum leverage (2x) +pub const MIN_LEVERAGE: u64 = 2; + +/// Maximum leverage (100x) +pub const MAX_LEVERAGE: u64 = 100; + +/// Basis points denominator (10000 = 100%) +pub const BPS_DENOMINATOR: u64 = 10000; + +/// Initial margin requirement (1% at 100x leverage = 100 bps) +pub const INITIAL_MARGIN_BPS: u64 = 100; + +/// Maintenance margin (0.5% = 50 bps) - below this, liquidation +pub const MAINTENANCE_MARGIN_BPS: u64 = 50; + +/// Liquidation fee (5% = 500 bps) - goes to liquidator + insurance +pub const LIQUIDATION_FEE_BPS: u64 = 500; + +/// Insurance fund share of liquidation fee (50%) +pub const INSURANCE_SHARE_BPS: u64 = 5000; + +/// Maximum funding rate (0.1% per 8h = 10 bps) +pub const MAX_FUNDING_RATE_BPS: u64 = 10; + +/// Funding interval (8 hours in seconds) +pub const FUNDING_INTERVAL: u64 = 8 * 60 * 60; + +/// Price precision (6 decimals, like USDC) +pub const PRICE_PRECISION: u64 = 1_000_000; + +/// Size precision (8 decimals) +pub const SIZE_PRECISION: u64 = 100_000_000; + +// ============================================================================= +// EVENT TOPICS +// ============================================================================= + +fn event_topic(name: &[u8]) -> [u8; 32] { + use synor_sdk::crypto::blake3_hash; + blake3_hash(name).0 +} + +// ============================================================================= +// STORAGE KEYS +// ============================================================================= + +mod keys { + pub const OWNER: &[u8] = b"perps:owner"; + pub const INITIALIZED: &[u8] = b"perps:initialized"; + pub const PAUSED: &[u8] = b"perps:paused"; + + // Market configuration + pub const MARKETS: &[u8] = b"perps:markets"; + pub const MARKET_COUNT: &[u8] = b"perps:market_count"; + + // Positions + pub const POSITIONS: &[u8] = b"perps:positions"; + pub const POSITION_COUNT: &[u8] = b"perps:position_count"; + pub const USER_POSITIONS: &[u8] = b"perps:user_positions"; + + // Funding + pub const LAST_FUNDING_TIME: &[u8] = b"perps:last_funding"; + pub const CUMULATIVE_FUNDING: &[u8] = b"perps:cum_funding"; + + // Insurance fund + pub const INSURANCE_FUND: &[u8] = b"perps:insurance"; + + // Oracle + pub const ORACLE: &[u8] = b"perps:oracle"; + pub const PRICES: &[u8] = b"perps:prices"; +} + +// ============================================================================= +// DATA STRUCTURES +// ============================================================================= + +/// Position direction +#[derive(Clone, Copy, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub enum Direction { + Long, + Short, +} + +/// Margin mode +#[derive(Clone, Copy, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub enum MarginMode { + /// Each position has isolated margin + Isolated, + /// All positions share margin + Cross, +} + +/// Market status +#[derive(Clone, Copy, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub enum MarketStatus { + Active, + Paused, + SettleOnly, + Delisted, +} + +/// Market configuration +#[derive(Clone, Debug, BorshSerialize, BorshDeserialize)] +pub struct Market { + /// Market ID + pub id: u32, + /// Base asset symbol (e.g., "BTC") + pub base_asset: String, + /// Quote asset symbol (e.g., "USD") + pub quote_asset: String, + /// Market status + pub status: MarketStatus, + /// Maximum leverage allowed + pub max_leverage: u64, + /// Maintenance margin requirement (bps) + pub maintenance_margin_bps: u64, + /// Taker fee (bps) + pub taker_fee_bps: u64, + /// Maker fee (bps, can be negative for rebates) + pub maker_fee_bps: i64, + /// Minimum position size + pub min_size: u64, + /// Maximum position size per user + pub max_size: u64, + /// Total open interest (long) + pub open_interest_long: u64, + /// Total open interest (short) + pub open_interest_short: u64, + /// Maximum open interest + pub max_open_interest: u64, +} + +/// A trading position +#[derive(Clone, Debug, BorshSerialize, BorshDeserialize)] +pub struct Position { + /// Position ID + pub id: u64, + /// Owner address + pub owner: Address, + /// Market ID + pub market_id: u32, + /// Position direction + pub direction: Direction, + /// Position size (in SIZE_PRECISION) + pub size: u64, + /// Collateral amount (in PRICE_PRECISION) + pub collateral: u64, + /// Entry price (in PRICE_PRECISION) + pub entry_price: u64, + /// Leverage (2x to 100x) + pub leverage: u64, + /// Margin mode + pub margin_mode: MarginMode, + /// Cumulative funding at entry + pub entry_funding: i64, + /// Timestamp when opened + pub opened_at: u64, + /// Last update timestamp + pub updated_at: u64, + /// Whether position is active + pub is_open: bool, +} + +/// Price data from oracle +#[derive(Clone, Debug, BorshSerialize, BorshDeserialize)] +pub struct PriceData { + /// Index price (spot price from oracles) + pub index_price: u64, + /// Mark price (EMA-smoothed, used for liquidations) + pub mark_price: u64, + /// Last update timestamp + pub timestamp: u64, + /// Confidence interval + pub confidence: u64, +} + +/// Funding rate data +#[derive(Clone, Debug, BorshSerialize, BorshDeserialize)] +pub struct FundingData { + /// Current funding rate (can be negative, in bps) + pub rate_bps: i64, + /// Cumulative funding for longs + pub cumulative_long: i64, + /// Cumulative funding for shorts + pub cumulative_short: i64, + /// Last funding timestamp + pub last_update: u64, +} + +// ============================================================================= +// EVENTS +// ============================================================================= + +#[derive(BorshSerialize)] +pub struct MarketCreated { + pub market_id: u32, + pub base_asset: String, + pub quote_asset: String, + pub max_leverage: u64, +} + +#[derive(BorshSerialize)] +pub struct PositionOpened { + pub position_id: u64, + pub owner: Address, + pub market_id: u32, + pub direction: Direction, + pub size: u64, + pub collateral: u64, + pub entry_price: u64, + pub leverage: u64, +} + +#[derive(BorshSerialize)] +pub struct PositionClosed { + pub position_id: u64, + pub owner: Address, + pub exit_price: u64, + pub realized_pnl: i64, + pub fee_paid: u64, +} + +#[derive(BorshSerialize)] +pub struct PositionLiquidated { + pub position_id: u64, + pub owner: Address, + pub liquidator: Address, + pub size: u64, + pub collateral_seized: u64, + pub liquidation_price: u64, + pub insurance_fund_share: u64, + pub liquidator_reward: u64, +} + +#[derive(BorshSerialize)] +pub struct FundingPaid { + pub market_id: u32, + pub funding_rate_bps: i64, + pub total_long_payment: i64, + pub total_short_payment: i64, + pub timestamp: u64, +} + +#[derive(BorshSerialize)] +pub struct CollateralModified { + pub position_id: u64, + pub old_collateral: u64, + pub new_collateral: u64, + pub new_leverage: u64, +} + +// ============================================================================= +// STORAGE HELPERS +// ============================================================================= + +fn get_owner() -> Option
{ + storage::get::
(keys::OWNER) +} + +fn is_owner(addr: &Address) -> bool { + get_owner().map(|o| o == *addr).unwrap_or(false) +} + +fn is_initialized() -> bool { + storage::get::(keys::INITIALIZED).unwrap_or(false) +} + +fn is_paused() -> bool { + storage::get::(keys::PAUSED).unwrap_or(false) +} + +fn get_market_count() -> u32 { + storage::get::(keys::MARKET_COUNT).unwrap_or(0) +} + +fn set_market_count(count: u32) { + storage::set(keys::MARKET_COUNT, &count); +} + +fn get_market(id: u32) -> Option { + storage::get_with_suffix::(keys::MARKETS, &id.to_le_bytes()) +} + +fn set_market(id: u32, market: &Market) { + storage::set_with_suffix(keys::MARKETS, &id.to_le_bytes(), market); +} + +fn get_position_count() -> u64 { + storage::get::(keys::POSITION_COUNT).unwrap_or(0) +} + +fn set_position_count(count: u64) { + storage::set(keys::POSITION_COUNT, &count); +} + +fn get_position(id: u64) -> Option { + storage::get_with_suffix::(keys::POSITIONS, &id.to_le_bytes()) +} + +fn set_position(id: u64, position: &Position) { + storage::set_with_suffix(keys::POSITIONS, &id.to_le_bytes(), position); +} + +fn get_price(market_id: u32) -> Option { + storage::get_with_suffix::(keys::PRICES, &market_id.to_le_bytes()) +} + +fn set_price(market_id: u32, price: &PriceData) { + storage::set_with_suffix(keys::PRICES, &market_id.to_le_bytes(), price); +} + +fn get_funding(market_id: u32) -> Option { + storage::get_with_suffix::(keys::CUMULATIVE_FUNDING, &market_id.to_le_bytes()) +} + +fn set_funding(market_id: u32, funding: &FundingData) { + storage::set_with_suffix(keys::CUMULATIVE_FUNDING, &market_id.to_le_bytes(), funding); +} + +fn get_insurance_fund() -> u64 { + storage::get::(keys::INSURANCE_FUND).unwrap_or(0) +} + +fn set_insurance_fund(amount: u64) { + storage::set(keys::INSURANCE_FUND, &amount); +} + +fn get_oracle() -> Option
{ + storage::get::
(keys::ORACLE) +} + +// ============================================================================= +// CALCULATION HELPERS +// ============================================================================= + +/// Calculate unrealized PnL for a position +fn calculate_pnl(position: &Position, mark_price: u64) -> i64 { + let size_value = (position.size as u128 * mark_price as u128) / SIZE_PRECISION as u128; + let entry_value = (position.size as u128 * position.entry_price as u128) / SIZE_PRECISION as u128; + + match position.direction { + Direction::Long => (size_value as i64) - (entry_value as i64), + Direction::Short => (entry_value as i64) - (size_value as i64), + } +} + +/// Calculate margin ratio (collateral + pnl) / position_value +fn calculate_margin_ratio(position: &Position, mark_price: u64) -> u64 { + let pnl = calculate_pnl(position, mark_price); + let effective_collateral = if pnl >= 0 { + position.collateral + (pnl as u64) + } else { + position.collateral.saturating_sub((-pnl) as u64) + }; + + let position_value = (position.size as u128 * mark_price as u128) / SIZE_PRECISION as u128; + if position_value == 0 { + return BPS_DENOMINATOR; + } + + ((effective_collateral as u128 * BPS_DENOMINATOR as u128) / position_value) as u64 +} + +/// Check if position should be liquidated +fn is_liquidatable(position: &Position, mark_price: u64) -> bool { + let margin_ratio = calculate_margin_ratio(position, mark_price); + margin_ratio < MAINTENANCE_MARGIN_BPS +} + +/// Calculate liquidation price +fn calculate_liquidation_price(position: &Position) -> u64 { + // For long: liq_price = entry_price * (1 - maintenance_margin / leverage) + // For short: liq_price = entry_price * (1 + maintenance_margin / leverage) + + let margin_factor = (MAINTENANCE_MARGIN_BPS as u128 * PRICE_PRECISION as u128) + / (position.leverage as u128 * 100); + + match position.direction { + Direction::Long => { + position.entry_price.saturating_sub(margin_factor as u64) + } + Direction::Short => { + position.entry_price + margin_factor as u64 + } + } +} + +/// Calculate funding payment for a position +fn calculate_funding_payment(position: &Position, current_funding: &FundingData) -> i64 { + let funding_delta = match position.direction { + Direction::Long => current_funding.cumulative_long - position.entry_funding, + Direction::Short => current_funding.cumulative_short - position.entry_funding, + }; + + // funding_payment = position_size * funding_delta / SIZE_PRECISION + ((position.size as i128 * funding_delta as i128) / SIZE_PRECISION as i128) as i64 +} + +/// Calculate new funding rate based on mark vs index price +fn calculate_funding_rate(mark_price: u64, index_price: u64) -> i64 { + if index_price == 0 { + return 0; + } + + // funding_rate = (mark_price - index_price) / index_price + // Scaled to bps + let price_diff = mark_price as i64 - index_price as i64; + let rate = (price_diff as i128 * BPS_DENOMINATOR as i128) / index_price as i128; + + // Clamp to max funding rate + rate.max(-(MAX_FUNDING_RATE_BPS as i128)).min(MAX_FUNDING_RATE_BPS as i128) as i64 +} + +/// Calculate trading fee +fn calculate_fee(size: u64, price: u64, fee_bps: u64) -> u64 { + let notional = (size as u128 * price as u128) / SIZE_PRECISION as u128; + ((notional * fee_bps as u128) / BPS_DENOMINATOR as u128) as u64 +} + +// ============================================================================= +// ENTRY POINTS +// ============================================================================= + +synor_sdk::entry_point!(init, call); + +fn init(params: &[u8]) -> Result<()> { + require!(!is_initialized(), Error::invalid_args("Already initialized")); + + #[derive(BorshDeserialize)] + struct InitParams { + oracle: Address, + } + + let params = InitParams::try_from_slice(params) + .map_err(|_| Error::invalid_args("Invalid init params"))?; + + storage::set(keys::OWNER, &caller()); + storage::set(keys::INITIALIZED, &true); + storage::set(keys::PAUSED, &false); + storage::set(keys::ORACLE, ¶ms.oracle); + set_market_count(0); + set_position_count(0); + set_insurance_fund(0); + + Ok(()) +} + +fn call(selector: &[u8], params: &[u8]) -> Result> { + // Method selectors + let create_market_sel = synor_sdk::method_selector("create_market"); + let open_position_sel = synor_sdk::method_selector("open_position"); + let close_position_sel = synor_sdk::method_selector("close_position"); + let add_collateral_sel = synor_sdk::method_selector("add_collateral"); + let remove_collateral_sel = synor_sdk::method_selector("remove_collateral"); + let liquidate_sel = synor_sdk::method_selector("liquidate"); + let update_price_sel = synor_sdk::method_selector("update_price"); + let settle_funding_sel = synor_sdk::method_selector("settle_funding"); + let get_position_sel = synor_sdk::method_selector("get_position"); + let get_market_sel = synor_sdk::method_selector("get_market"); + let get_price_sel = synor_sdk::method_selector("get_price"); + let get_pnl_sel = synor_sdk::method_selector("get_pnl"); + let pause_sel = synor_sdk::method_selector("pause"); + let unpause_sel = synor_sdk::method_selector("unpause"); + + match selector { + // ===== Admin Methods ===== + + s if s == create_market_sel => { + let owner = get_owner().ok_or(Error::Unauthorized)?; + require_auth!(owner); + + #[derive(BorshDeserialize)] + struct Args { + base_asset: String, + quote_asset: String, + max_leverage: u64, + taker_fee_bps: u64, + maker_fee_bps: i64, + min_size: u64, + max_size: u64, + max_open_interest: u64, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Invalid create_market params"))?; + + require!(args.max_leverage >= MIN_LEVERAGE && args.max_leverage <= MAX_LEVERAGE, + Error::invalid_args("Leverage must be 2-100x")); + + let market_id = get_market_count(); + let market = Market { + id: market_id, + base_asset: args.base_asset.clone(), + quote_asset: args.quote_asset.clone(), + status: MarketStatus::Active, + max_leverage: args.max_leverage, + maintenance_margin_bps: MAINTENANCE_MARGIN_BPS, + taker_fee_bps: args.taker_fee_bps, + maker_fee_bps: args.maker_fee_bps, + min_size: args.min_size, + max_size: args.max_size, + open_interest_long: 0, + open_interest_short: 0, + max_open_interest: args.max_open_interest, + }; + + set_market(market_id, &market); + set_market_count(market_id + 1); + + // Initialize funding data + let funding = FundingData { + rate_bps: 0, + cumulative_long: 0, + cumulative_short: 0, + last_update: timestamp(), + }; + set_funding(market_id, &funding); + + emit_raw( + &[event_topic(b"MarketCreated")], + &borsh::to_vec(&MarketCreated { + market_id, + base_asset: args.base_asset, + quote_asset: args.quote_asset, + max_leverage: args.max_leverage, + }).unwrap(), + ); + + Ok(borsh::to_vec(&market_id).unwrap()) + } + + s if s == pause_sel => { + let owner = get_owner().ok_or(Error::Unauthorized)?; + require_auth!(owner); + storage::set(keys::PAUSED, &true); + Ok(borsh::to_vec(&true).unwrap()) + } + + s if s == unpause_sel => { + let owner = get_owner().ok_or(Error::Unauthorized)?; + require_auth!(owner); + storage::set(keys::PAUSED, &false); + Ok(borsh::to_vec(&true).unwrap()) + } + + // ===== Oracle Methods ===== + + s if s == update_price_sel => { + // Only oracle can update prices + let oracle = get_oracle().ok_or(Error::Unauthorized)?; + require_auth!(oracle); + + #[derive(BorshDeserialize)] + struct Args { + market_id: u32, + index_price: u64, + confidence: u64, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Invalid update_price params"))?; + + let _market = get_market(args.market_id) + .ok_or_else(|| Error::invalid_args("Market not found"))?; + + // Get previous mark price for EMA calculation + let prev_price = get_price(args.market_id); + let prev_mark = prev_price.map(|p| p.mark_price).unwrap_or(args.index_price); + + // Mark price is EMA of index price (smooths out manipulation) + // mark_price = 0.9 * prev_mark + 0.1 * index_price + let mark_price = (prev_mark * 9 + args.index_price) / 10; + + let price_data = PriceData { + index_price: args.index_price, + mark_price, + timestamp: timestamp(), + confidence: args.confidence, + }; + + set_price(args.market_id, &price_data); + + Ok(borsh::to_vec(&price_data).unwrap()) + } + + // ===== Trading Methods ===== + + s if s == open_position_sel => { + require!(!is_paused(), Error::invalid_args("Contract paused")); + + #[derive(BorshDeserialize)] + struct Args { + market_id: u32, + direction: Direction, + size: u64, + collateral: u64, + leverage: u64, + margin_mode: MarginMode, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Invalid open_position params"))?; + + let mut market = get_market(args.market_id) + .ok_or_else(|| Error::invalid_args("Market not found"))?; + require!(market.status == MarketStatus::Active, + Error::invalid_args("Market not active")); + + let price = get_price(args.market_id) + .ok_or_else(|| Error::invalid_args("Price not available"))?; + + // Validate leverage + require!(args.leverage >= MIN_LEVERAGE && args.leverage <= market.max_leverage, + Error::invalid_args("Invalid leverage")); + + // Validate size + require!(args.size >= market.min_size && args.size <= market.max_size, + Error::invalid_args("Invalid position size")); + + // Validate collateral matches leverage requirement + let required_collateral = (args.size as u128 * price.mark_price as u128) + / (SIZE_PRECISION as u128 * args.leverage as u128); + require!(args.collateral >= required_collateral as u64, + Error::invalid_args("Insufficient collateral for leverage")); + + // Check open interest limits + let new_oi = match args.direction { + Direction::Long => market.open_interest_long + args.size, + Direction::Short => market.open_interest_short + args.size, + }; + require!(new_oi <= market.max_open_interest, + Error::invalid_args("Exceeds max open interest")); + + // Get current funding + let funding = get_funding(args.market_id).unwrap_or(FundingData { + rate_bps: 0, + cumulative_long: 0, + cumulative_short: 0, + last_update: timestamp(), + }); + + let entry_funding = match args.direction { + Direction::Long => funding.cumulative_long, + Direction::Short => funding.cumulative_short, + }; + + // Calculate and deduct fee + let fee = calculate_fee(args.size, price.mark_price, market.taker_fee_bps); + let net_collateral = args.collateral.saturating_sub(fee); + + // Create position + let position_id = get_position_count(); + let position = Position { + id: position_id, + owner: caller(), + market_id: args.market_id, + direction: args.direction, + size: args.size, + collateral: net_collateral, + entry_price: price.mark_price, + leverage: args.leverage, + margin_mode: args.margin_mode, + entry_funding, + opened_at: timestamp(), + updated_at: timestamp(), + is_open: true, + }; + + // Update open interest + match args.direction { + Direction::Long => market.open_interest_long += args.size, + Direction::Short => market.open_interest_short += args.size, + } + + set_position(position_id, &position); + set_position_count(position_id + 1); + set_market(args.market_id, &market); + + emit_raw( + &[event_topic(b"PositionOpened")], + &borsh::to_vec(&PositionOpened { + position_id, + owner: caller(), + market_id: args.market_id, + direction: args.direction, + size: args.size, + collateral: net_collateral, + entry_price: price.mark_price, + leverage: args.leverage, + }).unwrap(), + ); + + Ok(borsh::to_vec(&position_id).unwrap()) + } + + s if s == close_position_sel => { + #[derive(BorshDeserialize)] + struct Args { + position_id: u64, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Invalid close_position params"))?; + + let mut position = get_position(args.position_id) + .ok_or_else(|| Error::invalid_args("Position not found"))?; + require!(position.is_open, Error::invalid_args("Position already closed")); + require!(position.owner == caller(), Error::Unauthorized); + + let mut market = get_market(position.market_id) + .ok_or_else(|| Error::invalid_args("Market not found"))?; + + let price = get_price(position.market_id) + .ok_or_else(|| Error::invalid_args("Price not available"))?; + + // Calculate PnL + let pnl = calculate_pnl(&position, price.mark_price); + + // Calculate funding owed + let funding = get_funding(position.market_id).unwrap_or(FundingData { + rate_bps: 0, + cumulative_long: 0, + cumulative_short: 0, + last_update: timestamp(), + }); + let funding_payment = calculate_funding_payment(&position, &funding); + + // Calculate fee + let fee = calculate_fee(position.size, price.mark_price, market.taker_fee_bps); + + // Calculate final payout + let total_pnl = pnl - funding_payment - fee as i64; + let payout = if total_pnl >= 0 { + position.collateral + total_pnl as u64 + } else { + position.collateral.saturating_sub((-total_pnl) as u64) + }; + + // Update open interest + match position.direction { + Direction::Long => market.open_interest_long = market.open_interest_long.saturating_sub(position.size), + Direction::Short => market.open_interest_short = market.open_interest_short.saturating_sub(position.size), + } + + // Close position + position.is_open = false; + position.updated_at = timestamp(); + + set_position(args.position_id, &position); + set_market(position.market_id, &market); + + emit_raw( + &[event_topic(b"PositionClosed")], + &borsh::to_vec(&PositionClosed { + position_id: args.position_id, + owner: position.owner, + exit_price: price.mark_price, + realized_pnl: total_pnl, + fee_paid: fee, + }).unwrap(), + ); + + Ok(borsh::to_vec(&payout).unwrap()) + } + + s if s == add_collateral_sel => { + #[derive(BorshDeserialize)] + struct Args { + position_id: u64, + amount: u64, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Invalid add_collateral params"))?; + + let mut position = get_position(args.position_id) + .ok_or_else(|| Error::invalid_args("Position not found"))?; + require!(position.is_open, Error::invalid_args("Position closed")); + require!(position.owner == caller(), Error::Unauthorized); + + let old_collateral = position.collateral; + position.collateral += args.amount; + position.updated_at = timestamp(); + + // Recalculate effective leverage + let price = get_price(position.market_id) + .ok_or_else(|| Error::invalid_args("Price not available"))?; + let notional = (position.size as u128 * price.mark_price as u128) / SIZE_PRECISION as u128; + let new_leverage = if position.collateral > 0 { + (notional / position.collateral as u128) as u64 + } else { + MAX_LEVERAGE + }; + position.leverage = new_leverage.max(MIN_LEVERAGE); + + set_position(args.position_id, &position); + + emit_raw( + &[event_topic(b"CollateralModified")], + &borsh::to_vec(&CollateralModified { + position_id: args.position_id, + old_collateral, + new_collateral: position.collateral, + new_leverage: position.leverage, + }).unwrap(), + ); + + Ok(borsh::to_vec(&position.collateral).unwrap()) + } + + s if s == remove_collateral_sel => { + #[derive(BorshDeserialize)] + struct Args { + position_id: u64, + amount: u64, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Invalid remove_collateral params"))?; + + let mut position = get_position(args.position_id) + .ok_or_else(|| Error::invalid_args("Position not found"))?; + require!(position.is_open, Error::invalid_args("Position closed")); + require!(position.owner == caller(), Error::Unauthorized); + + let market = get_market(position.market_id) + .ok_or_else(|| Error::invalid_args("Market not found"))?; + + let price = get_price(position.market_id) + .ok_or_else(|| Error::invalid_args("Price not available"))?; + + let old_collateral = position.collateral; + let new_collateral = position.collateral.saturating_sub(args.amount); + + // Check that new collateral maintains margin requirement + let notional = (position.size as u128 * price.mark_price as u128) / SIZE_PRECISION as u128; + let new_leverage = if new_collateral > 0 { + (notional / new_collateral as u128) as u64 + } else { + MAX_LEVERAGE + 1 + }; + + require!(new_leverage <= market.max_leverage, + Error::invalid_args("Would exceed max leverage")); + + // Check margin ratio after removal + position.collateral = new_collateral; + require!(!is_liquidatable(&position, price.mark_price), + Error::invalid_args("Would become liquidatable")); + + position.leverage = new_leverage; + position.updated_at = timestamp(); + + set_position(args.position_id, &position); + + emit_raw( + &[event_topic(b"CollateralModified")], + &borsh::to_vec(&CollateralModified { + position_id: args.position_id, + old_collateral, + new_collateral, + new_leverage, + }).unwrap(), + ); + + Ok(borsh::to_vec(&args.amount).unwrap()) + } + + s if s == liquidate_sel => { + #[derive(BorshDeserialize)] + struct Args { + position_id: u64, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Invalid liquidate params"))?; + + let mut position = get_position(args.position_id) + .ok_or_else(|| Error::invalid_args("Position not found"))?; + require!(position.is_open, Error::invalid_args("Position closed")); + + let mut market = get_market(position.market_id) + .ok_or_else(|| Error::invalid_args("Market not found"))?; + + let price = get_price(position.market_id) + .ok_or_else(|| Error::invalid_args("Price not available"))?; + + // Verify position is liquidatable + require!(is_liquidatable(&position, price.mark_price), + Error::invalid_args("Position not liquidatable")); + + // Calculate liquidation amounts + let collateral_seized = position.collateral; + let liquidation_fee = (collateral_seized as u128 * LIQUIDATION_FEE_BPS as u128 + / BPS_DENOMINATOR as u128) as u64; + + let insurance_share = (liquidation_fee as u128 * INSURANCE_SHARE_BPS as u128 + / BPS_DENOMINATOR as u128) as u64; + let liquidator_reward = liquidation_fee - insurance_share; + + // Update insurance fund + let insurance = get_insurance_fund(); + set_insurance_fund(insurance + insurance_share); + + // Update open interest + match position.direction { + Direction::Long => market.open_interest_long = market.open_interest_long.saturating_sub(position.size), + Direction::Short => market.open_interest_short = market.open_interest_short.saturating_sub(position.size), + } + + // Close position + position.is_open = false; + position.collateral = 0; + position.updated_at = timestamp(); + + set_position(args.position_id, &position); + set_market(position.market_id, &market); + + emit_raw( + &[event_topic(b"PositionLiquidated")], + &borsh::to_vec(&PositionLiquidated { + position_id: args.position_id, + owner: position.owner, + liquidator: caller(), + size: position.size, + collateral_seized, + liquidation_price: price.mark_price, + insurance_fund_share: insurance_share, + liquidator_reward, + }).unwrap(), + ); + + Ok(borsh::to_vec(&liquidator_reward).unwrap()) + } + + s if s == settle_funding_sel => { + #[derive(BorshDeserialize)] + struct Args { + market_id: u32, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Invalid settle_funding params"))?; + + let market = get_market(args.market_id) + .ok_or_else(|| Error::invalid_args("Market not found"))?; + + let price = get_price(args.market_id) + .ok_or_else(|| Error::invalid_args("Price not available"))?; + + let mut funding = get_funding(args.market_id).unwrap_or(FundingData { + rate_bps: 0, + cumulative_long: 0, + cumulative_short: 0, + last_update: timestamp(), + }); + + let now = timestamp(); + let time_elapsed = now - funding.last_update; + + // Only settle if enough time has passed + require!(time_elapsed >= FUNDING_INTERVAL, + Error::invalid_args("Funding interval not elapsed")); + + // Calculate new funding rate + let new_rate = calculate_funding_rate(price.mark_price, price.index_price); + + // Calculate funding payments + // Positive rate = longs pay shorts + // Negative rate = shorts pay longs + let long_payment = (market.open_interest_long as i128 * new_rate as i128 + / BPS_DENOMINATOR as i128) as i64; + let short_payment = -long_payment; + + // Update cumulative funding + funding.rate_bps = new_rate; + funding.cumulative_long += long_payment; + funding.cumulative_short += short_payment; + funding.last_update = now; + + set_funding(args.market_id, &funding); + + emit_raw( + &[event_topic(b"FundingPaid")], + &borsh::to_vec(&FundingPaid { + market_id: args.market_id, + funding_rate_bps: new_rate, + total_long_payment: long_payment, + total_short_payment: short_payment, + timestamp: now, + }).unwrap(), + ); + + Ok(borsh::to_vec(&new_rate).unwrap()) + } + + // ===== Query Methods ===== + + s if s == get_position_sel => { + #[derive(BorshDeserialize)] + struct Args { + position_id: u64, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Expected (position_id: u64)"))?; + + let position = get_position(args.position_id); + Ok(borsh::to_vec(&position).unwrap()) + } + + s if s == get_market_sel => { + #[derive(BorshDeserialize)] + struct Args { + market_id: u32, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Expected (market_id: u32)"))?; + + let market = get_market(args.market_id); + Ok(borsh::to_vec(&market).unwrap()) + } + + s if s == get_price_sel => { + #[derive(BorshDeserialize)] + struct Args { + market_id: u32, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Expected (market_id: u32)"))?; + + let price = get_price(args.market_id); + Ok(borsh::to_vec(&price).unwrap()) + } + + s if s == get_pnl_sel => { + #[derive(BorshDeserialize)] + struct Args { + position_id: u64, + } + let args = Args::try_from_slice(params) + .map_err(|_| Error::invalid_args("Expected (position_id: u64)"))?; + + let position = get_position(args.position_id) + .ok_or_else(|| Error::invalid_args("Position not found"))?; + + let price = get_price(position.market_id) + .ok_or_else(|| Error::invalid_args("Price not available"))?; + + let pnl = calculate_pnl(&position, price.mark_price); + let liq_price = calculate_liquidation_price(&position); + let margin_ratio = calculate_margin_ratio(&position, price.mark_price); + + #[derive(BorshSerialize)] + struct PnlResult { + unrealized_pnl: i64, + liquidation_price: u64, + margin_ratio_bps: u64, + is_liquidatable: bool, + } + + let result = PnlResult { + unrealized_pnl: pnl, + liquidation_price: liq_price, + margin_ratio_bps: margin_ratio, + is_liquidatable: is_liquidatable(&position, price.mark_price), + }; + + Ok(borsh::to_vec(&result).unwrap()) + } + + _ => Err(Error::InvalidMethod), + } +}