diff --git a/programs/mango-v4/src/error.rs b/programs/mango-v4/src/error.rs index c1f4d29ce..9776f27b3 100644 --- a/programs/mango-v4/src/error.rs +++ b/programs/mango-v4/src/error.rs @@ -115,6 +115,7 @@ impl MangoError { pub trait IsAnchorErrorWithCode { fn is_anchor_error_with_code(&self, code: u32) -> bool; + fn is_oracle_error(&self) -> bool; } impl IsAnchorErrorWithCode for anchor_lang::Result { @@ -124,6 +125,15 @@ impl IsAnchorErrorWithCode for anchor_lang::Result { _ => false, } } + fn is_oracle_error(&self) -> bool { + match self { + Err(Error::AnchorError(e)) => { + e.error_code_number == MangoError::OracleConfidence.error_code() + || e.error_code_number == MangoError::OracleStale.error_code() + } + _ => false, + } + } } pub trait Contextable { diff --git a/programs/mango-v4/src/health/cache.rs b/programs/mango-v4/src/health/cache.rs index 1a31571dc..4ff174c36 100644 --- a/programs/mango-v4/src/health/cache.rs +++ b/programs/mango-v4/src/health/cache.rs @@ -1110,13 +1110,44 @@ pub(crate) fn find_token_info_index(infos: &[TokenInfo], token_index: TokenIndex pub fn new_health_cache( account: &MangoAccountRef, retriever: &impl AccountRetriever, +) -> Result { + new_health_cache_impl(account, retriever, false) +} + +/// Generate a special HealthCache for an account and its health accounts +/// where nonnegative token positions for bad oracles are skipped. +/// +/// This health cache must be used carefully, since it doesn't provide the actual +/// account health, just a value that is guaranteed to be less than it. +pub fn new_health_cache_skipping_bad_oracles( + account: &MangoAccountRef, + retriever: &impl AccountRetriever, +) -> Result { + new_health_cache_impl(account, retriever, true) +} + +fn new_health_cache_impl( + account: &MangoAccountRef, + retriever: &impl AccountRetriever, + // If an oracle is stale or inconfident and the health contribution would + // not be negative, skip it. This decreases health, but maybe overall it's + // still positive? + skip_bad_oracles: bool, ) -> Result { // token contribution from token accounts let mut token_infos = vec![]; for (i, position) in account.active_token_positions().enumerate() { - let (bank, oracle_price) = - retriever.bank_and_oracle(&account.fixed.group, i, position.token_index)?; + let bank_oracle_result = + retriever.bank_and_oracle(&account.fixed.group, i, position.token_index); + if skip_bad_oracles + && bank_oracle_result.is_oracle_error() + && position.indexed_position >= 0 + { + // Ignore the asset because the oracle is bad, decreasing total health + continue; + } + let (bank, oracle_price) = bank_oracle_result?; let native = position.native(bank); let prices = Prices { diff --git a/programs/mango-v4/src/instructions/perp_place_order.rs b/programs/mango-v4/src/instructions/perp_place_order.rs index b88da6099..ecb661c82 100644 --- a/programs/mango-v4/src/instructions/perp_place_order.rs +++ b/programs/mango-v4/src/instructions/perp_place_order.rs @@ -30,13 +30,13 @@ pub fn perp_place_order( asks: ctx.accounts.asks.load_mut()?, }; - let oracle_state; - (oracle_price, oracle_state) = perp_market.oracle_price_and_state( + let oracle_state = perp_market.oracle_state( &AccountInfoRef::borrow(ctx.accounts.oracle.as_ref())?, None, // staleness checked in health )?; + oracle_price = oracle_state.price; - perp_market.update_funding_and_stable_price(&book, oracle_price, oracle_state, now_ts)?; + perp_market.update_funding_and_stable_price(&book, &oracle_state, now_ts)?; } let mut account = ctx.accounts.account.load_full_mut()?; diff --git a/programs/mango-v4/src/instructions/perp_update_funding.rs b/programs/mango-v4/src/instructions/perp_update_funding.rs index 86f4c517b..8c2b60004 100644 --- a/programs/mango-v4/src/instructions/perp_update_funding.rs +++ b/programs/mango-v4/src/instructions/perp_update_funding.rs @@ -14,12 +14,12 @@ pub fn perp_update_funding(ctx: Context) -> Result<()> { }; let now_slot = Clock::get()?.slot; - let (oracle_price, oracle_state) = perp_market.oracle_price_and_state( + let oracle_state = perp_market.oracle_state( &AccountInfoRef::borrow(ctx.accounts.oracle.as_ref())?, Some(now_slot), )?; - perp_market.update_funding_and_stable_price(&book, oracle_price, oracle_state, now_ts)?; + perp_market.update_funding_and_stable_price(&book, &oracle_state, now_ts)?; Ok(()) } diff --git a/programs/mango-v4/src/instructions/stub_oracle_create.rs b/programs/mango-v4/src/instructions/stub_oracle_create.rs index cf3b75baf..05f5ab0af 100644 --- a/programs/mango-v4/src/instructions/stub_oracle_create.rs +++ b/programs/mango-v4/src/instructions/stub_oracle_create.rs @@ -8,7 +8,7 @@ pub fn stub_oracle_create(ctx: Context, price: I80F48) -> Resu oracle.group = ctx.accounts.group.key(); oracle.mint = ctx.accounts.mint.key(); oracle.price = price; - oracle.last_updated = Clock::get()?.unix_timestamp; + oracle.last_update_ts = Clock::get()?.unix_timestamp; Ok(()) } diff --git a/programs/mango-v4/src/instructions/stub_oracle_set.rs b/programs/mango-v4/src/instructions/stub_oracle_set.rs index 2f538d13b..797faee2b 100644 --- a/programs/mango-v4/src/instructions/stub_oracle_set.rs +++ b/programs/mango-v4/src/instructions/stub_oracle_set.rs @@ -5,7 +5,22 @@ use fixed::types::I80F48; pub fn stub_oracle_set(ctx: Context, price: I80F48) -> Result<()> { let mut oracle = ctx.accounts.oracle.load_mut()?; oracle.price = price; - oracle.last_updated = Clock::get()?.unix_timestamp; + oracle.last_update_ts = Clock::get()?.unix_timestamp; + oracle.last_update_slot = Clock::get()?.slot; + Ok(()) +} + +pub fn stub_oracle_set_test( + ctx: Context, + price: I80F48, + last_update_slot: u64, + deviation: I80F48, +) -> Result<()> { + let mut oracle = ctx.accounts.oracle.load_mut()?; + oracle.price = price; + oracle.last_update_ts = Clock::get()?.unix_timestamp; + oracle.last_update_slot = last_update_slot; + oracle.deviation = deviation; Ok(()) } diff --git a/programs/mango-v4/src/instructions/token_deposit.rs b/programs/mango-v4/src/instructions/token_deposit.rs index 6bf29d29f..4d470b559 100644 --- a/programs/mango-v4/src/instructions/token_deposit.rs +++ b/programs/mango-v4/src/instructions/token_deposit.rs @@ -87,13 +87,17 @@ impl<'a, 'info> DepositCommon<'a, 'info> { token::transfer(self.transfer_ctx(), amount_i80f48.to_num::())?; let indexed_position = position.indexed_position; - let oracle_price = bank.oracle_price( + + // Get the oracle price, even if stale or unconfident: We want to allow users + // to deposit to close borrows or do other fixes even if the oracle is bad. + let unsafe_oracle_price = oracle_state_unchecked( &AccountInfoRef::borrow(self.oracle.as_ref())?, - None, // staleness checked in health - )?; + bank.mint_decimals, + )? + .price; // Update the net deposits - adjust by price so different tokens are on the same basis (in USD terms) - let amount_usd = (amount_i80f48 * oracle_price).to_num::(); + let amount_usd = (amount_i80f48 * unsafe_oracle_price).to_num::(); account.fixed.net_deposits += amount_usd; emit!(TokenBalanceLog { @@ -110,7 +114,11 @@ impl<'a, 'info> DepositCommon<'a, 'info> { // Health computation // let retriever = new_fixed_order_account_retriever(remaining_accounts, &account.borrow())?; - let cache = new_health_cache(&account.borrow(), &retriever)?; + + // We only compute health to check if the account leaves the being_liquidated state. + // So it's ok to possibly skip token positions for bad oracles and compute a health + // value that is too low. + let cache = new_health_cache_skipping_bad_oracles(&account.borrow(), &retriever)?; // Since depositing can only increase health, we can skip the usual pre-health computation. // Also, TokenDeposit is one of the rare instructions that is allowed even during being_liquidated. @@ -160,7 +168,7 @@ impl<'a, 'info> DepositCommon<'a, 'info> { signer: self.token_authority.key(), token_index, quantity: amount_i80f48.to_num::(), - price: oracle_price.to_bits(), + price: unsafe_oracle_price.to_bits(), }); Ok(()) diff --git a/programs/mango-v4/src/instructions/token_withdraw.rs b/programs/mango-v4/src/instructions/token_withdraw.rs index 618de0f6f..07c283063 100644 --- a/programs/mango-v4/src/instructions/token_withdraw.rs +++ b/programs/mango-v4/src/instructions/token_withdraw.rs @@ -23,10 +23,19 @@ pub fn token_withdraw(ctx: Context, amount: u64, allow_borrow: bo let pre_health_opt = if !account.fixed.is_in_health_region() { let retriever = new_fixed_order_account_retriever(ctx.remaining_accounts, &account.borrow())?; - let health_cache = - new_health_cache(&account.borrow(), &retriever).context("pre-withdraw init health")?; - let pre_init_health = account.check_health_pre(&health_cache)?; - Some((health_cache, pre_init_health)) + let hc_result = + new_health_cache(&account.borrow(), &retriever).context("pre-withdraw health cache"); + if hc_result.is_oracle_error() { + // We allow NOT checking the pre init health. That means later on the health + // check will be stricter (post_init > 0, without the post_init >= pre_init option) + // Then later we can compute the health while ignoring potential nonnegative + // health contributions from tokens with stale oracles. + None + } else { + let health_cache = hc_result?; + let pre_init_health = account.check_health_pre(&health_cache)?; + Some((health_cache, pre_init_health)) + } } else { None }; @@ -56,10 +65,11 @@ pub fn token_withdraw(ctx: Context, amount: u64, allow_borrow: bo let amount_i80f48 = I80F48::from(amount); - let now_slot = Clock::get()?.slot; - let oracle_price = bank.oracle_price( + // Get the oracle price, even if stale or unconfident: We want to allow users + // to withdraw deposits (while staying healthy otherwise) if the oracle is bad. + let unsafe_oracle_state = oracle_state_unchecked( &AccountInfoRef::borrow(ctx.accounts.oracle.as_ref())?, - Some(now_slot), + bank.mint_decimals, )?; // Update the bank and position @@ -69,6 +79,10 @@ pub fn token_withdraw(ctx: Context, amount: u64, allow_borrow: bo Clock::get()?.unix_timestamp.try_into().unwrap(), )?; + // Avoid getting in trouble because of the mutable bank account borrow later + drop(bank); + let bank = ctx.accounts.bank.load()?; + // Provide a readable error message in case the vault doesn't have enough tokens if ctx.accounts.vault.amount < amount { return err!(MangoError::InsufficentBankVaultFunds).with_context(|| { @@ -98,15 +112,32 @@ pub fn token_withdraw(ctx: Context, amount: u64, allow_borrow: bo }); // Update the net deposits - adjust by price so different tokens are on the same basis (in USD terms) - let amount_usd = (amount_i80f48 * oracle_price).to_num::(); + let amount_usd = (amount_i80f48 * unsafe_oracle_state.price).to_num::(); account.fixed.net_deposits -= amount_usd; // // Health check // - if let Some((mut health_cache, pre_init_health)) = pre_health_opt { - health_cache.adjust_token_balance(&bank, native_position_after - native_position)?; - account.check_health_post(&health_cache, pre_init_health)?; + if !account.fixed.is_in_health_region() { + if let Some((mut health_cache, pre_init_health)) = pre_health_opt { + // This is the normal case + health_cache.adjust_token_balance(&bank, native_position_after - native_position)?; + account.check_health_post(&health_cache, pre_init_health)?; + } else { + // Some oracle was stale/not confident enough above. + // + // Try computing health while ignoring nonnegative contributions from bad oracles. + // If the health is good enough without those, we can pass. + // + // Note that this must include the normal pre and post health checks. + let retriever = + new_fixed_order_account_retriever(ctx.remaining_accounts, &account.borrow())?; + let health_cache = new_health_cache_skipping_bad_oracles(&account.borrow(), &retriever) + .context("special post-withdraw health-cache")?; + let post_init_health = health_cache.health(HealthType::Init); + account.check_health_pre_checks(&health_cache, post_init_health)?; + account.check_health_post_checks(I80F48::MAX, post_init_health)?; + } } // @@ -124,7 +155,7 @@ pub fn token_withdraw(ctx: Context, amount: u64, allow_borrow: bo signer: ctx.accounts.owner.key(), token_index, quantity: amount, - price: oracle_price.to_bits(), + price: unsafe_oracle_state.price.to_bits(), }); if withdraw_result.loan_origination_fee.is_positive() { @@ -135,7 +166,7 @@ pub fn token_withdraw(ctx: Context, amount: u64, allow_borrow: bo loan_amount: withdraw_result.loan_amount.to_bits(), loan_origination_fee: withdraw_result.loan_origination_fee.to_bits(), instruction: LoanOriginationFeeInstruction::TokenWithdraw, - price: Some(oracle_price.to_bits()), + price: Some(unsafe_oracle_state.price.to_bits()), }); } @@ -143,7 +174,15 @@ pub fn token_withdraw(ctx: Context, amount: u64, allow_borrow: bo if is_borrow { ctx.accounts.vault.reload()?; bank.enforce_min_vault_to_deposits_ratio(ctx.accounts.vault.as_ref())?; - bank.check_net_borrows(oracle_price)?; + + // When borrowing the price has be trustworthy, so we can do a reasonable + // net borrow check. + unsafe_oracle_state.check_confidence_and_maybe_staleness( + &bank.oracle, + &bank.oracle_config, + Some(Clock::get()?.slot), + )?; + bank.check_net_borrows(unsafe_oracle_state.price)?; } Ok(()) diff --git a/programs/mango-v4/src/lib.rs b/programs/mango-v4/src/lib.rs index 7cf6eccd4..1ea142c34 100644 --- a/programs/mango-v4/src/lib.rs +++ b/programs/mango-v4/src/lib.rs @@ -365,6 +365,17 @@ pub mod mango_v4 { Ok(()) } + pub fn stub_oracle_set_test( + ctx: Context, + price: I80F48, + last_update_slot: u64, + deviation: I80F48, + ) -> Result<()> { + #[cfg(feature = "enable-gpl")] + instructions::stub_oracle_set_test(ctx, price, last_update_slot, deviation)?; + Ok(()) + } + pub fn token_deposit(ctx: Context, amount: u64, reduce_only: bool) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::token_deposit(ctx, amount, reduce_only)?; diff --git a/programs/mango-v4/src/state/bank.rs b/programs/mango-v4/src/state/bank.rs index aa799dd15..e079d0e32 100644 --- a/programs/mango-v4/src/state/bank.rs +++ b/programs/mango-v4/src/state/bank.rs @@ -850,14 +850,13 @@ impl Bank { staleness_slot: Option, ) -> Result { require_keys_eq!(self.oracle, *oracle_acc.key()); - let (price, _) = oracle::oracle_price_and_state( - oracle_acc, + let state = oracle::oracle_state_unchecked(oracle_acc, self.mint_decimals)?; + state.check_confidence_and_maybe_staleness( + &self.oracle, &self.oracle_config, - self.mint_decimals, staleness_slot, )?; - - Ok(price) + Ok(state.price) } pub fn stable_price(&self) -> I80F48 { diff --git a/programs/mango-v4/src/state/mango_account.rs b/programs/mango-v4/src/state/mango_account.rs index 3c9d4cedd..eeb2a4f56 100644 --- a/programs/mango-v4/src/state/mango_account.rs +++ b/programs/mango-v4/src/state/mango_account.rs @@ -1173,7 +1173,15 @@ impl< pub fn check_health_pre(&mut self, health_cache: &HealthCache) -> Result { let pre_init_health = health_cache.health(HealthType::Init); msg!("pre_init_health: {}", pre_init_health); + self.check_health_pre_checks(health_cache, pre_init_health)?; + Ok(pre_init_health) + } + pub fn check_health_pre_checks( + &mut self, + health_cache: &HealthCache, + pre_init_health: I80F48, + ) -> Result<()> { // We can skip computing LiquidationEnd health if Init health > 0, because // LiquidationEnd health >= Init health. self.fixed_mut() @@ -1187,8 +1195,7 @@ impl< !self.fixed().being_liquidated(), MangoError::BeingLiquidated ); - - Ok(pre_init_health) + Ok(()) } pub fn check_health_post( @@ -1198,7 +1205,15 @@ impl< ) -> Result { let post_init_health = health_cache.health(HealthType::Init); msg!("post_init_health: {}", post_init_health); + self.check_health_post_checks(pre_init_health, post_init_health)?; + Ok(post_init_health) + } + pub fn check_health_post_checks( + &mut self, + pre_init_health: I80F48, + post_init_health: I80F48, + ) -> Result<()> { // Accounts that have negative init health may only take actions that don't further // decrease their health. // To avoid issues with rounding, we allow accounts to decrease their health by up to @@ -1219,7 +1234,7 @@ impl< post_init_health >= 0 || health_does_not_decrease, MangoError::HealthMustBePositiveOrIncrease ); - Ok(post_init_health) + Ok(()) } pub fn check_liquidatable(&mut self, health_cache: &HealthCache) -> Result { diff --git a/programs/mango-v4/src/state/oracle.rs b/programs/mango-v4/src/state/oracle.rs index 5c64c243e..0e84f09c0 100644 --- a/programs/mango-v4/src/state/oracle.rs +++ b/programs/mango-v4/src/state/oracle.rs @@ -83,7 +83,7 @@ impl OracleConfigParams { } } -#[derive(PartialEq, AnchorSerialize, AnchorDeserialize)] +#[derive(Clone, Copy, PartialEq, AnchorSerialize, AnchorDeserialize)] pub enum OracleType { Pyth, Stub, @@ -92,11 +92,65 @@ pub enum OracleType { } pub struct OracleState { + pub price: I80F48, + pub deviation: I80F48, pub last_update_slot: u64, - pub confidence: I80F48, pub oracle_type: OracleType, } +impl OracleState { + #[inline] + pub fn check_confidence_and_maybe_staleness( + &self, + oracle_pk: &Pubkey, + config: &OracleConfig, + staleness_slot: Option, + ) -> Result<()> { + if let Some(now_slot) = staleness_slot { + self.check_staleness(oracle_pk, config, now_slot)?; + } + self.check_confidence(oracle_pk, config) + } + + pub fn check_staleness( + &self, + oracle_pk: &Pubkey, + 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 + { + msg!( + "Oracle is stale; pubkey {}, price: {}, last_update_slot: {}, now_slot: {}", + oracle_pk, + self.price.to_num::(), + self.last_update_slot, + now_slot, + ); + return Err(MangoError::OracleStale.into()); + } + Ok(()) + } + + pub fn check_confidence(&self, oracle_pk: &Pubkey, config: &OracleConfig) -> Result<()> { + if self.deviation > config.conf_filter * self.price { + msg!( + "Oracle confidence not good enough: pubkey {}, price: {}, deviation: {}, conf_filter: {}", + oracle_pk, + self.price.to_num::(), + self.deviation.to_num::(), + config.conf_filter.to_num::(), + ); + return Err(MangoError::OracleConfidence.into()); + } + Ok(()) + } +} + #[account(zero_copy)] pub struct StubOracle { // ABI: Clients rely on this being at offset 8 @@ -104,10 +158,12 @@ pub struct StubOracle { // ABI: Clients rely on this being at offset 40 pub mint: Pubkey, pub price: I80F48, - pub last_updated: i64, - pub reserved: [u8; 128], + 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 + 128); +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); @@ -134,22 +190,18 @@ pub fn determine_oracle_type(acc_info: &impl KeyedAccountReader) -> Result Result<(pyth_sdk_solana::Price, u64)> { +) -> (pyth_sdk_solana::Price, u64) { use pyth_sdk_solana::*; - if account.agg.status == state::PriceStatus::Trading - && (max_staleness < 0 - || account.agg.pub_slot.saturating_add(max_staleness as u64) >= now_slot) - { - return Ok(( + if account.agg.status == state::PriceStatus::Trading { + ( Price { conf: account.agg.conf, expo: account.expo, @@ -157,11 +209,9 @@ fn pyth_get_price( publish_time: account.timestamp, }, account.agg.pub_slot, - )); - } - - if max_staleness < 0 || account.prev_slot.saturating_add(max_staleness as u64) >= now_slot { - return Ok(( + ) + } else { + ( Price { conf: account.prev_conf, expo: account.expo, @@ -169,82 +219,62 @@ fn pyth_get_price( publish_time: account.prev_timestamp, }, account.prev_slot, - )); + ) } - - msg!( - "Pyth price too stale; pubkey {} prev price: {} prev slot: {} agg pub slot: {} agg status: {:?}", - pubkey, - account.prev_price, - account.prev_slot, - account.agg.pub_slot, - account.agg.status, - ); - - Err(MangoError::OracleStale.into()) } /// Returns the price of one native base token, in native quote tokens /// /// Example: The for SOL at 40 USDC/SOL it would return 0.04 (the unit is USDC-native/SOL-native) /// -/// This currently assumes that quote decimals is 6, like for USDC. +/// This currently assumes that quote decimals (i.e. decimals for USD) is 6, like for USDC. /// -/// Pass `staleness_slot` = None to skip the staleness check -pub fn oracle_price_and_state( +/// 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_info: &impl KeyedAccountReader, - config: &OracleConfig, base_decimals: u8, - staleness_slot: Option, -) -> Result<(I80F48, OracleState)> { +) -> Result { let data = &acc_info.data(); let oracle_type = determine_oracle_type(acc_info)?; - let staleness_slot = staleness_slot.unwrap_or(0); Ok(match oracle_type { - OracleType::Stub => ( - acc_info.load::()?.price, + OracleType::Stub => { + let stub = acc_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 { - last_update_slot: 0, - confidence: I80F48::ZERO, + price: stub.price, + last_update_slot, + deviation, oracle_type: OracleType::Stub, - }, - ), + } + } OracleType::Pyth => { let price_account = pyth_sdk_solana::state::load_price_account(data).unwrap(); - let (price_data, pub_slot) = pyth_get_price( - acc_info.key(), - price_account, - staleness_slot, - config.max_staleness_slots, - )?; - let price = I80F48::from_num(price_data.price); - - // Filter out bad prices - if I80F48::from_num(price_data.conf) > (config.conf_filter * price) { - msg!( - "Pyth conf interval too high; pubkey {} price: {} price_data.conf: {}", - acc_info.key(), - price.to_num::(), - price_data.conf - ); - - // future: in v3, we had pricecache, and in case of luna, when there were no updates, we used last known value from cache - // we'll have to add a CachedOracle that is based on one of the oracle types, needs a separate keeper and supports - // maintaining this "last known good value" - return Err(MangoError::OracleConfidence.into()); - } + let (price_data, last_update_slot) = pyth_get_price(acc_info.key(), price_account); + let raw_price = I80F48::from_num(price_data.price); let decimals = (price_account.expo as i8) + QUOTE_DECIMALS - (base_decimals as i8); let decimal_adj = power_of_ten(decimals); - ( - price * decimal_adj, - OracleState { - last_update_slot: pub_slot, - confidence: I80F48::from_num(price_data.conf), - oracle_type: OracleType::Pyth, - }, - ) + let price = raw_price * decimal_adj; + require_gte!(price, 0); + OracleState { + price, + last_update_slot, + deviation: I80F48::from_num(price_data.conf), + oracle_type: OracleType::Pyth, + } } OracleType::SwitchboardV2 => { fn from_foreign_error(e: impl std::fmt::Display) -> Error { @@ -254,93 +284,47 @@ pub fn oracle_price_and_state( let feed = bytemuck::from_bytes::(&data[8..]); let feed_result = feed.get_result().map_err(from_foreign_error)?; let price_decimal: f64 = feed_result.try_into().map_err(from_foreign_error)?; - let price = I80F48::from_num(price_decimal); + let ui_price = I80F48::from_num(price_decimal); - // Filter out bad prices let std_deviation_decimal: f64 = feed .latest_confirmed_round .std_deviation .try_into() .map_err(from_foreign_error)?; - if I80F48::from_num(std_deviation_decimal) > (config.conf_filter * price) { - msg!( - "Switchboard v2 std deviation too high; pubkey {} price: {} latest_confirmed_round.std_deviation: {}", - acc_info.key(), - price.to_num::(), - std_deviation_decimal - ); - return Err(MangoError::OracleConfidence.into()); - } - // The round_open_slot is an overestimate of the oracle staleness: Reporters will see + // 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 round_open_slot = feed.latest_confirmed_round.round_open_slot; - if config.max_staleness_slots >= 0 - && round_open_slot.saturating_add(config.max_staleness_slots as u64) - < staleness_slot - { - msg!( - "Switchboard v2 price too stale; pubkey {} price: {} latest_confirmed_round.round_open_slot: {}", - acc_info.key(), - price.to_num::(), - round_open_slot, - ); - return Err(MangoError::OracleConfidence.into()); - } + 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); - ( - price * decimal_adj, - OracleState { - last_update_slot: round_open_slot, - confidence: I80F48::from_num(std_deviation_decimal), - oracle_type: OracleType::SwitchboardV2, - }, - ) + let price = ui_price * decimal_adj; + require_gte!(price, 0); + OracleState { + price, + last_update_slot, + deviation: I80F48::from_num(std_deviation_decimal), + oracle_type: OracleType::SwitchboardV2, + } } OracleType::SwitchboardV1 => { let result = FastRoundResultAccountData::deserialize(data).unwrap(); - let price = I80F48::from_num(result.result.result); + let ui_price = I80F48::from_num(result.result.result); - // Filter out bad prices - let min_response = I80F48::from_num(result.result.min_response); - let max_response = I80F48::from_num(result.result.max_response); - if (max_response - min_response) > (config.conf_filter * price) { - msg!( - "Switchboard v1 min-max response gap too wide; pubkey {} price: {} min_response: {} max_response {}", - acc_info.key(), - price.to_num::(), - min_response, - max_response - ); - return Err(MangoError::OracleConfidence.into()); - } - - let round_open_slot = result.result.round_open_slot; - if config.max_staleness_slots >= 0 - && round_open_slot.saturating_add(config.max_staleness_slots as u64) - < staleness_slot - { - msg!( - "Switchboard v1 price too stale; pubkey {} price: {} round_open_slot: {}", - acc_info.key(), - price.to_num::(), - round_open_slot, - ); - return Err(MangoError::OracleConfidence.into()); - } + let 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); - ( - price * decimal_adj, - OracleState { - last_update_slot: round_open_slot, - confidence: max_response - min_response, - oracle_type: OracleType::SwitchboardV1, - }, - ) + let price = ui_price * decimal_adj; + require_gte!(price, 0); + OracleState { + price, + last_update_slot, + deviation, + oracle_type: OracleType::SwitchboardV1, + } } }) } diff --git a/programs/mango-v4/src/state/perp_market.rs b/programs/mango-v4/src/state/perp_market.rs index 80b979170..7b8b10775 100644 --- a/programs/mango-v4/src/state/perp_market.rs +++ b/programs/mango-v4/src/state/perp_market.rs @@ -255,29 +255,22 @@ impl PerpMarket { oracle_acc: &impl KeyedAccountReader, staleness_slot: Option, ) -> Result { - require_keys_eq!(self.oracle, *oracle_acc.key()); - let (price, _) = oracle::oracle_price_and_state( - oracle_acc, - &self.oracle_config, - self.base_decimals, - staleness_slot, - )?; - - Ok(price) + Ok(self.oracle_state(oracle_acc, staleness_slot)?.price) } - pub fn oracle_price_and_state( + pub fn oracle_state( &self, oracle_acc: &impl KeyedAccountReader, staleness_slot: Option, - ) -> Result<(I80F48, OracleState)> { + ) -> Result { require_keys_eq!(self.oracle, *oracle_acc.key()); - oracle::oracle_price_and_state( - oracle_acc, + let state = oracle::oracle_state_unchecked(oracle_acc, self.base_decimals)?; + state.check_confidence_and_maybe_staleness( + &self.oracle, &self.oracle_config, - self.base_decimals, staleness_slot, - ) + )?; + Ok(state) } pub fn stable_price(&self) -> I80F48 { @@ -288,15 +281,14 @@ impl PerpMarket { pub fn update_funding_and_stable_price( &mut self, book: &Orderbook, - oracle_price: I80F48, - oracle_state: OracleState, + oracle_state: &OracleState, now_ts: u64, ) -> Result<()> { if now_ts <= self.funding_last_updated { return Ok(()); } - let index_price = oracle_price; + let oracle_price = oracle_state.price; let oracle_price_lots = self.native_price_to_lot(oracle_price); // Get current book price & compare it to index price @@ -312,7 +304,7 @@ impl PerpMarket { // calculate mid-market rate let mid_price = (bid + ask) / 2; let book_price = self.lot_to_native_price(mid_price); - let diff = book_price / index_price - I80F48::ONE; + let diff = book_price / oracle_price - I80F48::ONE; diff.clamp(self.min_funding, self.max_funding) } (Some(_bid), None) => self.max_funding, @@ -332,7 +324,7 @@ impl PerpMarket { let base_lot_size = I80F48::from_num(self.base_lot_size); // The number of native quote that one base lot should pay in funding - let funding_delta = index_price * base_lot_size * funding_rate * time_factor; + let funding_delta = oracle_price * base_lot_size * funding_rate * time_factor; self.long_funding += funding_delta; self.short_funding += funding_delta; @@ -348,7 +340,7 @@ impl PerpMarket { short_funding: self.short_funding.to_bits(), price: oracle_price.to_bits(), oracle_slot: oracle_state.last_update_slot, - oracle_confidence: oracle_state.confidence.to_bits(), + oracle_confidence: oracle_state.deviation.to_bits(), oracle_type: oracle_state.oracle_type, stable_price: self.stable_price().to_bits(), fees_accrued: self.fees_accrued.to_bits(), diff --git a/programs/mango-v4/tests/cases/mod.rs b/programs/mango-v4/tests/cases/mod.rs index 0e91c2165..08278ffb8 100644 --- a/programs/mango-v4/tests/cases/mod.rs +++ b/programs/mango-v4/tests/cases/mod.rs @@ -34,5 +34,6 @@ mod test_perp_settle_fees; mod test_position_lifetime; mod test_reduce_only; mod test_serum; +mod test_stale_oracles; mod test_token_conditional_swap; mod test_token_update_index_and_rate; diff --git a/programs/mango-v4/tests/cases/test_serum.rs b/programs/mango-v4/tests/cases/test_serum.rs index 7b8035ce0..a00345b13 100644 --- a/programs/mango-v4/tests/cases/test_serum.rs +++ b/programs/mango-v4/tests/cases/test_serum.rs @@ -385,7 +385,7 @@ async fn test_serum_basics() -> Result<(), TransportError> { #[tokio::test] async fn test_serum_loan_origination_fees() -> Result<(), TransportError> { let mut test_builder = TestContextBuilder::new(); - test_builder.test().set_compute_max_units(95_000); // Serum3PlaceOrder needs 92.8k + test_builder.test().set_compute_max_units(100_000); // Serum3PlaceOrder needs 95.1k let context = test_builder.start_default().await; let solana = &context.solana.clone(); diff --git a/programs/mango-v4/tests/cases/test_stale_oracles.rs b/programs/mango-v4/tests/cases/test_stale_oracles.rs new file mode 100644 index 000000000..a08f78738 --- /dev/null +++ b/programs/mango-v4/tests/cases/test_stale_oracles.rs @@ -0,0 +1,145 @@ +use super::*; + +#[tokio::test] +async fn test_stale_oracle_deposit_withdraw() -> Result<(), TransportError> { + let mut test_builder = TestContextBuilder::new(); + test_builder.test().set_compute_max_units(100_000); // bad oracles log a lot + let context = test_builder.start_default().await; + let solana = &context.solana.clone(); + + let admin = TestKeypair::new(); + let owner = context.users[0].key; + let payer = context.users[1].key; + let mints = &context.mints[0..3]; + let payer_token_accounts = &context.users[1].token_accounts[0..3]; + + // + // SETUP: Create a group, account, register tokens + // + + let mango_setup::GroupWithTokens { group, .. } = mango_setup::GroupWithTokensConfig { + admin, + payer, + mints: mints.to_vec(), + ..mango_setup::GroupWithTokensConfig::default() + } + .create(solana) + .await; + + // fill vaults, so we can borrow + let _vault_account = create_funded_account( + &solana, + group, + owner, + 2, + &context.users[1], + mints, + 100000, + 0, + ) + .await; + + // Create account with token0 deposits + let account = create_funded_account( + &solana, + group, + owner, + 0, + &context.users[1], + &mints[0..1], + 100, + 0, + ) + .await; + + // Create some token1 borrows + send_tx( + solana, + TokenWithdrawInstruction { + amount: 10, + allow_borrow: true, + account, + owner, + token_account: payer_token_accounts[1], + bank_index: 0, + }, + ) + .await + .unwrap(); + + // Make both oracles invalid by increasing deviation + send_tx( + solana, + StubOracleSetTestInstruction { + group, + mint: mints[0].pubkey, + admin, + price: 1.0, + last_update_slot: 0, + deviation: 100.0, + }, + ) + .await + .unwrap(); + send_tx( + solana, + StubOracleSetTestInstruction { + group, + mint: mints[1].pubkey, + admin, + price: 1.0, + last_update_slot: 0, + deviation: 100.0, + }, + ) + .await + .unwrap(); + + // Verify that creating a new borrow won't work + assert!(send_tx( + solana, + TokenWithdrawInstruction { + amount: 1, + allow_borrow: true, + account, + owner, + token_account: payer_token_accounts[2], + bank_index: 0, + }, + ) + .await + .is_err()); + + // Repay token1 borrows + send_tx( + solana, + TokenDepositInstruction { + amount: 11, + reduce_only: true, + account, + owner, + token_account: payer_token_accounts[1], + token_authority: payer.clone(), + bank_index: 0, + }, + ) + .await + .unwrap(); + + // Withdraw token0 deposits + send_tx( + solana, + TokenWithdrawInstruction { + amount: 100, + allow_borrow: true, + account, + owner, + token_account: payer_token_accounts[0], + bank_index: 0, + }, + ) + .await + .unwrap(); + + Ok(()) +} diff --git a/programs/mango-v4/tests/program_test/mango_client.rs b/programs/mango-v4/tests/program_test/mango_client.rs index 748ac73d8..ce21c6b61 100644 --- a/programs/mango-v4/tests/program_test/mango_client.rs +++ b/programs/mango-v4/tests/program_test/mango_client.rs @@ -1368,6 +1368,54 @@ impl ClientInstruction for StubOracleSetInstruction { } } +pub struct StubOracleSetTestInstruction { + pub mint: Pubkey, + pub group: Pubkey, + pub admin: TestKeypair, + pub price: f64, + pub last_update_slot: u64, + pub deviation: f64, +} +#[async_trait::async_trait(?Send)] +impl ClientInstruction for StubOracleSetTestInstruction { + type Accounts = mango_v4::accounts::StubOracleSet; + type Instruction = mango_v4::instruction::StubOracleSetTest; + + async fn to_instruction( + &self, + _loader: impl ClientAccountLoader + 'async_trait, + ) -> (Self::Accounts, Instruction) { + let program_id = mango_v4::id(); + let instruction = Self::Instruction { + price: I80F48::from_num(self.price), + last_update_slot: self.last_update_slot, + deviation: I80F48::from_num(self.deviation), + }; + let oracle = Pubkey::find_program_address( + &[ + b"StubOracle".as_ref(), + self.group.as_ref(), + self.mint.as_ref(), + ], + &program_id, + ) + .0; + + let accounts = Self::Accounts { + oracle, + group: self.group, + admin: self.admin.pubkey(), + }; + + let instruction = make_instruction(program_id, &accounts, &instruction); + (accounts, instruction) + } + + fn signers(&self) -> Vec { + vec![self.admin] + } +} + pub struct StubOracleCreate { pub group: Pubkey, pub mint: Pubkey, diff --git a/programs/mango-v4/tests/program_test/mod.rs b/programs/mango-v4/tests/program_test/mod.rs index a35cadc25..1be9cc744 100644 --- a/programs/mango-v4/tests/program_test/mod.rs +++ b/programs/mango-v4/tests/program_test/mod.rs @@ -112,7 +112,7 @@ impl TestContextBuilder { let mut test = ProgramTest::new("mango_v4", mango_v4::id(), processor!(mango_v4::entry)); // intentionally set to as tight as possible, to catch potential problems early - test.set_compute_max_units(75000); + test.set_compute_max_units(80000); Self { test,