use std::mem::size_of; use anchor_lang::prelude::*; use anchor_lang::{AnchorDeserialize, Discriminator}; use derivative::Derivative; use fixed::types::{I80F48, U64F64}; use static_assertions::const_assert_eq; use switchboard_program::FastRoundResultAccountData; use switchboard_v2::AggregatorAccountData; use crate::accounts_zerocopy::*; use crate::error::*; use crate::state::load_whirlpool_state; use super::orca_mainnet_whirlpool; const DECIMAL_CONSTANT_ZERO_INDEX: i8 = 12; const DECIMAL_CONSTANTS: [I80F48; 25] = [ I80F48::from_bits((1 << 48) / 10i128.pow(12u32)), I80F48::from_bits((1 << 48) / 10i128.pow(11u32) + 1), I80F48::from_bits((1 << 48) / 10i128.pow(10u32)), I80F48::from_bits((1 << 48) / 10i128.pow(9u32) + 1), I80F48::from_bits((1 << 48) / 10i128.pow(8u32) + 1), I80F48::from_bits((1 << 48) / 10i128.pow(7u32) + 1), I80F48::from_bits((1 << 48) / 10i128.pow(6u32) + 1), I80F48::from_bits((1 << 48) / 10i128.pow(5u32)), I80F48::from_bits((1 << 48) / 10i128.pow(4u32)), I80F48::from_bits((1 << 48) / 10i128.pow(3u32) + 1), // 0.001 I80F48::from_bits((1 << 48) / 10i128.pow(2u32) + 1), // 0.01 I80F48::from_bits((1 << 48) / 10i128.pow(1u32) + 1), // 0.1 I80F48::from_bits((1 << 48) * 10i128.pow(0u32)), // 1, index 12 I80F48::from_bits((1 << 48) * 10i128.pow(1u32)), // 10 I80F48::from_bits((1 << 48) * 10i128.pow(2u32)), // 100 I80F48::from_bits((1 << 48) * 10i128.pow(3u32)), // 1000 I80F48::from_bits((1 << 48) * 10i128.pow(4u32)), I80F48::from_bits((1 << 48) * 10i128.pow(5u32)), I80F48::from_bits((1 << 48) * 10i128.pow(6u32)), I80F48::from_bits((1 << 48) * 10i128.pow(7u32)), I80F48::from_bits((1 << 48) * 10i128.pow(8u32)), I80F48::from_bits((1 << 48) * 10i128.pow(9u32)), I80F48::from_bits((1 << 48) * 10i128.pow(10u32)), I80F48::from_bits((1 << 48) * 10i128.pow(11u32)), I80F48::from_bits((1 << 48) * 10i128.pow(12u32)), ]; pub const fn power_of_ten(decimals: i8) -> I80F48 { DECIMAL_CONSTANTS[(decimals + DECIMAL_CONSTANT_ZERO_INDEX) as usize] } pub const QUOTE_DECIMALS: i8 = 6; pub const SOL_DECIMALS: i8 = 9; pub const QUOTE_NATIVE_TO_UI: I80F48 = power_of_ten(-QUOTE_DECIMALS); pub mod switchboard_v1_devnet_oracle { use solana_program::declare_id; declare_id!("7azgmy1pFXHikv36q1zZASvFq5vFa39TT9NweVugKKTU"); } pub mod switchboard_v2_mainnet_oracle { use solana_program::declare_id; declare_id!("DtmE9D2CSB4L5D6A15mraeEjrGMm6auWVzgaD8hK2tZM"); } pub mod pyth_mainnet_usdc_oracle { use solana_program::declare_id; declare_id!("Gnt27xtC473ZT2Mw5u8wZ68Z3gULkSTb5DuxJy7eJotD"); } pub mod pyth_mainnet_sol_oracle { use solana_program::declare_id; declare_id!("H6ARHf6YXhGYeQfUzQNGk6rDNnLBQKrenN712K4AQJEG"); } pub mod usdc_mint_mainnet { use solana_program::declare_id; declare_id!("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); } pub mod sol_mint_mainnet { use solana_program::declare_id; declare_id!("So11111111111111111111111111111111111111112"); } #[zero_copy] #[derive(AnchorDeserialize, AnchorSerialize, Derivative, PartialEq, Eq)] #[derivative(Debug)] pub struct OracleConfig { pub conf_filter: I80F48, pub max_staleness_slots: i64, #[derivative(Debug = "ignore")] pub reserved: [u8; 72], } const_assert_eq!(size_of::(), 16 + 8 + 72); const_assert_eq!(size_of::(), 96); const_assert_eq!(size_of::() % 8, 0); #[derive(AnchorDeserialize, AnchorSerialize, Debug, Default)] pub struct OracleConfigParams { pub conf_filter: f32, pub max_staleness_slots: Option, } impl OracleConfigParams { pub fn to_oracle_config(&self) -> OracleConfig { OracleConfig { conf_filter: I80F48::from_num(self.conf_filter), max_staleness_slots: self.max_staleness_slots.map(|v| v as i64).unwrap_or(-1), reserved: [0; 72], } } } #[derive(Clone, Copy, PartialEq, AnchorSerialize, AnchorDeserialize)] pub enum OracleType { Pyth, Stub, SwitchboardV1, SwitchboardV2, OrcaCLMM, } pub struct OracleState { pub price: I80F48, pub deviation: I80F48, pub last_update_slot: u64, pub oracle_type: OracleType, } impl OracleState { #[inline] pub fn check_confidence_and_maybe_staleness( &self, config: &OracleConfig, staleness_slot: Option, ) -> Result<()> { if let Some(now_slot) = staleness_slot { self.check_staleness(config, now_slot)?; } self.check_confidence(config) } pub fn check_staleness(&self, config: &OracleConfig, now_slot: u64) -> Result<()> { if config.max_staleness_slots >= 0 && self .last_update_slot .saturating_add(config.max_staleness_slots as u64) < now_slot { return Err(MangoError::OracleStale.into()); } Ok(()) } pub fn check_confidence(&self, config: &OracleConfig) -> Result<()> { if self.deviation > config.conf_filter * self.price { return Err(MangoError::OracleConfidence.into()); } Ok(()) } } #[account(zero_copy)] pub struct StubOracle { // ABI: Clients rely on this being at offset 8 pub group: Pubkey, // ABI: Clients rely on this being at offset 40 pub mint: Pubkey, pub price: I80F48, pub last_update_ts: i64, pub last_update_slot: u64, pub deviation: I80F48, pub reserved: [u8; 104], } const_assert_eq!(size_of::(), 32 + 32 + 16 + 8 + 8 + 16 + 104); const_assert_eq!(size_of::(), 216); const_assert_eq!(size_of::() % 8, 0); pub fn determine_oracle_type(acc_info: &impl KeyedAccountReader) -> Result { let data = acc_info.data(); if u32::from_le_bytes(data[0..4].try_into().unwrap()) == pyth_sdk_solana::state::MAGIC { return Ok(OracleType::Pyth); } else if data[0..8] == StubOracle::discriminator() { return Ok(OracleType::Stub); } // https://github.com/switchboard-xyz/switchboard-v2/blob/main/libraries/rs/src/aggregator.rs#L114 // note: disc is not public, hence the copy pasta else if data[0..8] == [217, 230, 65, 101, 201, 162, 27, 125] { return Ok(OracleType::SwitchboardV2); } // note: this is the only known way of checking this else if acc_info.owner() == &switchboard_v1_devnet_oracle::ID || acc_info.owner() == &switchboard_v2_mainnet_oracle::ID { return Ok(OracleType::SwitchboardV1); } else if acc_info.owner() == &orca_mainnet_whirlpool::ID { return Ok(OracleType::OrcaCLMM); } Err(MangoError::UnknownOracleType.into()) } pub fn check_is_valid_fallback_oracle(acc_info: &impl KeyedAccountReader) -> Result<()> { if acc_info.key() == &Pubkey::default() { return Ok(()); }; let oracle_type = determine_oracle_type(acc_info)?; if oracle_type == OracleType::OrcaCLMM { let whirlpool = load_whirlpool_state(acc_info)?; let has_usdc_token = whirlpool.token_mint_a == usdc_mint_mainnet::ID || whirlpool.token_mint_b == usdc_mint_mainnet::ID; let has_sol_token = whirlpool.token_mint_a == sol_mint_mainnet::ID || whirlpool.token_mint_b == sol_mint_mainnet::ID; require!( has_usdc_token || has_sol_token, MangoError::InvalidCLMMOracle ); } Ok(()) } /// Get the pyth agg price if it's available, otherwise take the prev price. /// /// Returns the publish slot in addition to the price info. /// /// Also see pyth's PriceAccount::get_price_no_older_than(). fn pyth_get_price( pubkey: &Pubkey, account: &pyth_sdk_solana::state::PriceAccount, ) -> (pyth_sdk_solana::Price, u64) { use pyth_sdk_solana::*; if account.agg.status == state::PriceStatus::Trading { ( Price { conf: account.agg.conf, expo: account.expo, price: account.agg.price, publish_time: account.timestamp, }, account.agg.pub_slot, ) } else { ( Price { conf: account.prev_conf, expo: account.expo, price: account.prev_price, publish_time: account.prev_timestamp, }, account.prev_slot, ) } } fn get_pyth_state( acc_info: &(impl KeyedAccountReader + ?Sized), base_decimals: u8, ) -> Result { let data = &acc_info.data(); let price_account = pyth_sdk_solana::state::load_price_account(data).unwrap(); let (price_data, last_update_slot) = pyth_get_price(acc_info.key(), price_account); let decimals = (price_account.expo as i8) + QUOTE_DECIMALS - (base_decimals as i8); let decimal_adj = power_of_ten(decimals); let price = I80F48::from_num(price_data.price) * decimal_adj; let deviation = I80F48::from_num(price_data.conf) * decimal_adj; require_gte!(price, 0); Ok(OracleState { price, last_update_slot, deviation, oracle_type: OracleType::Pyth, }) } /// Contains all oracle account infos that could be used to read price pub struct OracleAccountInfos<'a, T: KeyedAccountReader> { pub oracle: &'a T, pub fallback_opt: Option<&'a T>, pub usdc_opt: Option<&'a T>, pub sol_opt: Option<&'a T>, } impl<'a, T: KeyedAccountReader> OracleAccountInfos<'a, T> { pub fn from_reader(acc_reader: &'a T) -> Self { OracleAccountInfos { oracle: acc_reader, fallback_opt: None, usdc_opt: None, sol_opt: None, } } } /// Returns the price of one native base token, in native quote tokens /// /// Example: The price for SOL at 40 USDC/SOL it would return 0.04 (the unit is USDC-native/SOL-native) /// /// This currently assumes that quote decimals (i.e. decimals for USD) is 6, like for USDC. /// /// The staleness and confidence of the oracle is not checked. Use the functions on /// OracleState to validate them if needed. That's why this function is called _unchecked. pub fn oracle_state_unchecked( acc_infos: &OracleAccountInfos, base_decimals: u8, ) -> Result { oracle_state_unchecked_inner(acc_infos, base_decimals, false) } pub fn fallback_oracle_state_unchecked( acc_infos: &OracleAccountInfos, base_decimals: u8, ) -> Result { oracle_state_unchecked_inner(acc_infos, base_decimals, true) } fn oracle_state_unchecked_inner( acc_infos: &OracleAccountInfos, base_decimals: u8, use_fallback: bool, ) -> Result { let oracle_info = if use_fallback { acc_infos .fallback_opt .ok_or_else(|| error!(MangoError::UnknownOracleType))? } else { acc_infos.oracle }; let data = &oracle_info.data(); let oracle_type = determine_oracle_type(oracle_info)?; Ok(match oracle_type { OracleType::Stub => { let stub = oracle_info.load::()?; let deviation = if stub.deviation == 0 { // allows the confidence check to pass even for negative prices I80F48::MIN } else { stub.deviation }; let last_update_slot = if stub.last_update_slot == 0 { // ensure staleness checks will never fail u64::MAX } else { stub.last_update_slot }; OracleState { price: stub.price, last_update_slot, deviation, oracle_type: OracleType::Stub, } } OracleType::Pyth => get_pyth_state(oracle_info, base_decimals)?, OracleType::SwitchboardV2 => { fn from_foreign_error(e: impl std::fmt::Display) -> Error { error_msg!("{}", e) } let feed = bytemuck::from_bytes::(&data[8..]); let feed_result = feed.get_result().map_err(from_foreign_error)?; let ui_price: f64 = feed_result.try_into().map_err(from_foreign_error)?; let ui_deviation: f64 = feed .latest_confirmed_round .std_deviation .try_into() .map_err(from_foreign_error)?; // The round_open_slot is an underestimate of the last update slot: Reporters will see // the round opening and only then start executing the price tasks. let last_update_slot = feed.latest_confirmed_round.round_open_slot; let decimals = QUOTE_DECIMALS - (base_decimals as i8); let decimal_adj = power_of_ten(decimals); let price = I80F48::from_num(ui_price) * decimal_adj; let deviation = I80F48::from_num(ui_deviation) * decimal_adj; require_gte!(price, 0); OracleState { price, last_update_slot, deviation, oracle_type: OracleType::SwitchboardV2, } } OracleType::SwitchboardV1 => { let result = FastRoundResultAccountData::deserialize(data).unwrap(); let ui_price = I80F48::from_num(result.result.result); let ui_deviation = I80F48::from_num(result.result.max_response - result.result.min_response); let last_update_slot = result.result.round_open_slot; let decimals = QUOTE_DECIMALS - (base_decimals as i8); let decimal_adj = power_of_ten(decimals); let price = ui_price * decimal_adj; let deviation = ui_deviation * decimal_adj; require_gte!(price, 0); OracleState { price, last_update_slot, deviation, oracle_type: OracleType::SwitchboardV1, } } OracleType::OrcaCLMM => { let whirlpool = load_whirlpool_state(oracle_info)?; let inverted = whirlpool.is_inverted(); let quote_state = if inverted { quote_state_unchecked(acc_infos, &whirlpool.token_mint_a)? } else { quote_state_unchecked(acc_infos, &whirlpool.token_mint_b)? }; let clmm_price = if inverted { let sqrt_price = U64F64::from_bits(whirlpool.sqrt_price).to_num::(); let inverted_price = sqrt_price * sqrt_price; I80F48::from_num(1.0f64 / inverted_price) } else { let sqrt_price = U64F64::from_bits(whirlpool.sqrt_price); I80F48::from_num(sqrt_price * sqrt_price) }; let price = clmm_price * quote_state.price; OracleState { price, last_update_slot: quote_state.last_update_slot, deviation: quote_state.deviation, oracle_type: OracleType::OrcaCLMM, } } }) } fn quote_state_unchecked( acc_infos: &OracleAccountInfos, quote_mint: &Pubkey, ) -> Result { if quote_mint == &usdc_mint_mainnet::ID { let usd_feed = acc_infos .usdc_opt .ok_or_else(|| error!(MangoError::MissingFeedForCLMMOracle))?; let usd_state = get_pyth_state(usd_feed, QUOTE_DECIMALS as u8)?; return Ok(usd_state); } else if quote_mint == &sol_mint_mainnet::ID { let sol_feed = acc_infos .sol_opt .ok_or_else(|| error!(MangoError::MissingFeedForCLMMOracle))?; let sol_state = get_pyth_state(sol_feed, SOL_DECIMALS as u8)?; return Ok(sol_state); } else { return Err(MangoError::MissingFeedForCLMMOracle.into()); } } pub fn oracle_log_context( name: &str, state: &OracleState, oracle_config: &OracleConfig, staleness_slot: Option, ) -> String { format!( "name: {}, price: {}, deviation: {}, last_update_slot: {}, now_slot: {}, conf_filter: {:#?}", name, state.price.to_num::(), state.deviation.to_num::(), state.last_update_slot, staleness_slot.unwrap_or_else(|| u64::MAX), oracle_config.conf_filter.to_num::(), ) } #[cfg(test)] mod tests { use super::*; use solana_program_test::{find_file, read_file}; use std::{cell::RefCell, path::PathBuf, str::FromStr}; #[test] pub fn test_oracles() -> Result<()> { // add ability to find fixtures let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR")); d.push("resources/test"); let fixtures = vec![ ( "J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix", OracleType::Pyth, Pubkey::default(), ), ( "8k7F9Xb36oFJsjpCKpsXvg4cgBRoZtwNTc3EzG5Ttd2o", OracleType::SwitchboardV1, switchboard_v1_devnet_oracle::ID, ), ( "GvDMxPzN1sCj7L26YDK2HnMRXEQmQ2aemov8YBtPS7vR", OracleType::SwitchboardV2, Pubkey::default(), ), ( "83v8iPyZihDEjDdY8RdZddyZNyUtXngz69Lgo9Kt5d6d", OracleType::OrcaCLMM, orca_mainnet_whirlpool::ID, ), ]; for fixture in fixtures { let filename = format!("resources/test/{}.bin", fixture.0); let mut pyth_price_data = read_file(find_file(&filename).unwrap()); let data = RefCell::new(&mut pyth_price_data[..]); let ai = &AccountInfoRef { key: &Pubkey::from_str(fixture.0).unwrap(), owner: &fixture.2, data: data.borrow(), }; assert!(determine_oracle_type(ai).unwrap() == fixture.1); } Ok(()) } #[test] pub fn lookup_test() { for idx in -12..0 { assert_eq!( power_of_ten(idx), I80F48::from_str(&format!( "0.{}1", str::repeat("0", (idx.abs() as usize) - 1) )) .unwrap() ) } assert_eq!(power_of_ten(0), I80F48::ONE); for idx in 1..=12 { assert_eq!( power_of_ten(idx), I80F48::from_str(&format!("1{}", str::repeat("0", idx.abs() as usize))).unwrap() ) } } #[test] pub fn test_clmm_price() -> Result<()> { // add ability to find fixtures let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR")); d.push("resources/test"); let fixtures = vec![ ( "83v8iPyZihDEjDdY8RdZddyZNyUtXngz69Lgo9Kt5d6d", OracleType::OrcaCLMM, orca_mainnet_whirlpool::ID, 9, // SOL/USDC pool ), ( "Gnt27xtC473ZT2Mw5u8wZ68Z3gULkSTb5DuxJy7eJotD", OracleType::Pyth, Pubkey::default(), 6, ), ]; let clmm_file = format!("resources/test/{}.bin", fixtures[0].0); let mut clmm_data = read_file(find_file(&clmm_file).unwrap()); let data = RefCell::new(&mut clmm_data[..]); let ai = &AccountInfoRef { key: &Pubkey::from_str(fixtures[0].0).unwrap(), owner: &fixtures[0].2, data: data.borrow(), }; let pyth_file = format!("resources/test/{}.bin", fixtures[1].0); let mut pyth_data = read_file(find_file(&pyth_file).unwrap()); let pyth_data_cell = RefCell::new(&mut pyth_data[..]); let usdc_ai = &AccountInfoRef { key: &Pubkey::from_str(fixtures[1].0).unwrap(), owner: &fixtures[1].2, data: pyth_data_cell.borrow(), }; let base_decimals = fixtures[0].3; let usdc_decimals = fixtures[1].3; let usdc_ais = OracleAccountInfos { oracle: usdc_ai, fallback_opt: None, usdc_opt: None, sol_opt: None, }; let orca_ais = OracleAccountInfos { oracle: ai, fallback_opt: None, usdc_opt: Some(usdc_ai), sol_opt: None, }; let usdc = oracle_state_unchecked(&usdc_ais, usdc_decimals).unwrap(); let orca = oracle_state_unchecked(&orca_ais, base_decimals).unwrap(); assert!(usdc.price == I80F48::from_num(1.00000758274099)); // 63.006792786538313 * 1.00000758274099 (but in native/native) assert!(orca.price == I80F48::from_num(0.06300727055072872)); Ok(()) } #[test] pub fn test_clmm_price_missing_usdc() -> Result<()> { // add ability to find fixtures let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR")); d.push("resources/test"); let fixtures = vec![( "83v8iPyZihDEjDdY8RdZddyZNyUtXngz69Lgo9Kt5d6d", OracleType::OrcaCLMM, orca_mainnet_whirlpool::ID, 9, // SOL/USDC pool )]; for fixture in fixtures { let filename = format!("resources/test/{}.bin", fixture.0); let mut clmm_data = read_file(find_file(&filename).unwrap()); let data = RefCell::new(&mut clmm_data[..]); let ai = &AccountInfoRef { key: &Pubkey::from_str(fixture.0).unwrap(), owner: &fixture.2, data: data.borrow(), }; let base_decimals = fixture.3; assert!(determine_oracle_type(ai).unwrap() == fixture.1); let oracle_infos = OracleAccountInfos { oracle: ai, fallback_opt: None, usdc_opt: None, sol_opt: None, }; assert!(oracle_state_unchecked(&oracle_infos, base_decimals) .is_anchor_error_with_code(6068)); } Ok(()) } }