From 93d33edb7418756b8346ef4a17748267314325ba Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Thu, 12 Jan 2023 09:07:13 +0100 Subject: [PATCH] Perp: liq with token instruction (#368) The new instruction allows the liqor to take over negative pnl (limited by liqee settle health and settle limits) before applying the bankruptcy logic. --- client/src/client.rs | 11 +- liquidator/src/liquidate.rs | 44 +- programs/mango-v4/src/error.rs | 10 + programs/mango-v4/src/health/cache.rs | 137 +++- programs/mango-v4/src/health/client.rs | 1 + programs/mango-v4/src/instructions/mod.rs | 4 +- .../src/instructions/perp_consume_events.rs | 3 - .../src/instructions/perp_liq_bankruptcy.rs | 238 ------- .../instructions/perp_liq_base_position.rs | 14 +- .../perp_liq_quote_and_bankruptcy.rs | 380 +++++++++++ .../src/instructions/perp_settle_fees.rs | 1 - .../src/instructions/perp_settle_pnl.rs | 146 +++-- .../src/instructions/token_liq_bankruptcy.rs | 16 +- .../src/instructions/token_liq_with_token.rs | 7 +- programs/mango-v4/src/lib.rs | 6 +- programs/mango-v4/src/logs.rs | 18 +- .../tests/program_test/mango_client.rs | 176 +++-- programs/mango-v4/tests/test_basic.rs | 9 +- programs/mango-v4/tests/test_liq_perps.rs | 617 +++++++++++++++--- 19 files changed, 1290 insertions(+), 548 deletions(-) delete mode 100644 programs/mango-v4/src/instructions/perp_liq_bankruptcy.rs create mode 100644 programs/mango-v4/src/instructions/perp_liq_quote_and_bankruptcy.rs diff --git a/client/src/client.rs b/client/src/client.rs index 7630f21e9..0e38be4bc 100644 --- a/client/src/client.rs +++ b/client/src/client.rs @@ -879,7 +879,7 @@ impl MangoClient { self.send_and_confirm_owner_tx(vec![ix]).await } - pub async fn perp_liq_bankruptcy( + pub async fn perp_liq_quote_and_bankruptcy( &self, liqee: (&Pubkey, &MangoAccountValue), market_index: PerpMarketIndex, @@ -903,9 +903,10 @@ impl MangoClient { program_id: mango_v4::id(), accounts: { let mut ams = anchor_lang::ToAccountMetas::to_account_metas( - &mango_v4::accounts::PerpLiqBankruptcy { + &mango_v4::accounts::PerpLiqQuoteAndBankruptcy { group: self.group(), perp_market: perp.address, + oracle: perp.market.oracle, liqor: self.mango_account_address, liqor_owner: self.owner(), liqee: *liqee.0, @@ -920,9 +921,9 @@ impl MangoClient { ams.extend(health_remaining_ams.into_iter()); ams }, - data: anchor_lang::InstructionData::data(&mango_v4::instruction::PerpLiqBankruptcy { - max_liab_transfer, - }), + data: anchor_lang::InstructionData::data( + &mango_v4::instruction::PerpLiqQuoteAndBankruptcy { max_liab_transfer }, + ), }; self.send_and_confirm_owner_tx(vec![ix]).await } diff --git a/liquidator/src/liquidate.rs b/liquidator/src/liquidate.rs index d0c9d0b08..07dc336d4 100644 --- a/liquidator/src/liquidate.rs +++ b/liquidator/src/liquidate.rs @@ -247,6 +247,7 @@ impl<'a> LiquidateHelper<'a> { Ok(Some(sig)) } + /* async fn perp_settle_pnl(&self) -> anyhow::Result> { let perp_settle_health = self.health_cache.perp_settle_health(); let mut perp_settleable_pnl = self @@ -257,6 +258,7 @@ impl<'a> LiquidateHelper<'a> { return None; } let pnl = pp.quote_position_native(); + // TODO: outdated: must account for perp settle limit let settleable_pnl = if pnl > 0 { pnl } else if pnl < 0 && perp_settle_health > 0 { @@ -320,12 +322,13 @@ impl<'a> LiquidateHelper<'a> { } return Ok(None); } + */ - async fn perp_liq_bankruptcy(&self) -> anyhow::Result> { - if self.health_cache.has_liquidatable_assets() { + async fn perp_liq_quote_and_bankruptcy(&self) -> anyhow::Result> { + if !self.health_cache.in_phase3_liquidation() { return Ok(None); } - let mut perp_bankruptcies = self + let mut perp_negative_pnl = self .liqee .active_perp_positions() .filter_map(|pp| { @@ -336,24 +339,24 @@ impl<'a> LiquidateHelper<'a> { Some((pp.market_index, quote)) }) .collect::>(); - perp_bankruptcies.sort_by(|a, b| a.1.cmp(&b.1)); + perp_negative_pnl.sort_by(|a, b| a.1.cmp(&b.1)); - if perp_bankruptcies.is_empty() { + if perp_negative_pnl.is_empty() { return Ok(None); } - let (perp_market_index, _) = perp_bankruptcies.first().unwrap(); + let (perp_market_index, _) = perp_negative_pnl.first().unwrap(); let sig = self .client - .perp_liq_bankruptcy( + .perp_liq_quote_and_bankruptcy( (self.pubkey, &self.liqee), *perp_market_index, - // Always use the max amount, since the health effect is always positive + // Always use the max amount, since the health effect is >= 0 u64::MAX, ) .await?; log::info!( - "Liquidated bankruptcy for perp market on account {}, market index {}, maint_health was {}, tx sig {:?}", + "Liquidated negative perp pnl on account {}, market index {}, maint_health was {}, tx sig {:?}", self.pubkey, perp_market_index, self.maint_health, @@ -564,7 +567,10 @@ impl<'a> LiquidateHelper<'a> { // return Ok(txsig); // } - // Try to close orders before touching the user's positions + // + // Phase 1: Try to close orders before touching the user's positions + // + // TODO: All these close ix could be in one transaction. if let Some(txsig) = self.perp_close_orders().await? { return Ok(txsig); } @@ -572,6 +578,10 @@ impl<'a> LiquidateHelper<'a> { return Ok(txsig); } + // + // Phase 2: token, perp base, TODO: perp positive trusted pnl + // + if let Some(txsig) = self.perp_liq_base_position().await? { return Ok(txsig); } @@ -581,16 +591,20 @@ impl<'a> LiquidateHelper<'a> { // It's possible that some positive pnl can't be settled (if there's // no liquid counterparty) and that some negative pnl can't be settled // (if the liqee isn't liquid enough). - if let Some(txsig) = self.perp_settle_pnl().await? { - return Ok(txsig); - } + // if let Some(txsig) = self.perp_settle_pnl().await? { + // return Ok(txsig); + // } if let Some(txsig) = self.token_liq().await? { return Ok(txsig); } - // Socialize/insurance fund unsettleable negative pnl - if let Some(txsig) = self.perp_liq_bankruptcy().await? { + // + // Phase 3: perp and token bankruptcy + // + + // 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); } diff --git a/programs/mango-v4/src/error.rs b/programs/mango-v4/src/error.rs index b5a6ffb58..cd212b740 100644 --- a/programs/mango-v4/src/error.rs +++ b/programs/mango-v4/src/error.rs @@ -69,6 +69,16 @@ pub enum MangoError { TokenInReduceOnlyMode, #[msg("market is in reduce only mode")] MarketInReduceOnlyMode, + #[msg("the perp position has non-zero base lots")] + PerpHasBaseLots, + #[msg("there are open or unsettled serum3 orders")] + HasOpenOrUnsettledSerum3Orders, + #[msg("has liquidatable token position")] + HasLiquidatableTokenPosition, + #[msg("has liquidatable perp base position")] + HasLiquidatablePerpBasePosition, + #[msg("has liquidatable trusted perp pnl")] + HasLiquidatableTrustedPerpPnl, } impl MangoError { diff --git a/programs/mango-v4/src/health/cache.rs b/programs/mango-v4/src/health/cache.rs index 1273391d7..40b304e29 100644 --- a/programs/mango-v4/src/health/cache.rs +++ b/programs/mango-v4/src/health/cache.rs @@ -4,8 +4,7 @@ use fixed::types::I80F48; use crate::error::*; use crate::state::{ - Bank, MangoAccountFixed, MangoAccountRef, PerpMarket, PerpMarketIndex, PerpPosition, - Serum3MarketIndex, TokenIndex, + Bank, MangoAccountRef, PerpMarket, PerpMarketIndex, PerpPosition, Serum3MarketIndex, TokenIndex, }; use crate::util::checked_math as cm; @@ -135,6 +134,8 @@ pub struct Serum3Info { pub base_index: usize, pub quote_index: usize, pub market_index: Serum3MarketIndex, + /// The open orders account has no free or reserved funds + pub has_zero_funds: bool, } impl Serum3Info { @@ -328,29 +329,6 @@ impl HealthCache { health } - pub fn check_health_pre(&self, account: &mut MangoAccountFixed) -> Result { - let pre_health = self.health(HealthType::Init); - msg!("pre_health: {}", pre_health); - account.maybe_recover_from_being_liquidated(pre_health); - require!(!account.being_liquidated(), MangoError::BeingLiquidated); - Ok(pre_health) - } - - pub fn check_health_post( - &self, - account: &mut MangoAccountFixed, - pre_health: I80F48, - ) -> Result<()> { - let post_health = self.health(HealthType::Init); - msg!("post_health: {}", post_health); - require!( - post_health >= 0 || post_health > pre_health, - MangoError::HealthMustBePositiveOrIncrease - ); - account.maybe_recover_from_being_liquidated(post_health); - Ok(()) - } - pub fn token_info(&self, token_index: TokenIndex) -> Result<&TokenInfo> { Ok(&self.token_infos[self.token_info_index(token_index)?]) } @@ -446,13 +424,94 @@ impl HealthCache { }) } + pub fn has_serum3_open_orders_funds(&self) -> bool { + self.serum3_infos.iter().any(|si| !si.has_zero_funds) + } + + pub fn has_perp_open_orders(&self) -> bool { + self.perp_infos.iter().any(|p| p.has_open_orders) + } + + pub fn has_perp_base_positions(&self) -> bool { + self.perp_infos.iter().any(|p| p.base_lots != 0) + } + + pub fn has_perp_positive_trusted_pnl_without_base_position(&self) -> bool { + self.perp_infos + .iter() + .any(|p| p.trusted_market && p.base_lots == 0 && p.quote > 0) + } + + pub fn has_perp_negative_pnl(&self) -> bool { + self.perp_infos.iter().any(|p| p.quote < 0) + } + + /// Phase1 is spot/perp order cancellation and spot settlement since + /// neither of these come at a cost to the liqee + pub fn has_phase1_liquidatable(&self) -> bool { + self.has_serum3_open_orders_funds() || self.has_perp_open_orders() + } + + pub fn require_after_phase1_liquidation(&self) -> Result<()> { + require!( + !self.has_serum3_open_orders_funds(), + MangoError::HasOpenOrUnsettledSerum3Orders + ); + require!(!self.has_perp_open_orders(), MangoError::HasOpenPerpOrders); + Ok(()) + } + + pub fn in_phase1_liquidation(&self) -> bool { + self.has_phase1_liquidatable() + } + + /// Phase2 is for: + /// - token-token liquidation + /// - liquidation of perp base positions + /// - 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_positive_trusted_pnl_without_base_position() + } + + pub fn require_after_phase2_liquidation(&self) -> Result<()> { + self.require_after_phase1_liquidation()?; + require!( + !self.has_spot_assets() || !self.has_spot_borrows(), + MangoError::HasLiquidatableTokenPosition + ); + require!( + !self.has_perp_base_positions(), + MangoError::HasLiquidatablePerpBasePosition + ); + require!( + !self.has_perp_positive_trusted_pnl_without_base_position(), + MangoError::HasLiquidatableTrustedPerpPnl + ); + Ok(()) + } + + pub fn in_phase2_liquidation(&self) -> bool { + !self.has_phase1_liquidatable() && self.has_phase2_liquidatable() + } + + /// Phase3 is bankruptcy: + /// - token bankruptcy + /// - perp bankruptcy + pub fn has_phase3_liquidatable(&self) -> bool { + self.has_spot_borrows() || self.has_perp_negative_pnl() + } + + pub fn in_phase3_liquidation(&self) -> bool { + !self.has_phase1_liquidatable() + && !self.has_phase2_liquidatable() + && self.has_phase3_liquidatable() + } + pub fn has_liquidatable_assets(&self) -> bool { let spot_liquidatable = self.has_spot_assets(); - // can use serum3_liq_force_cancel_orders - let serum3_cancelable = self - .serum3_infos - .iter() - .any(|si| si.reserved_base != 0 || si.reserved_quote != 0); + 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 @@ -477,6 +536,21 @@ impl HealthCache { self.has_spot_borrows() || perp_borrows } + pub fn has_liquidatable_spot_or_perp_base(&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(crate) fn compute_serum3_reservations( &self, health_type: HealthType, @@ -671,6 +745,9 @@ pub fn new_health_cache( base_index, quote_index, market_index: serum_account.market_index, + has_zero_funds: oo.native_coin_total == 0 + && oo.native_pc_total == 0 + && oo.referrer_rebates_accrued == 0, }); } diff --git a/programs/mango-v4/src/health/client.rs b/programs/mango-v4/src/health/client.rs index 27ad3db22..9bccc0c52 100644 --- a/programs/mango-v4/src/health/client.rs +++ b/programs/mango-v4/src/health/client.rs @@ -846,6 +846,7 @@ mod tests { market_index: 0, reserved_base: I80F48::from(30 / 3), reserved_quote: I80F48::from(30 / 2), + has_zero_funds: false, }]; adjust_by_usdc(&mut health_cache, 0, -20.0); adjust_by_usdc(&mut health_cache, 1, -40.0); diff --git a/programs/mango-v4/src/instructions/mod.rs b/programs/mango-v4/src/instructions/mod.rs index ede7669da..e339c99f7 100644 --- a/programs/mango-v4/src/instructions/mod.rs +++ b/programs/mango-v4/src/instructions/mod.rs @@ -20,9 +20,9 @@ pub use perp_consume_events::*; pub use perp_create_market::*; pub use perp_deactivate_position::*; pub use perp_edit_market::*; -pub use perp_liq_bankruptcy::*; pub use perp_liq_base_position::*; pub use perp_liq_force_cancel_orders::*; +pub use perp_liq_quote_and_bankruptcy::*; pub use perp_place_order::*; pub use perp_settle_fees::*; pub use perp_settle_pnl::*; @@ -73,9 +73,9 @@ mod perp_consume_events; mod perp_create_market; mod perp_deactivate_position; mod perp_edit_market; -mod perp_liq_bankruptcy; mod perp_liq_base_position; mod perp_liq_force_cancel_orders; +mod perp_liq_quote_and_bankruptcy; mod perp_place_order; mod perp_settle_fees; mod perp_settle_pnl; diff --git a/programs/mango-v4/src/instructions/perp_consume_events.rs b/programs/mango-v4/src/instructions/perp_consume_events.rs index 411085f78..eb66374a1 100644 --- a/programs/mango-v4/src/instructions/perp_consume_events.rs +++ b/programs/mango-v4/src/instructions/perp_consume_events.rs @@ -71,7 +71,6 @@ pub fn perp_consume_events(ctx: Context, limit: usize) -> Res emit_perp_balances( ctx.accounts.group.key(), fill.maker, - perp_market.perp_market_index, ma.perp_position(perp_market.perp_market_index).unwrap(), &perp_market, ); @@ -123,14 +122,12 @@ pub fn perp_consume_events(ctx: Context, limit: usize) -> Res emit_perp_balances( ctx.accounts.group.key(), fill.maker, - perp_market.perp_market_index, maker.perp_position(perp_market.perp_market_index).unwrap(), &perp_market, ); emit_perp_balances( ctx.accounts.group.key(), fill.taker, - perp_market.perp_market_index, taker.perp_position(perp_market.perp_market_index).unwrap(), &perp_market, ); diff --git a/programs/mango-v4/src/instructions/perp_liq_bankruptcy.rs b/programs/mango-v4/src/instructions/perp_liq_bankruptcy.rs deleted file mode 100644 index c8ea2abda..000000000 --- a/programs/mango-v4/src/instructions/perp_liq_bankruptcy.rs +++ /dev/null @@ -1,238 +0,0 @@ -use anchor_lang::prelude::*; -use anchor_spl::token; -use anchor_spl::token::Token; -use anchor_spl::token::TokenAccount; -use fixed::types::I80F48; - -use crate::error::*; -use crate::health::*; -use crate::state::*; -use crate::util::checked_math as cm; - -use crate::logs::{emit_perp_balances, PerpLiqBankruptcyLog, TokenBalanceLog}; - -// Remaining accounts: -// - merged health accounts for liqor+liqee -#[derive(Accounts)] -pub struct PerpLiqBankruptcy<'info> { - #[account( - has_one = insurance_vault, - )] - pub group: AccountLoader<'info, Group>, - - #[account(mut, has_one = group)] - pub perp_market: AccountLoader<'info, PerpMarket>, - - #[account( - mut, - has_one = group - // liqor_owner is checked at #1 - )] - pub liqor: AccountLoader<'info, MangoAccountFixed>, - pub liqor_owner: Signer<'info>, - - #[account( - mut, - has_one = group - )] - pub liqee: AccountLoader<'info, MangoAccountFixed>, - - #[account( - mut, - has_one = group, - // address is checked at #2 - )] - pub settle_bank: AccountLoader<'info, Bank>, - - #[account( - mut, - address = settle_bank.load()?.vault - )] - pub settle_vault: Account<'info, TokenAccount>, - - /// CHECK: Oracle can have different account types - #[account(address = settle_bank.load()?.oracle)] - pub settle_oracle: UncheckedAccount<'info>, - - // future: this would be an insurance fund vault specific to a - // trustless token, separate from the shared one on the group - #[account(mut)] - pub insurance_vault: Account<'info, TokenAccount>, - - pub token_program: Program<'info, Token>, -} - -impl<'info> PerpLiqBankruptcy<'info> { - pub fn transfer_ctx(&self) -> CpiContext<'_, '_, '_, 'info, token::Transfer<'info>> { - let program = self.token_program.to_account_info(); - let accounts = token::Transfer { - from: self.insurance_vault.to_account_info(), - to: self.settle_vault.to_account_info(), - authority: self.group.to_account_info(), - }; - CpiContext::new(program, accounts) - } -} - -pub fn perp_liq_bankruptcy(ctx: Context, max_liab_transfer: u64) -> Result<()> { - let group = ctx.accounts.group.load()?; - let group_pk = &ctx.accounts.group.key(); - - let mut liqor = ctx.accounts.liqor.load_full_mut()?; - // account constraint #1 - require!( - liqor - .fixed - .is_owner_or_delegate(ctx.accounts.liqor_owner.key()), - MangoError::SomeError - ); - require!(!liqor.fixed.being_liquidated(), MangoError::BeingLiquidated); - - let mut liqee = ctx.accounts.liqee.load_full_mut()?; - let mut liqee_health_cache = { - let account_retriever = ScanningAccountRetriever::new(ctx.remaining_accounts, group_pk)?; - new_health_cache(&liqee.borrow(), &account_retriever) - .context("create liqee health cache")? - }; - - // Check if liqee is bankrupt - require!( - !liqee_health_cache.has_liquidatable_assets(), - MangoError::IsNotBankrupt - ); - liqee.fixed.set_being_liquidated(true); - - // Find bankrupt liab amount - let mut perp_market = ctx.accounts.perp_market.load_mut()?; - let settle_token_index = perp_market.settle_token_index; - let liqee_perp_position = liqee.perp_position_mut(perp_market.perp_market_index)?; - require_msg!( - liqee_perp_position.base_position_lots() == 0, - "liqee must have zero base position" - ); - require!( - !liqee_perp_position.has_open_orders(), - MangoError::HasOpenPerpOrders - ); - - let liqee_pnl = liqee_perp_position.quote_position_native(); - require_msg!( - liqee_pnl.is_negative(), - "liqee pnl must be negative, was {}", - liqee_pnl - ); - let liab_transfer = (-liqee_pnl).min(I80F48::from(max_liab_transfer)); - - // Preparation for covering it with the insurance fund - let insurance_vault_amount = if perp_market.elligible_for_group_insurance_fund() { - ctx.accounts.insurance_vault.amount - } else { - 0 - }; - - let liquidation_fee_factor = cm!(I80F48::ONE + perp_market.liquidation_fee); - - let insurance_transfer = cm!(liab_transfer * liquidation_fee_factor) - .checked_ceil() - .unwrap() - .checked_to_num::() - .unwrap() - .min(insurance_vault_amount); - - let insurance_transfer_i80f48 = I80F48::from(insurance_transfer); - let insurance_fund_exhausted = insurance_transfer == insurance_vault_amount; - let insurance_liab_transfer = - cm!(insurance_transfer_i80f48 / liquidation_fee_factor).min(liab_transfer); - - // Try using the insurance fund if possible - if insurance_transfer > 0 { - let mut settle_bank = ctx.accounts.settle_bank.load_mut()?; - require_eq!(settle_bank.token_index, settle_token_index); - require_keys_eq!(settle_bank.mint, ctx.accounts.insurance_vault.mint); - - // move insurance assets into quote bank - let group_seeds = group_seeds!(group); - token::transfer( - ctx.accounts.transfer_ctx().with_signer(&[group_seeds]), - insurance_transfer, - )?; - - // credit the liqor with quote tokens - let (liqor_quote, _, _) = liqor.ensure_token_position(settle_token_index)?; - settle_bank.deposit( - liqor_quote, - insurance_transfer_i80f48, - Clock::get()?.unix_timestamp.try_into().unwrap(), - )?; - - emit!(TokenBalanceLog { - mango_group: ctx.accounts.group.key(), - mango_account: ctx.accounts.liqor.key(), - token_index: settle_token_index, - indexed_position: liqor_quote.indexed_position.to_bits(), - deposit_index: settle_bank.deposit_index.to_bits(), - borrow_index: settle_bank.borrow_index.to_bits(), - }); - - // transfer perp quote loss from the liqee to the liqor - let liqor_perp_position = liqor - .ensure_perp_position(perp_market.perp_market_index, settle_token_index)? - .0; - liqee_perp_position.record_settle(-insurance_liab_transfer); - liqor_perp_position.record_liquidation_quote_change(-insurance_liab_transfer); - - emit_perp_balances( - ctx.accounts.group.key(), - ctx.accounts.liqor.key(), - perp_market.perp_market_index, - liqor_perp_position, - &perp_market, - ); - } - - // Socialize loss if the insurance fund is exhausted - let remaining_liab = liab_transfer - insurance_liab_transfer; - let mut socialized_loss = I80F48::ZERO; - if insurance_fund_exhausted && remaining_liab.is_positive() { - perp_market.socialize_loss(-remaining_liab)?; - liqee_perp_position.record_settle(-remaining_liab); - require_eq!(liqee_perp_position.quote_position_native(), 0); - socialized_loss = remaining_liab; - } - - emit_perp_balances( - ctx.accounts.group.key(), - ctx.accounts.liqee.key(), - perp_market.perp_market_index, - liqee_perp_position, - &perp_market, - ); - - emit!(PerpLiqBankruptcyLog { - mango_group: ctx.accounts.group.key(), - liqee: ctx.accounts.liqee.key(), - liqor: ctx.accounts.liqor.key(), - perp_market_index: perp_market.perp_market_index, - insurance_transfer: insurance_transfer_i80f48.to_bits(), - socialized_loss: socialized_loss.to_bits() - }); - - // Check liqee health again - liqee_health_cache.recompute_perp_info(liqee_perp_position, &perp_market)?; - let liqee_init_health = liqee_health_cache.health(HealthType::Init); - liqee - .fixed - .maybe_recover_from_being_liquidated(liqee_init_health); - - drop(perp_market); - - // Check liqor's health - if !liqor.fixed.is_in_health_region() { - let account_retriever = ScanningAccountRetriever::new(ctx.remaining_accounts, group_pk)?; - let liqor_health = compute_health(&liqor.borrow(), HealthType::Init, &account_retriever) - .context("compute liqor health")?; - require!(liqor_health >= 0, MangoError::HealthMustBePositive); - } - - Ok(()) -} 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 4cfbb4ca4..34ef11eda 100644 --- a/programs/mango-v4/src/instructions/perp_liq_base_position.rs +++ b/programs/mango-v4/src/instructions/perp_liq_base_position.rs @@ -45,7 +45,11 @@ pub fn perp_liq_base_position( .is_owner_or_delegate(ctx.accounts.liqor_owner.key()), MangoError::SomeError ); - require!(!liqor.fixed.being_liquidated(), MangoError::BeingLiquidated); + require_msg_typed!( + !liqor.fixed.being_liquidated(), + MangoError::BeingLiquidated, + "liqor account" + ); let mut liqee = ctx.accounts.liqee.load_full_mut()?; @@ -57,6 +61,7 @@ pub fn perp_liq_base_position( .context("create liqee health cache")? }; let liqee_init_health = liqee_health_cache.health(HealthType::Init); + liqee_health_cache.require_after_phase1_liquidation()?; // Once maint_health falls below 0, we want to start liquidating, // we want to allow liquidation to continue until init_health is positive, @@ -96,11 +101,6 @@ pub fn perp_liq_base_position( .0; let liqee_base_lots = liqee_perp_position.base_position_lots(); - require!( - !liqee_perp_position.has_open_orders(), - MangoError::HasOpenPerpOrders - ); - // Settle funding liqee_perp_position.settle_funding(&perp_market); liqor_perp_position.settle_funding(&perp_market); @@ -173,7 +173,6 @@ pub fn perp_liq_base_position( emit_perp_balances( ctx.accounts.group.key(), ctx.accounts.liqor.key(), - perp_market.perp_market_index, liqor_perp_position, &perp_market, ); @@ -181,7 +180,6 @@ pub fn perp_liq_base_position( emit_perp_balances( ctx.accounts.group.key(), ctx.accounts.liqee.key(), - perp_market.perp_market_index, liqee_perp_position, &perp_market, ); diff --git a/programs/mango-v4/src/instructions/perp_liq_quote_and_bankruptcy.rs b/programs/mango-v4/src/instructions/perp_liq_quote_and_bankruptcy.rs new file mode 100644 index 000000000..546c269dd --- /dev/null +++ b/programs/mango-v4/src/instructions/perp_liq_quote_and_bankruptcy.rs @@ -0,0 +1,380 @@ +use anchor_lang::prelude::*; +use anchor_spl::token; +use anchor_spl::token::Token; +use anchor_spl::token::TokenAccount; +use checked_math as cm; +use fixed::types::I80F48; + +use crate::accounts_zerocopy::*; +use crate::error::*; +use crate::health::{compute_health, new_health_cache, HealthType, ScanningAccountRetriever}; +use crate::logs::{ + emit_perp_balances, PerpLiqBankruptcyLog, PerpLiqQuoteAndBankruptcyLog, TokenBalanceLog, +}; +use crate::state::*; + +#[derive(Accounts)] +pub struct PerpLiqQuoteAndBankruptcy<'info> { + pub group: AccountLoader<'info, Group>, + + #[account( + mut, + has_one = group, + // liqor_owner is checked at #1 + )] + pub liqor: AccountLoader<'info, MangoAccountFixed>, + pub liqor_owner: Signer<'info>, + + // This account MUST have a loss + #[account(mut, has_one = group)] + pub liqee: AccountLoader<'info, MangoAccountFixed>, + + #[account(mut, has_one = group, has_one = oracle)] + pub perp_market: AccountLoader<'info, PerpMarket>, + + /// CHECK: Oracle can have different account types, constrained by address in perp_market + pub oracle: UncheckedAccount<'info>, + + // bank correctness is checked at #2 + #[account(mut, has_one = group)] + pub settle_bank: AccountLoader<'info, Bank>, + + #[account( + mut, + address = settle_bank.load()?.vault + )] + pub settle_vault: Account<'info, TokenAccount>, + + /// CHECK: Oracle can have different account types + #[account(address = settle_bank.load()?.oracle)] + pub settle_oracle: UncheckedAccount<'info>, + + // future: this would be an insurance fund vault specific to a + // trustless token, separate from the shared one on the group + #[account(mut)] + pub insurance_vault: Account<'info, TokenAccount>, + + pub token_program: Program<'info, Token>, +} + +impl<'info> PerpLiqQuoteAndBankruptcy<'info> { + pub fn transfer_ctx(&self) -> CpiContext<'_, '_, '_, 'info, token::Transfer<'info>> { + let program = self.token_program.to_account_info(); + let accounts = token::Transfer { + from: self.insurance_vault.to_account_info(), + to: self.settle_vault.to_account_info(), + authority: self.group.to_account_info(), + }; + CpiContext::new(program, accounts) + } +} + +pub fn perp_liq_quote_and_bankruptcy( + ctx: Context, + max_liab_transfer: u64, +) -> Result<()> { + let mango_group = ctx.accounts.group.key(); + + // Cannot settle with yourself + require!( + ctx.accounts.liqor.key() != ctx.accounts.liqee.key(), + MangoError::SomeError + ); + + let (perp_market_index, settle_token_index) = { + let perp_market = ctx.accounts.perp_market.load()?; + ( + perp_market.perp_market_index, + perp_market.settle_token_index, + ) + }; + + let mut liqee = ctx.accounts.liqee.load_full_mut()?; + let mut liqor = ctx.accounts.liqor.load_full_mut()?; + // account constraint #1 + require!( + liqor + .fixed + .is_owner_or_delegate(ctx.accounts.liqor_owner.key()), + MangoError::SomeError + ); + require_msg_typed!( + !liqor.fixed.being_liquidated(), + MangoError::BeingLiquidated, + "liqor account" + ); + + let mut liqee_health_cache = { + let retriever = ScanningAccountRetriever::new(ctx.remaining_accounts, &mango_group) + .context("create account retriever")?; + new_health_cache(&liqee.borrow(), &retriever)? + }; + let liqee_init_health = liqee_health_cache.health(HealthType::Init); + let liqee_settle_health = liqee_health_cache.perp_settle_health(); + liqee_health_cache.require_after_phase2_liquidation()?; + + // Once maint_health falls below 0, we want to start liquidating, + // we want to allow liquidation to continue until init_health is positive, + // to prevent constant oscillation between the two states + if liqee.being_liquidated() { + if liqee + .fixed + .maybe_recover_from_being_liquidated(liqee_init_health) + { + msg!("Liqee init_health above zero"); + return Ok(()); + } + } else { + let maint_health = liqee_health_cache.health(HealthType::Maint); + require!( + maint_health < I80F48::ZERO, + MangoError::HealthMustBeNegative + ); + liqee.fixed.set_being_liquidated(true); + } + + // check positions exist/create them, done early for nicer error messages + { + liqee.perp_position(perp_market_index)?; + liqee.token_position(settle_token_index)?; + liqor.ensure_perp_position(perp_market_index, settle_token_index)?; + liqor.ensure_token_position(settle_token_index)?; + } + + let mut settle_bank = ctx.accounts.settle_bank.load_mut()?; + // account constraint #2 + require!( + settle_bank.token_index == settle_token_index, + MangoError::InvalidBank + ); + + // Get oracle price for market. Price is validated inside + let mut perp_market = ctx.accounts.perp_market.load_mut()?; + let oracle_price = perp_market.oracle_price( + &AccountInfoRef::borrow(ctx.accounts.oracle.as_ref())?, + None, // staleness checked in health + )?; + + let now_ts: u64 = Clock::get()?.unix_timestamp.try_into().unwrap(); + + // + // Step 1: Allow the liqor to take over ("settle") negative liqee pnl. + // + // The only limitation is the liqee's perp_settle_health and its perp pnl settle limit. + // + let settlement; + let max_settlement_liqee; + { + let liqee_perp_position = liqee.perp_position_mut(perp_market_index)?; + let liqor_perp_position = liqor.perp_position_mut(perp_market_index)?; + liqee_perp_position.settle_funding(&perp_market); + liqor_perp_position.settle_funding(&perp_market); + + let liqee_pnl = liqee_perp_position.pnl_for_price(&perp_market, oracle_price)?; + // TODO: deal with positive liqee pnl! Maybe another instruction? + require!(liqee_pnl < 0, MangoError::ProfitabilityMismatch); + + // Get settleable pnl on the liqee + liqee_perp_position.update_settle_limit(&perp_market, now_ts); + let liqee_settleable_pnl = + liqee_perp_position.apply_pnl_settle_limit(&perp_market, liqee_pnl); + + max_settlement_liqee = liqee_settle_health + .min(-liqee_settleable_pnl) + .max(I80F48::ZERO); + settlement = max_settlement_liqee + .min(I80F48::from(max_liab_transfer)) + .max(I80F48::ZERO); + if settlement > 0 { + liqor_perp_position.record_liquidation_quote_change(-settlement); + liqee_perp_position.record_settle(-settlement); + + // Update the accounts' perp_spot_transfer statistics. + let settlement_i64 = settlement.round_to_zero().checked_to_num::().unwrap(); + cm!(liqor_perp_position.perp_spot_transfers += settlement_i64); + cm!(liqee_perp_position.perp_spot_transfers -= settlement_i64); + cm!(liqor.fixed.perp_spot_transfers += settlement_i64); + cm!(liqee.fixed.perp_spot_transfers -= settlement_i64); + + let now_ts: u64 = Clock::get()?.unix_timestamp.try_into().unwrap(); + + // Transfer token balance + let liqor_token_position = liqor.token_position_mut(settle_token_index)?.0; + let liqee_token_position = liqee.token_position_mut(settle_token_index)?.0; + settle_bank.deposit(liqor_token_position, settlement, now_ts)?; + settle_bank.withdraw_without_fee( + liqee_token_position, + settlement, + now_ts, + oracle_price, + )?; + liqee_health_cache.adjust_token_balance(&settle_bank, -settlement)?; + + emit!(PerpLiqQuoteAndBankruptcyLog { + mango_group, + liqee: ctx.accounts.liqee.key(), + liqor: ctx.accounts.liqor.key(), + perp_market_index: perp_market_index, + settlement: settlement.to_bits(), + }); + + msg!("liquidated pnl = {}", settlement); + } + }; + let max_liab_transfer = cm!(I80F48::from(max_liab_transfer) - settlement); + + // + // Step 2: bankruptcy + // + // Remaining pnl that brings the account into negative init health is either: + // - taken by the liqor in exchange for spot from the insurance fund, or + // - wiped away and socialized among all perp participants + // + let insurance_transfer = if settlement == max_settlement_liqee { + let liqee_perp_position = liqee.perp_position_mut(perp_market_index)?; + let liqee_pnl = liqee_perp_position.pnl_for_price(&perp_market, oracle_price)?; + + let max_liab_transfer_from_liqee = (-liqee_pnl).min(-liqee_init_health).max(I80F48::ZERO); + let liab_transfer = max_liab_transfer_from_liqee + .min(max_liab_transfer) + .max(I80F48::ZERO); + + // Available insurance fund coverage + let insurance_vault_amount = if perp_market.elligible_for_group_insurance_fund() { + ctx.accounts.insurance_vault.amount + } else { + 0 + }; + + let liquidation_fee_factor = cm!(I80F48::ONE + perp_market.liquidation_fee); + + // Amount given to the liqor from the insurance fund + let insurance_transfer = cm!(liab_transfer * liquidation_fee_factor) + .checked_ceil() + .unwrap() + .checked_to_num::() + .unwrap() + .min(insurance_vault_amount); + + let insurance_transfer_i80f48 = I80F48::from(insurance_transfer); + let insurance_fund_exhausted = insurance_transfer == insurance_vault_amount; + + // Amount of negative perp pnl transfered to the liqor + let insurance_liab_transfer = + cm!(insurance_transfer_i80f48 / liquidation_fee_factor).min(liab_transfer); + + // Try using the insurance fund if possible + if insurance_transfer > 0 { + require_keys_eq!(settle_bank.mint, ctx.accounts.insurance_vault.mint); + + // move insurance assets into quote bank + let group = ctx.accounts.group.load()?; + let group_seeds = group_seeds!(group); + token::transfer( + ctx.accounts.transfer_ctx().with_signer(&[group_seeds]), + insurance_transfer, + )?; + + // credit the liqor with quote tokens + let (liqor_quote, _, _) = liqor.ensure_token_position(settle_token_index)?; + settle_bank.deposit(liqor_quote, insurance_transfer_i80f48, now_ts)?; + + // transfer perp quote loss from the liqee to the liqor + let liqor_perp_position = liqor.perp_position_mut(perp_market_index)?; + liqee_perp_position.record_settle(-insurance_liab_transfer); + liqor_perp_position.record_liquidation_quote_change(-insurance_liab_transfer); + } + + // Socialize loss if the insurance fund is exhausted + // At this point, we don't care about the liqor's requested max_liab_tranfer + let remaining_liab = max_liab_transfer_from_liqee - insurance_liab_transfer; + let mut socialized_loss = I80F48::ZERO; + let (starting_long_funding, starting_short_funding) = + (perp_market.long_funding, perp_market.short_funding); + if insurance_fund_exhausted && remaining_liab > 0 { + perp_market.socialize_loss(-remaining_liab)?; + liqee_perp_position.record_settle(-remaining_liab); + socialized_loss = remaining_liab; + } + + emit!(PerpLiqBankruptcyLog { + mango_group, + liqee: ctx.accounts.liqee.key(), + liqor: ctx.accounts.liqor.key(), + perp_market_index: perp_market.perp_market_index, + insurance_transfer: insurance_transfer_i80f48.to_bits(), + socialized_loss: socialized_loss.to_bits(), + starting_long_funding: starting_long_funding.to_bits(), + starting_short_funding: starting_short_funding.to_bits(), + ending_long_funding: perp_market.long_funding.to_bits(), + ending_short_funding: perp_market.short_funding.to_bits(), + }); + + insurance_transfer + } else { + 0 + }; + + // + // Log positions aftewards + // + if settlement > 0 || insurance_transfer > 0 { + let liqor_token_position = liqor.token_position(settle_token_index)?; + emit!(TokenBalanceLog { + mango_group, + mango_account: ctx.accounts.liqor.key(), + token_index: settle_token_index, + indexed_position: liqor_token_position.indexed_position.to_bits(), + deposit_index: settle_bank.deposit_index.to_bits(), + borrow_index: settle_bank.borrow_index.to_bits(), + }); + } + + if settlement > 0 { + let liqee_token_position = liqee.token_position(settle_token_index)?; + emit!(TokenBalanceLog { + mango_group, + mango_account: ctx.accounts.liqee.key(), + token_index: settle_token_index, + indexed_position: liqee_token_position.indexed_position.to_bits(), + deposit_index: settle_bank.deposit_index.to_bits(), + borrow_index: settle_bank.borrow_index.to_bits(), + }); + } + + let liqee_perp_position = liqee.perp_position(perp_market_index)?; + let liqor_perp_position = liqor.perp_position(perp_market_index)?; + emit_perp_balances( + mango_group, + ctx.accounts.liqor.key(), + liqor_perp_position, + &perp_market, + ); + emit_perp_balances( + mango_group, + ctx.accounts.liqee.key(), + liqee_perp_position, + &perp_market, + ); + + // Check liqee health again: bankruptcy would improve health + liqee_health_cache.recompute_perp_info(liqee_perp_position, &perp_market)?; + let liqee_init_health = liqee_health_cache.health(HealthType::Init); + liqee + .fixed + .maybe_recover_from_being_liquidated(liqee_init_health); + + drop(perp_market); + drop(settle_bank); + + // Check liqor's health + if !liqor.fixed.is_in_health_region() { + let account_retriever = + ScanningAccountRetriever::new(ctx.remaining_accounts, &mango_group)?; + let liqor_health = compute_health(&liqor.borrow(), HealthType::Init, &account_retriever) + .context("compute liqor health")?; + require!(liqor_health >= 0, MangoError::HealthMustBePositive); + } + + Ok(()) +} diff --git a/programs/mango-v4/src/instructions/perp_settle_fees.rs b/programs/mango-v4/src/instructions/perp_settle_fees.rs index cfe3b417d..6bd30abe5 100644 --- a/programs/mango-v4/src/instructions/perp_settle_fees.rs +++ b/programs/mango-v4/src/instructions/perp_settle_fees.rs @@ -91,7 +91,6 @@ pub fn perp_settle_fees(ctx: Context, max_settle_amount: u64) -> emit_perp_balances( ctx.accounts.group.key(), ctx.accounts.account.key(), - perp_market.perp_market_index, perp_position, &perp_market, ); diff --git a/programs/mango-v4/src/instructions/perp_settle_pnl.rs b/programs/mango-v4/src/instructions/perp_settle_pnl.rs index fe4d129eb..95731e37f 100644 --- a/programs/mango-v4/src/instructions/perp_settle_pnl.rs +++ b/programs/mango-v4/src/instructions/perp_settle_pnl.rs @@ -81,19 +81,12 @@ pub fn perp_settle_pnl(ctx: Context) -> Result<()> { a_maint_health = a_cache.health(HealthType::Maint); }; - // Account B is the one that must have negative pnl. Check how much of that may be actualized - // given the account's health. In that, we only care about the health of spot assets on the account. - // Example: With +100 USDC and -2 SOL (-80 USD) and -500 USD PNL the account may still settle - // 100 - 1.1*80 = 12 USD perp pnl, even though the overall health is already negative. - // Further settlement would convert perp-losses into token-losses and isn't allowed. - require!(b_settle_health >= 0, MangoError::HealthMustBePositive); - - let mut bank = ctx.accounts.settle_bank.load_mut()?; + let mut settle_bank = ctx.accounts.settle_bank.load_mut()?; let perp_market = ctx.accounts.perp_market.load()?; // Verify that the bank is the quote currency bank require!( - bank.token_index == settle_token_index, + settle_bank.token_index == settle_token_index, MangoError::InvalidBank ); @@ -103,20 +96,16 @@ pub fn perp_settle_pnl(ctx: Context) -> Result<()> { None, // staleness checked in health )?; - // Fetch perp positions for accounts + // Fetch perp position and pnl let a_perp_position = account_a.perp_position_mut(perp_market_index)?; let b_perp_position = account_b.perp_position_mut(perp_market_index)?; - - // Settle funding before settling any PnL a_perp_position.settle_funding(&perp_market); b_perp_position.settle_funding(&perp_market); - - // Calculate PnL for each account let a_pnl = a_perp_position.pnl_for_price(&perp_market, oracle_price)?; let b_pnl = b_perp_position.pnl_for_price(&perp_market, oracle_price)?; - // Account A must be profitable, and B must be unprofitable - // PnL must be opposite signs for there to be a settlement + // PnL must have opposite signs for there to be a settlement: + // Account A must be profitable, and B must be unprofitable. require_msg_typed!( a_pnl.is_positive(), MangoError::ProfitabilityMismatch, @@ -130,12 +119,11 @@ pub fn perp_settle_pnl(ctx: Context) -> Result<()> { b_pnl ); - // Cap settlement of unrealized pnl - // Settles at most x100% each hour + // Apply pnl settle limits let now_ts: u64 = Clock::get()?.unix_timestamp.try_into().unwrap(); a_perp_position.update_settle_limit(&perp_market, now_ts); - b_perp_position.update_settle_limit(&perp_market, now_ts); let a_settleable_pnl = a_perp_position.apply_pnl_settle_limit(&perp_market, a_pnl); + b_perp_position.update_settle_limit(&perp_market, now_ts); let b_settleable_pnl = b_perp_position.apply_pnl_settle_limit(&perp_market, b_pnl); require_msg_typed!( @@ -153,11 +141,23 @@ pub fn perp_settle_pnl(ctx: Context) -> Result<()> { b_pnl ); - // Settle for the maximum possible capped to b's settle health + // Check how much of account b's negative pnl may be actualized given the health. + // In that, we only care about the health of spot assets on the account. + // Example: With +100 USDC and -2 SOL (-80 USD) and -500 USD PNL the account may still settle + // 100 - 1.1*80 = 12 USD perp pnl, even though the overall health is already negative. + // Further settlement would convert perp-losses into unbacked token-losses and isn't allowed. + require_msg_typed!( + b_settle_health >= 0, + MangoError::HealthMustBePositive, + "account b settle health is negative: {}", + b_settle_health + ); + + // Settle for the maximum possible capped to target's settle health let settlement = a_settleable_pnl - .abs() - .min(b_settleable_pnl.abs()) - .min(b_settle_health); + .min(-b_settleable_pnl) + .min(b_settle_health) + .max(I80F48::ZERO); require_msg_typed!( settlement >= 0, MangoError::SettlementAmountMustBePositive, @@ -167,54 +167,28 @@ pub fn perp_settle_pnl(ctx: Context) -> Result<()> { b_settle_health, ); - // Settle + let fee = compute_settle_fee(&perp_market, a_init_health, a_maint_health, settlement)?; + a_perp_position.record_settle(settlement); b_perp_position.record_settle(-settlement); - emit_perp_balances( ctx.accounts.group.key(), ctx.accounts.account_a.key(), - perp_market.perp_market_index, a_perp_position, &perp_market, ); - emit_perp_balances( ctx.accounts.group.key(), ctx.accounts.account_b.key(), - perp_market.perp_market_index, b_perp_position, &perp_market, ); - // A percentage fee is paid to the settler when account_a's health is low. - // That's because the settlement could avoid it getting liquidated. - let low_health_fee = if a_init_health < 0 { - let fee_fraction = I80F48::from_num(perp_market.settle_fee_fraction_low_health); - if a_maint_health < 0 { - cm!(settlement * fee_fraction) - } else { - cm!(settlement * fee_fraction * (-a_init_health / (a_maint_health - a_init_health))) - } - } else { - I80F48::ZERO - }; - - // The settler receives a flat fee - let flat_fee = I80F48::from_num(perp_market.settle_fee_flat); - - // Fees only apply when the settlement is large enough - let fee = if settlement >= perp_market.settle_fee_amount_threshold { - cm!(low_health_fee + flat_fee).min(settlement) - } else { - I80F48::ZERO - }; - - // Safety check to prevent any accidental negative transfer - require!(fee >= 0, MangoError::SettlementAmountMustBePositive); - - // Update the account's net_settled with the new PnL. + // Update the accounts' perp_spot_transfer statistics. + // // Applying the fee here means that it decreases the displayed perp pnl. + // Think about it like this: a's pnl reduces by `settlement` and spot increases by `settlement - fee`. + // That means that it managed to extract `settlement - fee` from perp interactions. let settlement_i64 = settlement.round_to_zero().checked_to_num::().unwrap(); let fee_i64 = fee.round_to_zero().checked_to_num::().unwrap(); cm!(a_perp_position.perp_spot_transfers += settlement_i64 - fee_i64); @@ -222,34 +196,32 @@ pub fn perp_settle_pnl(ctx: Context) -> Result<()> { cm!(account_a.fixed.perp_spot_transfers += settlement_i64 - fee_i64); cm!(account_b.fixed.perp_spot_transfers -= settlement_i64); - let now_ts: u64 = Clock::get()?.unix_timestamp.try_into().unwrap(); - // Transfer token balances // The fee is paid by the account with positive unsettled pnl let a_token_position = account_a.token_position_mut(settle_token_index)?.0; let b_token_position = account_b.token_position_mut(settle_token_index)?.0; - bank.deposit(a_token_position, cm!(settlement - fee), now_ts)?; + settle_bank.deposit(a_token_position, cm!(settlement - fee), now_ts)?; // Don't charge loan origination fees on borrows created via settling: // Even small loan origination fees could accumulate if a perp position is // settled back and forth repeatedly. - bank.withdraw_without_fee(b_token_position, settlement, now_ts, oracle_price)?; + settle_bank.withdraw_without_fee(b_token_position, settlement, now_ts, oracle_price)?; emit!(TokenBalanceLog { mango_group: ctx.accounts.group.key(), - mango_account: ctx.accounts.settler.key(), + mango_account: ctx.accounts.account_a.key(), token_index: settle_token_index, indexed_position: a_token_position.indexed_position.to_bits(), - deposit_index: bank.deposit_index.to_bits(), - borrow_index: bank.borrow_index.to_bits(), + deposit_index: settle_bank.deposit_index.to_bits(), + borrow_index: settle_bank.borrow_index.to_bits(), }); emit!(TokenBalanceLog { mango_group: ctx.accounts.group.key(), - mango_account: ctx.accounts.settler.key(), + mango_account: ctx.accounts.account_b.key(), token_index: settle_token_index, indexed_position: b_token_position.indexed_position.to_bits(), - deposit_index: bank.deposit_index.to_bits(), - borrow_index: bank.borrow_index.to_bits(), + deposit_index: settle_bank.deposit_index.to_bits(), + borrow_index: settle_bank.borrow_index.to_bits(), }); // settler might be the same as account a or b @@ -267,15 +239,15 @@ pub fn perp_settle_pnl(ctx: Context) -> Result<()> { let (settler_token_position, settler_token_raw_index, _) = settler.ensure_token_position(settle_token_index)?; - let settler_token_position_active = bank.deposit(settler_token_position, fee, now_ts)?; + let settler_token_position_active = settle_bank.deposit(settler_token_position, fee, now_ts)?; emit!(TokenBalanceLog { mango_group: ctx.accounts.group.key(), mango_account: ctx.accounts.settler.key(), token_index: settler_token_position.token_index, indexed_position: settler_token_position.indexed_position.to_bits(), - deposit_index: bank.deposit_index.to_bits(), - borrow_index: bank.borrow_index.to_bits(), + deposit_index: settle_bank.deposit_index.to_bits(), + borrow_index: settle_bank.borrow_index.to_bits(), }); if !settler_token_position_active { @@ -296,3 +268,41 @@ pub fn perp_settle_pnl(ctx: Context) -> Result<()> { msg!("settled pnl = {}, fee = {}", settlement, fee); Ok(()) } + +pub fn compute_settle_fee( + perp_market: &PerpMarket, + source_init_health: I80F48, + source_maint_health: I80F48, + settlement: I80F48, +) -> Result { + // A percentage fee is paid to the settler when the source account's health is low. + // That's because the settlement could avoid it getting liquidated: settling will + // increase its health by actualizing positive perp pnl. + let low_health_fee = if source_init_health < 0 { + let fee_fraction = I80F48::from_num(perp_market.settle_fee_fraction_low_health); + if source_maint_health < 0 { + cm!(settlement * fee_fraction) + } else { + cm!(settlement + * fee_fraction + * (-source_init_health / (source_maint_health - source_init_health))) + } + } else { + I80F48::ZERO + }; + + // The settler receives a flat fee + let flat_fee = I80F48::from_num(perp_market.settle_fee_flat); + + // Fees only apply when the settlement is large enough + let fee = if settlement >= perp_market.settle_fee_amount_threshold { + cm!(low_health_fee + flat_fee).min(settlement) + } else { + I80F48::ZERO + }; + + // Safety check to prevent any accidental negative transfer + require!(fee >= 0, MangoError::SettlementAmountMustBePositive); + + Ok(fee) +} diff --git a/programs/mango-v4/src/instructions/token_liq_bankruptcy.rs b/programs/mango-v4/src/instructions/token_liq_bankruptcy.rs index eef154b9c..993b564af 100644 --- a/programs/mango-v4/src/instructions/token_liq_bankruptcy.rs +++ b/programs/mango-v4/src/instructions/token_liq_bankruptcy.rs @@ -89,17 +89,18 @@ pub fn token_liq_bankruptcy( .is_owner_or_delegate(ctx.accounts.liqor_owner.key()), MangoError::SomeError ); - require!(!liqor.fixed.being_liquidated(), MangoError::BeingLiquidated); + require_msg_typed!( + !liqor.fixed.being_liquidated(), + MangoError::BeingLiquidated, + "liqor account" + ); let mut account_retriever = ScanningAccountRetriever::new(health_ais, group_pk)?; let mut liqee = ctx.accounts.liqee.load_full_mut()?; let mut liqee_health_cache = new_health_cache(&liqee.borrow(), &account_retriever) .context("create liqee health cache")?; - require!( - !liqee_health_cache.has_liquidatable_assets(), - MangoError::IsNotBankrupt - ); + liqee_health_cache.require_after_phase2_liquidation()?; liqee.fixed.set_being_liquidated(true); let (liab_bank, liab_price, opt_quote_bank_and_price) = @@ -244,6 +245,7 @@ pub fn token_liq_bankruptcy( // Socialize loss if there's more loss and noone else could use the // insurance fund to cover it. let mut socialized_loss = I80F48::ZERO; + let starting_deposit_index = liab_deposit_index; if insurance_fund_exhausted && remaining_liab_loss.is_positive() { // find the total deposits let mut indexed_total_deposits = I80F48::ZERO; @@ -318,7 +320,9 @@ pub fn token_liq_bankruptcy( liab_price: liab_price.to_bits(), insurance_token_index: QUOTE_TOKEN_INDEX, insurance_transfer: insurance_transfer_i80f48.to_bits(), - socialized_loss: socialized_loss.to_bits() + socialized_loss: socialized_loss.to_bits(), + starting_liab_deposit_index: starting_deposit_index.to_bits(), + ending_liab_deposit_index: liab_deposit_index.to_bits() }); Ok(()) 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 f1c62d651..506d66114 100644 --- a/programs/mango-v4/src/instructions/token_liq_with_token.rs +++ b/programs/mango-v4/src/instructions/token_liq_with_token.rs @@ -50,7 +50,11 @@ pub fn token_liq_with_token( .is_owner_or_delegate(ctx.accounts.liqor_owner.key()), MangoError::SomeError ); - require!(!liqor.fixed.being_liquidated(), MangoError::BeingLiquidated); + require_msg_typed!( + !liqor.fixed.being_liquidated(), + MangoError::BeingLiquidated, + "liqor account" + ); let mut liqee = ctx.accounts.liqee.load_full_mut()?; @@ -58,6 +62,7 @@ pub fn token_liq_with_token( let mut liqee_health_cache = new_health_cache(&liqee.borrow(), &account_retriever) .context("create liqee health cache")?; let init_health = liqee_health_cache.health(HealthType::Init); + liqee_health_cache.require_after_phase1_liquidation()?; // Once maint_health falls below 0, we want to start liquidating, // we want to allow liquidation to continue until init_health is positive, diff --git a/programs/mango-v4/src/lib.rs b/programs/mango-v4/src/lib.rs index ae22b0e86..4105923fe 100644 --- a/programs/mango-v4/src/lib.rs +++ b/programs/mango-v4/src/lib.rs @@ -720,11 +720,11 @@ pub mod mango_v4 { instructions::perp_liq_force_cancel_orders(ctx, limit) } - pub fn perp_liq_bankruptcy( - ctx: Context, + pub fn perp_liq_quote_and_bankruptcy( + ctx: Context, max_liab_transfer: u64, ) -> Result<()> { - instructions::perp_liq_bankruptcy(ctx, max_liab_transfer) + instructions::perp_liq_quote_and_bankruptcy(ctx, max_liab_transfer) } pub fn alt_set(ctx: Context, index: u8) -> Result<()> { diff --git a/programs/mango-v4/src/logs.rs b/programs/mango-v4/src/logs.rs index 30cee3bfc..dadcd8ae4 100644 --- a/programs/mango-v4/src/logs.rs +++ b/programs/mango-v4/src/logs.rs @@ -8,14 +8,13 @@ use borsh::BorshSerialize; pub fn emit_perp_balances( mango_group: Pubkey, mango_account: Pubkey, - market_index: u16, pp: &PerpPosition, pm: &PerpMarket, ) { emit!(PerpBalanceLog { mango_group, mango_account, - market_index, + market_index: pm.perp_market_index, base_position: pp.base_position_lots(), quote_position: pp.quote_position_native().to_bits(), long_settled_funding: pp.long_settled_funding.to_bits(), @@ -212,6 +211,8 @@ pub struct TokenLiqBankruptcyLog { pub insurance_token_index: u16, pub insurance_transfer: i128, pub socialized_loss: i128, + pub starting_liab_deposit_index: i128, + pub ending_liab_deposit_index: i128, } #[event] @@ -286,6 +287,19 @@ pub struct PerpLiqBankruptcyLog { pub perp_market_index: u16, pub insurance_transfer: i128, pub socialized_loss: i128, + pub starting_long_funding: i128, + pub starting_short_funding: i128, + pub ending_long_funding: i128, + pub ending_short_funding: i128, +} + +#[event] +pub struct PerpLiqQuoteAndBankruptcyLog { + pub mango_group: Pubkey, + pub liqee: Pubkey, + pub liqor: Pubkey, + pub perp_market_index: u16, + pub settlement: i128, } #[event] diff --git a/programs/mango-v4/tests/program_test/mango_client.rs b/programs/mango-v4/tests/program_test/mango_client.rs index 446349579..ec3ac9bd8 100644 --- a/programs/mango-v4/tests/program_test/mango_client.rs +++ b/programs/mango-v4/tests/program_test/mango_client.rs @@ -269,7 +269,7 @@ async fn derive_liquidation_remaining_account_metas( let perp_markets: Vec = liqee .active_perp_positions() - .chain(liqee.active_perp_positions()) + .chain(liqor.active_perp_positions()) .map(|perp| get_perp_market_address_by_index(liqee.fixed.group, perp.market_index)) .unique() .collect(); @@ -339,6 +339,17 @@ pub async fn account_position_f64(solana: &SolanaCookie, account: Pubkey, bank: native.to_num::() } +pub async fn account_init_health(solana: &SolanaCookie, account: Pubkey) -> f64 { + send_tx(solana, ComputeAccountDataInstruction { account }) + .await + .unwrap(); + let health_data = solana + .program_log_events::() + .pop() + .unwrap(); + health_data.init_health.to_num::() +} + // Verifies that the "post_health: ..." log emitted by the previous instruction // matches the init health of the account. pub async fn check_prev_instruction_post_health(solana: &SolanaCookie, account: Pubkey) { @@ -1036,6 +1047,96 @@ impl ClientInstruction for TokenDeregisterInstruction { } } +fn token_edit_instruction_default() -> mango_v4::instruction::TokenEdit { + mango_v4::instruction::TokenEdit { + oracle_opt: None, + oracle_config_opt: None, + group_insurance_fund_opt: None, + interest_rate_params_opt: None, + loan_fee_rate_opt: None, + loan_origination_fee_rate_opt: None, + maint_asset_weight_opt: None, + init_asset_weight_opt: None, + maint_liab_weight_opt: None, + init_liab_weight_opt: None, + liquidation_fee_opt: None, + stable_price_delay_interval_seconds_opt: None, + stable_price_delay_growth_limit_opt: None, + stable_price_growth_limit_opt: None, + min_vault_to_deposits_ratio_opt: None, + net_borrow_limit_per_window_quote_opt: None, + net_borrow_limit_window_size_ts_opt: None, + borrow_weight_scale_start_quote_opt: None, + deposit_weight_scale_start_quote_opt: None, + reset_stable_price: false, + reset_net_borrow_limit: false, + reduce_only_opt: None, + } +} + +pub struct TokenEditWeights { + pub group: Pubkey, + pub admin: TestKeypair, + pub mint: Pubkey, + + pub maint_asset_weight: f32, + pub maint_liab_weight: f32, + pub init_asset_weight: f32, + pub init_liab_weight: f32, +} + +#[async_trait::async_trait(?Send)] +impl ClientInstruction for TokenEditWeights { + type Accounts = mango_v4::accounts::TokenEdit; + type Instruction = mango_v4::instruction::TokenEdit; + async fn to_instruction( + &self, + account_loader: impl ClientAccountLoader + 'async_trait, + ) -> (Self::Accounts, instruction::Instruction) { + let program_id = mango_v4::id(); + + let mint_info_key = Pubkey::find_program_address( + &[ + b"MintInfo".as_ref(), + self.group.as_ref(), + self.mint.as_ref(), + ], + &program_id, + ) + .0; + let mint_info: MintInfo = account_loader.load(&mint_info_key).await.unwrap(); + + let instruction = Self::Instruction { + init_asset_weight_opt: Some(self.init_asset_weight), + init_liab_weight_opt: Some(self.init_liab_weight), + maint_asset_weight_opt: Some(self.maint_asset_weight), + maint_liab_weight_opt: Some(self.maint_liab_weight), + ..token_edit_instruction_default() + }; + + let accounts = Self::Accounts { + group: self.group, + admin: self.admin.pubkey(), + mint_info: mint_info_key, + oracle: mint_info.oracle, + }; + + let mut instruction = make_instruction(program_id, &accounts, instruction); + instruction + .accounts + .extend(mint_info.banks().iter().map(|&k| AccountMeta { + pubkey: k, + is_signer: false, + is_writable: true, + })); + (accounts, instruction) + } + + fn signers(&self) -> Vec { + vec![self.admin] + } +} + pub struct TokenResetStablePriceModel { pub group: Pubkey, pub admin: TestKeypair, @@ -1064,28 +1165,9 @@ impl ClientInstruction for TokenResetStablePriceModel { let mint_info: MintInfo = account_loader.load(&mint_info_key).await.unwrap(); let instruction = Self::Instruction { - oracle_opt: None, - oracle_config_opt: None, - group_insurance_fund_opt: None, - interest_rate_params_opt: None, - loan_fee_rate_opt: None, - loan_origination_fee_rate_opt: None, - maint_asset_weight_opt: None, - init_asset_weight_opt: None, - maint_liab_weight_opt: None, - init_liab_weight_opt: None, - liquidation_fee_opt: None, - stable_price_delay_interval_seconds_opt: None, - stable_price_delay_growth_limit_opt: None, - stable_price_growth_limit_opt: None, - min_vault_to_deposits_ratio_opt: None, - net_borrow_limit_per_window_quote_opt: None, - net_borrow_limit_window_size_ts_opt: None, - borrow_weight_scale_start_quote_opt: None, - deposit_weight_scale_start_quote_opt: None, reset_stable_price: true, reset_net_borrow_limit: false, - reduce_only_opt: None, + ..token_edit_instruction_default() }; let accounts = Self::Accounts { @@ -1142,28 +1224,11 @@ impl ClientInstruction for TokenResetNetBorrows { let mint_info: MintInfo = account_loader.load(&mint_info_key).await.unwrap(); let instruction = Self::Instruction { - oracle_opt: None, - oracle_config_opt: None, - group_insurance_fund_opt: None, - interest_rate_params_opt: None, - loan_fee_rate_opt: None, - loan_origination_fee_rate_opt: None, - maint_asset_weight_opt: None, - init_asset_weight_opt: None, - maint_liab_weight_opt: None, - init_liab_weight_opt: None, - liquidation_fee_opt: None, - stable_price_delay_interval_seconds_opt: None, - stable_price_delay_growth_limit_opt: None, - stable_price_growth_limit_opt: None, min_vault_to_deposits_ratio_opt: self.min_vault_to_deposits_ratio_opt, net_borrow_limit_per_window_quote_opt: self.net_borrow_limit_per_window_quote_opt, net_borrow_limit_window_size_ts_opt: self.net_borrow_limit_window_size_ts_opt, - borrow_weight_scale_start_quote_opt: None, - deposit_weight_scale_start_quote_opt: None, - reset_stable_price: false, reset_net_borrow_limit: true, - reduce_only_opt: None, + ..token_edit_instruction_default() }; let accounts = Self::Accounts { @@ -1217,28 +1282,8 @@ impl ClientInstruction for TokenMakeReduceOnly { let mint_info: MintInfo = account_loader.load(&mint_info_key).await.unwrap(); let instruction = Self::Instruction { - oracle_opt: None, - oracle_config_opt: None, - group_insurance_fund_opt: None, - interest_rate_params_opt: None, - loan_fee_rate_opt: None, - loan_origination_fee_rate_opt: None, - maint_asset_weight_opt: None, - init_asset_weight_opt: None, - maint_liab_weight_opt: None, - init_liab_weight_opt: None, - liquidation_fee_opt: None, - stable_price_delay_interval_seconds_opt: None, - stable_price_delay_growth_limit_opt: None, - stable_price_growth_limit_opt: None, - min_vault_to_deposits_ratio_opt: None, - net_borrow_limit_per_window_quote_opt: None, - net_borrow_limit_window_size_ts_opt: None, - borrow_weight_scale_start_quote_opt: None, - deposit_weight_scale_start_quote_opt: None, - reset_stable_price: false, - reset_net_borrow_limit: false, reduce_only_opt: Some(true), + ..token_edit_instruction_default() }; let accounts = Self::Accounts { @@ -3361,7 +3406,7 @@ impl ClientInstruction for PerpLiqBasePositionInstruction { } } -pub struct PerpLiqBankruptcyInstruction { +pub struct PerpLiqQuoteAndBankruptcyInstruction { pub liqor: Pubkey, pub liqor_owner: TestKeypair, pub liqee: Pubkey, @@ -3369,9 +3414,9 @@ pub struct PerpLiqBankruptcyInstruction { pub max_liab_transfer: u64, } #[async_trait::async_trait(?Send)] -impl ClientInstruction for PerpLiqBankruptcyInstruction { - type Accounts = mango_v4::accounts::PerpLiqBankruptcy; - type Instruction = mango_v4::instruction::PerpLiqBankruptcy; +impl ClientInstruction for PerpLiqQuoteAndBankruptcyInstruction { + type Accounts = mango_v4::accounts::PerpLiqQuoteAndBankruptcy; + type Instruction = mango_v4::instruction::PerpLiqQuoteAndBankruptcy; async fn to_instruction( &self, account_loader: impl ClientAccountLoader + 'async_trait, @@ -3416,10 +3461,11 @@ impl ClientInstruction for PerpLiqBankruptcyInstruction { let accounts = Self::Accounts { group: group_key, - perp_market: self.perp_market, liqor: self.liqor, liqor_owner: self.liqor_owner.pubkey(), liqee: self.liqee, + perp_market: self.perp_market, + oracle: perp_market.oracle, settle_bank: quote_mint_info.first_bank(), settle_vault: quote_mint_info.first_vault(), settle_oracle: quote_mint_info.oracle, diff --git a/programs/mango-v4/tests/test_basic.rs b/programs/mango-v4/tests/test_basic.rs index 2c59bc8de..0fbfffb93 100644 --- a/programs/mango-v4/tests/test_basic.rs +++ b/programs/mango-v4/tests/test_basic.rs @@ -136,14 +136,7 @@ async fn test_basic() -> Result<(), TransportError> { // // TEST: Compute the account health // - send_tx(solana, ComputeAccountDataInstruction { account }) - .await - .unwrap(); - let health_data = solana - .program_log_events::() - .pop() - .unwrap(); - assert_eq!(health_data.init_health.to_num::(), 60); + assert_eq!(account_init_health(solana, account).await.round(), 60.0); // // TEST: Withdraw funds diff --git a/programs/mango-v4/tests/test_liq_perps.rs b/programs/mango-v4/tests/test_liq_perps.rs index 00e945c3f..64965be07 100644 --- a/programs/mango-v4/tests/test_liq_perps.rs +++ b/programs/mango-v4/tests/test_liq_perps.rs @@ -1,10 +1,11 @@ #![cfg(feature = "test-bpf")] +use anchor_lang::prelude::Pubkey; use fixed::types::I80F48; use solana_program_test::*; use solana_sdk::transport::TransportError; -use mango_v4::state::*; +use mango_v4::state::{PerpMarketIndex, *}; use program_test::*; use mango_setup::*; @@ -180,7 +181,8 @@ async fn test_liq_perps_force_cancel() -> Result<(), TransportError> { #[tokio::test] async fn test_liq_perps_base_position_and_bankruptcy() -> Result<(), TransportError> { - let test_builder = TestContextBuilder::new(); + let mut test_builder = TestContextBuilder::new(); + test_builder.test().set_compute_max_units(100_000); // PerpLiqQuoteAndBankruptcy takes a lot of CU let context = test_builder.start_default().await; let solana = &context.solana.clone(); @@ -306,10 +308,6 @@ async fn test_liq_perps_base_position_and_bankruptcy() -> Result<(), TransportEr // // SETUP: Trade perps between accounts // - // health was 1000 + 1 * 0.8 = 1000.8 before - // after this order it is changed by -20*100*(1.4-1) = -800 for the short - // and 20*100*(0.6-1) = -800 for the long - // send_tx( solana, PerpPlaceOrderInstruction { @@ -352,25 +350,29 @@ async fn test_liq_perps_base_position_and_bankruptcy() -> Result<(), TransportEr .await .unwrap(); + // health was 1000 before; + // after this order exchange it is changed by + // 20*100*(0.6-1) = -800 for the long account0 + // 20*100*(1-1.4) = -800 for the short account1 + // (100 is base lot size) + assert_eq!( + account_init_health(solana, account_0).await.round(), + 1000.0 - 800.0 + ); + assert_eq!( + account_init_health(solana, account_1).await.round(), + 1000.0 - 800.0 + ); + // // SETUP: Change the oracle to make health go negative for account_0 + // perp base value decreases from 2000 * 0.6 to 2000 * 0.6 * 0.6, i.e. -480 // set_bank_stub_oracle_price(solana, group, base_token, admin, 0.6).await; - - // verify health is bad: can't withdraw - assert!(send_tx( - solana, - TokenWithdrawInstruction { - amount: 1, - allow_borrow: false, - account: account_0, - owner, - token_account: payer_mint_accounts[0], - bank_index: 0, - } - ) - .await - .is_err()); + assert_eq!( + account_init_health(solana, account_0).await.round(), + 200.0 - 480.0 + ); // // TEST: Liquidate base position with limit @@ -403,6 +405,13 @@ async fn test_liq_perps_base_position_and_bankruptcy() -> Result<(), TransportEr -20.0 * 100.0 + liq_amount, 0.1 )); + assert!(assert_equal( + liqee_data.perps[0].realized_trade_pnl_native, + liq_amount - 1000.0, + 0.1 + )); + // stable price is 1.0, so 0.2 * 1000 + assert_eq!(liqee_data.perps[0].settle_pnl_limit_realized_trade, -201); // // TEST: Liquidate base position max @@ -587,22 +596,6 @@ async fn test_liq_perps_base_position_and_bankruptcy() -> Result<(), TransportEr 0.1 )); - // - // TEST: Can't trigger perp bankruptcy yet, account_1 isn't bankrupt - // - assert!(send_tx( - solana, - PerpLiqBankruptcyInstruction { - liqor, - liqor_owner: owner, - liqee: account_1, - perp_market, - max_liab_transfer: u64::MAX, - } - ) - .await - .is_err()); - // // SETUP: We want pnl settling to cause a negative quote position, // thus we deposit some base token collateral. To be able to do that, @@ -671,56 +664,17 @@ async fn test_liq_perps_base_position_and_bankruptcy() -> Result<(), TransportEr 1 ); - /* - Perp liquidation / bankruptcy tests temporarily disabled until further PRs have gone in. - // - // TEST: Still can't trigger perp bankruptcy, account_1 has token collateral left - // - assert!(send_tx( - solana, - PerpLiqBankruptcyInstruction { - liqor, - liqor_owner: owner, - liqee: account_1, - perp_market, - max_liab_transfer: u64::MAX, - } - ) - .await - .is_err()); - - // - // SETUP: Liquidate token collateral + // TEST: Can liquidate/bankruptcy away remaining negative pnl // + let liqee_before = solana.get_account::(account_1).await; + let liqor_before = solana.get_account::(liqor).await; + let liqee_settle_limit_before = liqee_before.perps[0] + .available_settle_limit(&perp_market_data) + .0; send_tx( solana, - TokenLiqWithTokenInstruction { - liqee: account_1, - liqor: liqor, - liqor_owner: owner, - asset_token_index: base_token.index, - liab_token_index: quote_token.index, - max_liab_transfer: I80F48::MAX, - asset_bank_index: 0, - liab_bank_index: 0, - }, - ) - .await - .unwrap(); - assert_eq!( - account_position(solana, account_1, quote_token.bank).await, - 0 - ); - assert!(account_position_closed(solana, account_1, base_token.bank).await); - - // - // TEST: Now perp-bankruptcy will work, eat the insurance vault and socialize losses - // - let liqor_before = account_position_f64(solana, liqor, quote_token.bank).await; - send_tx( - solana, - PerpLiqBankruptcyInstruction { + PerpLiqQuoteAndBankruptcyInstruction { liqor, liqor_owner: owner, liqee: account_1, @@ -730,29 +684,47 @@ async fn test_liq_perps_base_position_and_bankruptcy() -> Result<(), TransportEr ) .await .unwrap(); + let liqee_after = solana.get_account::(account_1).await; + let liqor_data = solana.get_account::(liqor).await; + let quote_bank = solana.get_account::(tokens[0].bank).await; + + // the amount of spot the liqor received: full insurance fund, plus what was still settleable + let liq_spot_amount = insurance_vault_funding as f64 + (-liqee_settle_limit_before) as f64; + // the amount of perp quote transfered + let liq_perp_quote_amount = + (insurance_vault_funding as f64) / 1.05 + (-liqee_settle_limit_before) as f64; // insurance fund was depleted and the liqor received it assert_eq!(solana.token_account_balance(insurance_vault).await, 0); - let liqor_data = solana.get_account::(liqor).await; - let quote_bank = solana.get_account::(tokens[0].bank).await; assert!(assert_equal( liqor_data.tokens[0].native("e_bank), - liqor_before + insurance_vault_funding as f64, + liqor_before.tokens[0].native("e_bank).to_num::() + liq_spot_amount, 0.1 )); - // liqee's position is gone - let liqee_data = solana.get_account::(account_1).await; - assert_eq!(liqee_data.perps[0].base_position_lots(), 0); + // liqor took over the max possible negative pnl assert!(assert_equal( - liqee_data.perps[0].quote_position_native(), - 0.0, + liqor_data.perps[0].quote_position_native(), + liqor_before.perps[0] + .quote_position_native() + .to_num::() + - liq_perp_quote_amount, 0.1 )); + // liqee exited liquidation + assert!(account_init_health(solana, account_1).await >= 0.0); + assert_eq!(liqee_after.being_liquidated, 0); + // the remainder got socialized via funding payments - let socialized_amount = -remaining_pnl - 100.0 / 1.05; let perp_market = solana.get_account::(perp_market).await; + let pnl_before = liqee_before.perps[0] + .pnl_for_price(&perp_market, I80F48::ONE) + .unwrap(); + let pnl_after = liqee_after.perps[0] + .pnl_for_price(&perp_market, I80F48::ONE) + .unwrap(); + let socialized_amount = (pnl_after - pnl_before).to_num::() - liq_perp_quote_amount; assert!(assert_equal( perp_market.long_funding, socialized_amount / 20.0, @@ -763,7 +735,466 @@ async fn test_liq_perps_base_position_and_bankruptcy() -> Result<(), TransportEr -socialized_amount / 20.0, 0.1 )); - */ + + Ok(()) +} + +#[tokio::test] +async fn test_liq_perps_bankruptcy() -> Result<(), TransportError> { + let mut test_builder = TestContextBuilder::new(); + test_builder.test().set_compute_max_units(200_000); // PerpLiqQuoteAndBankruptcy takes a lot of CU + 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_mint_accounts = &context.users[1].token_accounts[0..3]; + + // + // SETUP: Create a group and an account to fill the vaults + // + + let GroupWithTokens { + group, + tokens, + insurance_vault, + .. + } = GroupWithTokensConfig { + admin, + payer, + mints: mints.to_vec(), + zero_token_is_quote: true, + ..GroupWithTokensConfig::default() + } + .create(solana) + .await; + + send_tx( + solana, + TokenEditWeights { + group, + admin, + mint: mints[2].pubkey, + maint_liab_weight: 1.0, + maint_asset_weight: 1.0, + init_liab_weight: 1.0, + init_asset_weight: 1.0, + }, + ) + .await + .unwrap(); + + let fund_insurance = |amount: u64| async move { + let mut tx = ClientTransaction::new(solana); + tx.add_instruction_direct( + spl_token::instruction::transfer( + &spl_token::ID, + &payer_mint_accounts[0], + &insurance_vault, + &payer.pubkey(), + &[&payer.pubkey()], + amount, + ) + .unwrap(), + ); + tx.add_signer(payer); + tx.send().await.unwrap(); + }; + + let quote_token = &tokens[0]; // USDC, 1/1 weights, price 1, never changed + let base_token = &tokens[1]; // used for perp market + let collateral_token = &tokens[2]; // used for adjusting account health + + // deposit some funds, to the vaults aren't empty + let liqor = create_funded_account( + &solana, + group, + owner, + 250, + &context.users[1], + mints, + 10000, + 0, + ) + .await; + + // all perp markets used here default to price = 1.0, base_lot_size = 100 + let price_lots = 100; + + let context_ref = &context; + let mut perp_market_index: PerpMarketIndex = 0; + let setup_perp_inner = |perp_market_index: PerpMarketIndex, + health: i64, + pnl: i64, + settle_limit: i64| async move { + // price used later to produce negative pnl with a short: + // doubling the price leads to -100 pnl + let adj_price = 1.0 + pnl as f64 / -100.0; + let adj_price_lots = (price_lots as f64 * adj_price) as i64; + + let mango_v4::accounts::PerpCreateMarket { perp_market, .. } = send_tx( + solana, + PerpCreateMarketInstruction { + group, + admin, + payer, + perp_market_index, + quote_lot_size: 1, + base_lot_size: 100, + maint_asset_weight: 0.8, + init_asset_weight: 0.6, + maint_liab_weight: 1.2, + init_liab_weight: 1.4, + liquidation_fee: 0.05, + maker_fee: 0.0, + taker_fee: 0.0, + group_insurance_fund: true, + // adjust this factur such that we get the desired settle limit in the end + settle_pnl_limit_factor: (settle_limit as f32 + 0.1).min(0.0) + / (-1.0 * 100.0 * adj_price) as f32, + settle_pnl_limit_window_size_ts: 24 * 60 * 60, + ..PerpCreateMarketInstruction::with_new_book_and_queue(&solana, base_token).await + }, + ) + .await + .unwrap(); + set_perp_stub_oracle_price(solana, group, perp_market, &base_token, admin, 1.0).await; + set_bank_stub_oracle_price(solana, group, &collateral_token, admin, 1.0).await; + + // + // SETUP: accounts + // + let deposit_amount = 1000; + let helper_account = create_funded_account( + &solana, + group, + owner, + perp_market_index as u32 * 2, + &context_ref.users[1], + &mints[2..3], + deposit_amount, + 0, + ) + .await; + let account = create_funded_account( + &solana, + group, + owner, + perp_market_index as u32 * 2 + 1, + &context_ref.users[1], + &mints[2..3], + deposit_amount, + 0, + ) + .await; + + // + // SETUP: Trade perps between accounts twice to generate pnl, settle_limit + // + let mut tx = ClientTransaction::new(solana); + tx.add_instruction(PerpPlaceOrderInstruction { + account: helper_account, + perp_market, + owner, + side: Side::Bid, + price_lots, + max_base_lots: 1, + max_quote_lots: i64::MAX, + client_order_id: 0, + reduce_only: false, + }) + .await; + tx.add_instruction(PerpPlaceOrderInstruction { + account: account, + perp_market, + owner, + side: Side::Ask, + price_lots, + max_base_lots: 1, + max_quote_lots: i64::MAX, + client_order_id: 0, + reduce_only: false, + }) + .await; + tx.add_instruction(PerpConsumeEventsInstruction { + perp_market, + mango_accounts: vec![account, helper_account], + }) + .await; + tx.send().await.unwrap(); + + set_perp_stub_oracle_price(solana, group, perp_market, &base_token, admin, adj_price).await; + let mut tx = ClientTransaction::new(solana); + tx.add_instruction(PerpPlaceOrderInstruction { + account: helper_account, + perp_market, + owner, + side: Side::Ask, + price_lots: adj_price_lots, + max_base_lots: 1, + max_quote_lots: i64::MAX, + client_order_id: 0, + reduce_only: false, + }) + .await; + tx.add_instruction(PerpPlaceOrderInstruction { + account: account, + perp_market, + owner, + side: Side::Bid, + price_lots: adj_price_lots, + max_base_lots: 1, + max_quote_lots: i64::MAX, + client_order_id: 0, + reduce_only: false, + }) + .await; + tx.add_instruction(PerpConsumeEventsInstruction { + perp_market, + mango_accounts: vec![account, helper_account], + }) + .await; + tx.send().await.unwrap(); + + set_perp_stub_oracle_price(solana, group, perp_market, &base_token, admin, 1.0).await; + + // Adjust target health: + // full health = 1000 * collat price * 1.0 + pnl + set_bank_stub_oracle_price( + solana, + group, + &collateral_token, + admin, + (health - pnl) as f64 / 1000.0, + ) + .await; + + // Verify we got it right + let account_data = solana.get_account::(account).await; + assert_eq!(account_data.perps[0].quote_position_native(), pnl); + assert_eq!( + account_data.perps[0].settle_pnl_limit_realized_trade, + settle_limit + ); + assert_eq!( + account_init_health(solana, account).await.round(), + health as f64 + ); + + (perp_market, account) + }; + let mut setup_perp = |health: i64, pnl: i64, settle_limit: i64| { + let out = setup_perp_inner(perp_market_index, health, pnl, settle_limit); + perp_market_index += 1; + out + }; + + let limit_prec = |f: f64| (f * 1000.0).round() / 1000.0; + + let liq_event_amounts = || { + let settlement = solana + .program_log_events::() + .pop() + .map(|v| limit_prec(I80F48::from_bits(v.settlement).to_num::())) + .unwrap_or(0.0); + let (insur, loss) = solana + .program_log_events::() + .pop() + .map(|v| { + ( + I80F48::from_bits(v.insurance_transfer).to_num::(), + limit_prec(I80F48::from_bits(v.socialized_loss).to_num::()), + ) + }) + .unwrap_or((0, 0.0)); + (settlement, insur, loss) + }; + + let liqor_info = |perp_market: Pubkey| async move { + let perp_market = solana.get_account::(perp_market).await; + let liqor_data = solana.get_account::(liqor).await; + let liqor_perp = liqor_data + .perps + .iter() + .find(|p| p.market_index == perp_market.perp_market_index) + .unwrap() + .clone(); + (liqor_data, liqor_perp) + }; + + { + let (perp_market, account) = setup_perp(-28, -50, -10).await; + let liqor_quote_before = account_position(solana, liqor, quote_token.bank).await; + + send_tx( + solana, + PerpLiqQuoteAndBankruptcyInstruction { + liqor, + liqor_owner: owner, + liqee: account, + perp_market, + max_liab_transfer: 1, + }, + ) + .await + .unwrap(); + assert_eq!(liq_event_amounts(), (1.0, 0, 0.0)); + + assert_eq!( + account_position(solana, account, quote_token.bank).await, + -1 + ); + assert_eq!( + account_position(solana, liqor, quote_token.bank).await, + liqor_quote_before + 1 + ); + let acc_data = solana.get_account::(account).await; + assert_eq!(acc_data.perps[0].quote_position_native(), -49); + assert_eq!(acc_data.being_liquidated, 1); + let (_liqor_data, liqor_perp) = liqor_info(perp_market).await; + assert_eq!(liqor_perp.quote_position_native(), -1); + } + + { + let (perp_market, account) = setup_perp(-28, -50, -10).await; + fund_insurance(2).await; + let liqor_quote_before = account_position(solana, liqor, quote_token.bank).await; + + send_tx( + solana, + PerpLiqQuoteAndBankruptcyInstruction { + liqor, + liqor_owner: owner, + liqee: account, + perp_market, + max_liab_transfer: 11, + }, + ) + .await + .unwrap(); + assert_eq!(liq_event_amounts(), (10.0, 2, 27.0)); + + assert_eq!( + account_position(solana, account, quote_token.bank).await, + -10 + ); + assert_eq!( + account_position(solana, liqor, quote_token.bank).await, + liqor_quote_before + 12 + ); + let acc_data = solana.get_account::(account).await; + assert!(assert_equal( + acc_data.perps[0].quote_position_native(), + -50.0 + 11.0 + 27.0, + 0.1 + )); + assert_eq!(acc_data.being_liquidated, 0); + let (_liqor_data, liqor_perp) = liqor_info(perp_market).await; + assert_eq!(liqor_perp.quote_position_native(), -11); + } + + { + let (perp_market, account) = setup_perp(-28, -50, -10).await; + fund_insurance(5).await; + + send_tx( + solana, + PerpLiqQuoteAndBankruptcyInstruction { + liqor, + liqor_owner: owner, + liqee: account, + perp_market, + max_liab_transfer: 16, + }, + ) + .await + .unwrap(); + assert_eq!( + liq_event_amounts(), + (10.0, 5, limit_prec(28.0 - 5.0 / 1.05)) + ); + } + + // no insurance + { + let (perp_market, account) = setup_perp(-28, -50, -10).await; + + send_tx( + solana, + PerpLiqQuoteAndBankruptcyInstruction { + liqor, + liqor_owner: owner, + liqee: account, + perp_market, + max_liab_transfer: u64::MAX, + }, + ) + .await + .unwrap(); + assert_eq!(liq_event_amounts(), (10.0, 0, limit_prec(28.0))); + } + + // no settlement: no settle health + { + let (perp_market, account) = setup_perp(-200, -50, -10).await; + fund_insurance(5).await; + + send_tx( + solana, + PerpLiqQuoteAndBankruptcyInstruction { + liqor, + liqor_owner: owner, + liqee: account, + perp_market, + max_liab_transfer: u64::MAX, + }, + ) + .await + .unwrap(); + assert_eq!(liq_event_amounts(), (0.0, 5, limit_prec(50.0 - 5.0 / 1.05))); + } + + // no settlement: no settle limit + { + let (perp_market, account) = setup_perp(-40, -50, 0).await; + // no insurance + + send_tx( + solana, + PerpLiqQuoteAndBankruptcyInstruction { + liqor, + liqor_owner: owner, + liqee: account, + perp_market, + max_liab_transfer: u64::MAX, + }, + ) + .await + .unwrap(); + assert_eq!(liq_event_amounts(), (0.0, 0, limit_prec(40.0))); + } + + // no socialized loss: fully covered by insurance fund + { + let (perp_market, account) = setup_perp(-40, -50, -5).await; + fund_insurance(42).await; + + send_tx( + solana, + PerpLiqQuoteAndBankruptcyInstruction { + liqor, + liqor_owner: owner, + liqee: account, + perp_market, + max_liab_transfer: u64::MAX, + }, + ) + .await + .unwrap(); + assert_eq!(liq_event_amounts(), (5.0, 42, 0.0)); + } Ok(()) }