Allow token withdraws/deposits even with stale oracles (#646)
This commit is contained in:
parent
a6b6fbbb82
commit
4f810edebc
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()?;
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
@ -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(())
|
||||
|
|
|
@ -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(())
|
||||
|
|
|
@ -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)?;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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(())
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue