Allow token withdraws/deposits even with stale oracles (#646)

This commit is contained in:
Christian Kamm 2023-08-07 16:15:45 +02:00 committed by GitHub
parent a6b6fbbb82
commit 4f810edebc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 504 additions and 206 deletions

View File

@ -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<T> IsAnchorErrorWithCode for anchor_lang::Result<T> {
@ -124,6 +125,15 @@ impl<T> IsAnchorErrorWithCode for anchor_lang::Result<T> {
_ => 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 {

View File

@ -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<HealthCache> {
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<HealthCache> {
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<HealthCache> {
// 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 {

View File

@ -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()?;

View File

@ -14,12 +14,12 @@ pub fn perp_update_funding(ctx: Context<PerpUpdateFunding>) -> 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(())
}

View File

@ -8,7 +8,7 @@ pub fn stub_oracle_create(ctx: Context<StubOracleCreate>, 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(())
}

View File

@ -5,7 +5,22 @@ use fixed::types::I80F48;
pub fn stub_oracle_set(ctx: Context<StubOracleSet>, 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<StubOracleSet>,
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(())
}

View File

@ -87,13 +87,17 @@ impl<'a, 'info> DepositCommon<'a, 'info> {
token::transfer(self.transfer_ctx(), amount_i80f48.to_num::<u64>())?;
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::<i64>();
let amount_usd = (amount_i80f48 * unsafe_oracle_price).to_num::<i64>();
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::<u64>(),
price: oracle_price.to_bits(),
price: unsafe_oracle_price.to_bits(),
});
Ok(())

View File

@ -23,10 +23,19 @@ pub fn token_withdraw(ctx: Context<TokenWithdraw>, 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<TokenWithdraw>, 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<TokenWithdraw>, 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<TokenWithdraw>, 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::<i64>();
let amount_usd = (amount_i80f48 * unsafe_oracle_state.price).to_num::<i64>();
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<TokenWithdraw>, 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<TokenWithdraw>, 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<TokenWithdraw>, 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(())

View File

@ -365,6 +365,17 @@ pub mod mango_v4 {
Ok(())
}
pub fn stub_oracle_set_test(
ctx: Context<StubOracleSet>,
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<TokenDeposit>, amount: u64, reduce_only: bool) -> Result<()> {
#[cfg(feature = "enable-gpl")]
instructions::token_deposit(ctx, amount, reduce_only)?;

View File

@ -850,14 +850,13 @@ impl Bank {
staleness_slot: Option<u64>,
) -> Result<I80F48> {
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 {

View File

@ -1173,7 +1173,15 @@ impl<
pub fn check_health_pre(&mut self, health_cache: &HealthCache) -> Result<I80F48> {
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<I80F48> {
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<CheckLiquidatable> {

View File

@ -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<u64>,
) -> 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::<f64>(),
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::<f64>(),
self.deviation.to_num::<f64>(),
config.conf_filter.to_num::<f32>(),
);
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::<StubOracle>(), 32 + 32 + 16 + 8 + 128);
const_assert_eq!(size_of::<StubOracle>(), 32 + 32 + 16 + 8 + 8 + 16 + 104);
const_assert_eq!(size_of::<StubOracle>(), 216);
const_assert_eq!(size_of::<StubOracle>() % 8, 0);
@ -134,22 +190,18 @@ pub fn determine_oracle_type(acc_info: &impl KeyedAccountReader) -> Result<Oracl
Err(MangoError::UnknownOracleType.into())
}
/// A modified version of PriceAccount::get_price_no_older_than() which
/// - doesn't need a Clock instance
/// - has the staleness check be optional (negative max_staleness)
/// - returns the publish slot of the price
/// 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,
now_slot: u64,
max_staleness: i64,
) -> 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<u64>,
) -> Result<(I80F48, OracleState)> {
) -> Result<OracleState> {
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::<StubOracle>()?.price,
OracleType::Stub => {
let stub = acc_info.load::<StubOracle>()?;
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::<f64>(),
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::<AggregatorAccountData>(&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::<f64>(),
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::<f64>(),
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::<f64>(),
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::<f64>(),
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,
}
}
})
}

View File

@ -255,29 +255,22 @@ impl PerpMarket {
oracle_acc: &impl KeyedAccountReader,
staleness_slot: Option<u64>,
) -> Result<I80F48> {
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<u64>,
) -> Result<(I80F48, OracleState)> {
) -> Result<OracleState> {
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(),

View File

@ -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;

View File

@ -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();

View File

@ -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(())
}

View File

@ -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<TestKeypair> {
vec![self.admin]
}
}
pub struct StubOracleCreate {
pub group: Pubkey,
pub mint: Pubkey,

View File

@ -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,