diff --git a/liquidator/src/liquidate.rs b/liquidator/src/liquidate.rs index 07dc336d4..dbdd12a55 100644 --- a/liquidator/src/liquidate.rs +++ b/liquidator/src/liquidate.rs @@ -168,7 +168,7 @@ impl<'a> LiquidateHelper<'a> { > = stream::iter(self.liqee.active_perp_positions()) .then(|pp| async { let base_lots = pp.base_position_lots(); - if base_lots == 0 { + if base_lots == 0 || pp.has_open_taker_fills() { return Ok(None); } let perp = self.client.context.perp(pp.market_index); @@ -505,7 +505,7 @@ impl<'a> LiquidateHelper<'a> { } async fn token_liq_bankruptcy(&self) -> anyhow::Result> { - if !self.health_cache.can_call_spot_bankruptcy() { + if !self.health_cache.in_phase3_liquidation() || !self.health_cache.has_spot_borrows() { return Ok(None); } @@ -557,7 +557,7 @@ impl<'a> LiquidateHelper<'a> { Ok(Some(sig)) } - async fn send_liq_tx(&self) -> anyhow::Result { + async fn send_liq_tx(&self) -> anyhow::Result> { // TODO: Should we make an attempt to settle positive PNL first? // The problem with it is that small market movements can continuously create // small amounts of new positive PNL while base_position > 0. @@ -572,10 +572,18 @@ impl<'a> LiquidateHelper<'a> { // // TODO: All these close ix could be in one transaction. if let Some(txsig) = self.perp_close_orders().await? { - return Ok(txsig); + return Ok(Some(txsig)); } if let Some(txsig) = self.serum3_close_orders().await? { - return Ok(txsig); + return Ok(Some(txsig)); + } + + if self.health_cache.has_phase1_liquidatable() { + anyhow::bail!( + "Don't know what to do with phase1 liquidatable account {}, maint_health was {}", + self.pubkey, + self.maint_health + ); } // @@ -583,7 +591,7 @@ impl<'a> LiquidateHelper<'a> { // if let Some(txsig) = self.perp_liq_base_position().await? { - return Ok(txsig); + return Ok(Some(txsig)); } // Now that the perp base positions are zeroed the perp pnl won't @@ -596,7 +604,24 @@ impl<'a> LiquidateHelper<'a> { // } if let Some(txsig) = self.token_liq().await? { - return Ok(txsig); + return Ok(Some(txsig)); + } + + if self.health_cache.has_perp_open_fills() { + log::info!( + "Account {} has open perp fills, maint_health {}, waiting...", + self.pubkey, + self.maint_health + ); + return Ok(None); + } + + if self.health_cache.has_phase2_liquidatable() { + anyhow::bail!( + "Don't know what to do with phase2 liquidatable account {}, maint_health was {}", + self.pubkey, + self.maint_health + ); } // @@ -605,12 +630,12 @@ impl<'a> LiquidateHelper<'a> { // Negative pnl: take over (paid by liqee or insurance) or socialize the loss if let Some(txsig) = self.perp_liq_quote_and_bankruptcy().await? { - return Ok(txsig); + return Ok(Some(txsig)); } // Socialize/insurance fund unliquidatable borrows if let Some(txsig) = self.token_liq_bankruptcy().await? { - return Ok(txsig); + return Ok(Some(txsig)); } // TODO: What about unliquidatable positive perp pnl? @@ -670,7 +695,7 @@ pub async fn maybe_liquidate_account( ); // try liquidating - let txsig = LiquidateHelper { + let maybe_txsig = LiquidateHelper { client: mango_client, account_fetcher, pubkey, @@ -684,16 +709,18 @@ pub async fn maybe_liquidate_account( .send_liq_tx() .await?; - let slot = account_fetcher.transaction_max_slot(&[txsig]).await?; - if let Err(e) = account_fetcher - .refresh_accounts_via_rpc_until_slot( - &[*pubkey, mango_client.mango_account_address], - slot, - config.refresh_timeout, - ) - .await - { - log::info!("could not refresh after liquidation: {}", e); + if let Some(txsig) = maybe_txsig { + let slot = account_fetcher.transaction_max_slot(&[txsig]).await?; + if let Err(e) = account_fetcher + .refresh_accounts_via_rpc_until_slot( + &[*pubkey, mango_client.mango_account_address], + slot, + config.refresh_timeout, + ) + .await + { + log::info!("could not refresh after liquidation: {}", e); + } } Ok(true) diff --git a/programs/mango-v4/src/error.rs b/programs/mango-v4/src/error.rs index c1f564104..1df9789fb 100644 --- a/programs/mango-v4/src/error.rs +++ b/programs/mango-v4/src/error.rs @@ -83,6 +83,8 @@ pub enum MangoError { HasLiquidatableTrustedPerpPnl, #[msg("account is frozen")] AccountIsFrozen, + #[msg("has open perp taker fills")] + HasOpenPerpTakerFills, } impl MangoError { diff --git a/programs/mango-v4/src/health/cache.rs b/programs/mango-v4/src/health/cache.rs index d9959402f..e712a478c 100644 --- a/programs/mango-v4/src/health/cache.rs +++ b/programs/mango-v4/src/health/cache.rs @@ -223,6 +223,7 @@ pub struct PerpInfo { pub quote: I80F48, pub prices: Prices, pub has_open_orders: bool, + pub has_open_fills: bool, } impl PerpInfo { @@ -251,6 +252,7 @@ impl PerpInfo { quote: quote_current, prices, has_open_orders: perp_position.has_open_orders(), + has_open_fills: perp_position.has_open_taker_fills(), }) } @@ -452,6 +454,10 @@ impl HealthCache { self.perp_infos.iter().any(|p| p.base_lots != 0) } + pub fn has_perp_open_fills(&self) -> bool { + self.perp_infos.iter().any(|p| p.has_open_fills) + } + pub fn has_perp_positive_maint_pnl_without_base_position(&self) -> bool { self.perp_infos .iter() @@ -483,11 +489,13 @@ impl HealthCache { /// Phase2 is for: /// - token-token liquidation - /// - liquidation of perp base positions + /// - liquidation of perp base positions (an open fill isn't liquidatable, but + /// it changes the base position, so need to wait for it to be processed...) /// - bringing positive trusted perp pnl into the spot realm pub fn has_phase2_liquidatable(&self) -> bool { self.has_spot_assets() && self.has_spot_borrows() || self.has_perp_base_positions() + || self.has_perp_open_fills() || self.has_perp_positive_maint_pnl_without_base_position() } @@ -501,6 +509,10 @@ impl HealthCache { !self.has_perp_base_positions(), MangoError::HasLiquidatablePerpBasePosition ); + require!( + !self.has_perp_open_fills(), + MangoError::HasOpenPerpTakerFills + ); require!( !self.has_perp_positive_maint_pnl_without_base_position(), MangoError::HasLiquidatableTrustedPerpPnl @@ -525,33 +537,10 @@ impl HealthCache { && self.has_phase3_liquidatable() } - pub fn has_liquidatable_assets(&self) -> bool { - let spot_liquidatable = self.has_spot_assets(); - let serum3_cancelable = self.has_serum3_open_orders_funds(); - let perp_liquidatable = self.perp_infos.iter().any(|p| { - // can use perp_liq_base_position - p.base_lots != 0 - // can use perp_liq_force_cancel_orders - || p.has_open_orders - // A remaining quote position can be reduced with perp_settle_pnl and that can improve health. - // However, since it's not guaranteed that there is a counterparty, a positive perp quote position - // does not prevent bankruptcy. - }); - spot_liquidatable || serum3_cancelable || perp_liquidatable - } - pub fn has_spot_borrows(&self) -> bool { self.token_infos.iter().any(|ti| ti.balance_native < 0) } - pub fn has_borrows(&self) -> bool { - let perp_borrows = self - .perp_infos - .iter() - .any(|p| p.quote.is_negative() || p.base_lots != 0); - self.has_spot_borrows() || perp_borrows - } - pub(crate) fn compute_serum3_reservations( &self, health_type: HealthType, @@ -598,7 +587,7 @@ impl HealthCache { (token_max_reserved, serum3_reserved) } - fn health_sum(&self, health_type: HealthType, mut action: impl FnMut(I80F48)) { + pub(crate) fn health_sum(&self, health_type: HealthType, mut action: impl FnMut(I80F48)) { for token_info in self.token_infos.iter() { let contrib = token_info.health_contribution(health_type); action(contrib); @@ -656,22 +645,6 @@ impl HealthCache { } health } - - /// Sum of only the positive health components (assets) and - /// sum of absolute values of all negative health components (liabs, always >= 0) - pub fn health_assets_and_liabs(&self, health_type: HealthType) -> (I80F48, I80F48) { - let mut assets = I80F48::ZERO; - let mut liabs = I80F48::ZERO; - let sum = |contrib| { - if contrib > 0 { - cm!(assets += contrib); - } else { - cm!(liabs -= contrib); - } - }; - self.health_sum(health_type, sum); - (assets, liabs) - } } pub(crate) fn find_token_info_index(infos: &[TokenInfo], token_index: TokenIndex) -> Result { diff --git a/programs/mango-v4/src/health/client.rs b/programs/mango-v4/src/health/client.rs index 9f9e59076..97aca881f 100644 --- a/programs/mango-v4/src/health/client.rs +++ b/programs/mango-v4/src/health/client.rs @@ -13,10 +13,6 @@ use crate::util::checked_math as cm; use super::*; impl HealthCache { - pub fn can_call_spot_bankruptcy(&self) -> bool { - !self.has_liquidatable_assets() && self.has_spot_borrows() - } - pub fn is_liquidatable(&self) -> bool { if self.being_liquidated { self.health(HealthType::Init).is_negative() @@ -25,6 +21,22 @@ impl HealthCache { } } + /// Sum of only the positive health components (assets) and + /// sum of absolute values of all negative health components (liabs, always >= 0) + pub fn health_assets_and_liabs(&self, health_type: HealthType) -> (I80F48, I80F48) { + let mut assets = I80F48::ZERO; + let mut liabs = I80F48::ZERO; + let sum = |contrib| { + if contrib > 0 { + cm!(assets += contrib); + } else { + cm!(liabs -= contrib); + } + }; + self.health_sum(health_type, sum); + (assets, liabs) + } + /// The health ratio is /// - 0 if health is 0 - meaning assets = liabs /// - 100 if there's 2x as many assets as liabs @@ -951,6 +963,7 @@ mod tests { quote: I80F48::ZERO, prices: Prices::new_single_price(I80F48::from_num(2.0)), has_open_orders: false, + has_open_fills: false, }; let health_cache = HealthCache { diff --git a/programs/mango-v4/src/instructions/perp_liq_base_position.rs b/programs/mango-v4/src/instructions/perp_liq_base_position.rs index db8e6466a..a6712e85c 100644 --- a/programs/mango-v4/src/instructions/perp_liq_base_position.rs +++ b/programs/mango-v4/src/instructions/perp_liq_base_position.rs @@ -88,6 +88,11 @@ pub fn perp_liq_base_position( // Fetch perp positions for accounts, creating for the liqor if needed let liqee_perp_position = liqee.perp_position_mut(perp_market_index)?; + require!( + !liqee_perp_position.has_open_taker_fills(), + MangoError::HasOpenPerpTakerFills + ); + let liqor_perp_position = liqor .ensure_perp_position(perp_market_index, perp_market.settle_token_index)? .0; diff --git a/programs/mango-v4/src/instructions/token_liq_with_token.rs b/programs/mango-v4/src/instructions/token_liq_with_token.rs index fc8713bbf..04313cbb0 100644 --- a/programs/mango-v4/src/instructions/token_liq_with_token.rs +++ b/programs/mango-v4/src/instructions/token_liq_with_token.rs @@ -283,7 +283,7 @@ pub fn token_liq_with_token( liab_transfer: liab_transfer.to_bits(), asset_price: asset_price.to_bits(), liab_price: liab_price.to_bits(), - bankruptcy: !liqee_health_cache.has_liquidatable_assets() & liqee_init_health.is_negative() + bankruptcy: !liqee_health_cache.has_phase2_liquidatable() & liqee_init_health.is_negative() }); Ok(()) diff --git a/programs/mango-v4/src/state/mango_account_components.rs b/programs/mango-v4/src/state/mango_account_components.rs index d3080a785..4953639ab 100644 --- a/programs/mango-v4/src/state/mango_account_components.rs +++ b/programs/mango-v4/src/state/mango_account_components.rs @@ -575,12 +575,22 @@ impl PerpPosition { cm!(self.quote_position_native += quote_change_native); } - /// Does the perp position have any open orders or fill events? + /// Does the user have any orders on the book? + /// + /// Note that it's possible they were matched already: This only becomes + /// false when the fill event is processed or the orders are cancelled. pub fn has_open_orders(&self) -> bool { - self.asks_base_lots != 0 - || self.bids_base_lots != 0 - || self.taker_base_lots != 0 - || self.taker_quote_lots != 0 + self.asks_base_lots != 0 || self.bids_base_lots != 0 + } + + // Did the user take orders and hasn't been filled yet? + pub fn has_open_taker_fills(&self) -> bool { + self.taker_base_lots != 0 || self.taker_quote_lots != 0 + } + + /// Are there any open orders or fills that haven't been processed yet? + pub fn has_open_orders_or_fills(&self) -> bool { + self.has_open_orders() || self.has_open_taker_fills() } /// Calculate the average entry price of the position, in native/native units