From 57e1d981aca7dcfabe4aad7937b1e5474d8d6455 Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Thu, 15 Jun 2023 10:44:11 +0200 Subject: [PATCH] Health: Allow actions while init health does not decrease (#592) Instead of requiring a strict increase when init health < 0. This allows users to still place reducing limit orders on the spot and perp orderbooks as long as these orders keep the health unchanged. --- programs/mango-v4/src/error.rs | 4 +- programs/mango-v4/src/state/mango_account.rs | 19 +- programs/mango-v4/tests/cases/test_perp.rs | 178 +++++++++++++++++++ programs/mango-v4/tests/cases/test_serum.rs | 59 ++++++ 4 files changed, 257 insertions(+), 3 deletions(-) diff --git a/programs/mango-v4/src/error.rs b/programs/mango-v4/src/error.rs index 901a5117b..ee5a93fa5 100644 --- a/programs/mango-v4/src/error.rs +++ b/programs/mango-v4/src/error.rs @@ -19,8 +19,8 @@ pub enum MangoError { InvalidFlashLoanTargetCpiProgram, #[msg("health must be positive")] HealthMustBePositive, - #[msg("health must be positive or increase")] - HealthMustBePositiveOrIncrease, + #[msg("health must be positive or not decrease")] + HealthMustBePositiveOrIncrease, // outdated name is kept for backwards compatibility #[msg("health must be negative")] HealthMustBeNegative, #[msg("the account is bankrupt")] diff --git a/programs/mango-v4/src/state/mango_account.rs b/programs/mango-v4/src/state/mango_account.rs index 79f8aa4c6..5846132d3 100644 --- a/programs/mango-v4/src/state/mango_account.rs +++ b/programs/mango-v4/src/state/mango_account.rs @@ -1036,8 +1036,25 @@ impl< ) -> Result<()> { let post_init_health = health_cache.health(HealthType::Init); msg!("post_init_health: {}", post_init_health); + + // 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 + // $1e-6. This is safe because the grace amount is way less than the cost of a transaction. + // And worst case, users can only use this to gradually drive their own account into + // liquidation. + // There is an exception for accounts with health between $0 and -$0.001 (-1000 native), + // because we don't want to allow empty accounts or accounts with extremely tiny deposits + // to immediately drive themselves into bankruptcy. (accounts with large deposits can also + // be in this health range, but it's really unlikely) + let health_does_not_decrease = if post_init_health < -1000 { + post_init_health.ceil() >= pre_init_health.ceil() + } else { + post_init_health >= pre_init_health + }; + require!( - post_init_health >= 0 || post_init_health > pre_init_health, + post_init_health >= 0 || health_does_not_decrease, MangoError::HealthMustBePositiveOrIncrease ); Ok(()) diff --git a/programs/mango-v4/tests/cases/test_perp.rs b/programs/mango-v4/tests/cases/test_perp.rs index 661f8f8f9..25855875c 100644 --- a/programs/mango-v4/tests/cases/test_perp.rs +++ b/programs/mango-v4/tests/cases/test_perp.rs @@ -1035,6 +1035,184 @@ async fn test_perp_realize_partially() -> Result<(), TransportError> { Ok(()) } +#[tokio::test] +async fn test_perp_reducing_when_liquidatable() -> Result<(), TransportError> { + let context = TestContext::new().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..2]; + + // + // SETUP: Create a group and an account + // + + let GroupWithTokens { group, tokens, .. } = GroupWithTokensConfig { + admin, + payer, + mints: mints.to_vec(), + ..GroupWithTokensConfig::default() + } + .create(solana) + .await; + + let deposit_amount = 100000; + let account_0 = create_funded_account( + &solana, + group, + owner, + 0, + &context.users[1], + &mints[0..1], + deposit_amount, + 0, + ) + .await; + let account_1 = create_funded_account( + &solana, + group, + owner, + 1, + &context.users[1], + &mints[0..1], + deposit_amount, + 0, + ) + .await; + + // + // TEST: Create a perp market + // + let mango_v4::accounts::PerpCreateMarket { perp_market, .. } = send_tx( + solana, + PerpCreateMarketInstruction { + group, + admin, + payer, + perp_market_index: 0, + quote_lot_size: 10, + base_lot_size: 100, + maint_base_asset_weight: 0.975, + init_base_asset_weight: 0.95, + maint_base_liab_weight: 1.025, + init_base_liab_weight: 1.05, + base_liquidation_fee: 0.012, + maker_fee: 0.0000, + taker_fee: 0.0000, + settle_pnl_limit_factor: -1.0, + settle_pnl_limit_window_size_ts: 24 * 60 * 60, + ..PerpCreateMarketInstruction::with_new_book_and_queue(&solana, &tokens[1]).await + }, + ) + .await + .unwrap(); + + let perp_market_data = solana.get_account::(perp_market).await; + let price_lots = perp_market_data.native_price_to_lot(I80F48::from(1000)); + set_perp_stub_oracle_price(solana, group, perp_market, &tokens[1], admin, 1000.0).await; + + // + // SETUP: Place a bid, corresponding ask, and consume event + // + send_tx( + solana, + PerpPlaceOrderInstruction { + account: account_0, + perp_market, + owner, + side: Side::Bid, + price_lots, + max_base_lots: 2, + client_order_id: 5, + ..PerpPlaceOrderInstruction::default() + }, + ) + .await + .unwrap(); + + send_tx( + solana, + PerpPlaceOrderInstruction { + account: account_1, + perp_market, + owner, + side: Side::Ask, + price_lots, + max_base_lots: 2, + client_order_id: 6, + ..PerpPlaceOrderInstruction::default() + }, + ) + .await + .unwrap(); + + send_tx( + solana, + PerpConsumeEventsInstruction { + perp_market, + mango_accounts: vec![account_0, account_1], + }, + ) + .await + .unwrap(); + + let mango_account_0 = solana.get_account::(account_0).await; + let perp_0 = mango_account_0.perps[0]; + assert_eq!(perp_0.base_position_lots(), 2); + + let mango_account_1 = solana.get_account::(account_1).await; + let perp_1 = mango_account_1.perps[0]; + assert_eq!(perp_1.base_position_lots(), -2); + + // + // SETUP: Change the price to make the SHORT account liquidatable + // + set_perp_stub_oracle_price(solana, group, perp_market, &tokens[1], admin, 4000.0).await; + assert!(account_init_health(solana, account_1).await < 0.0); + + // + // TEST: Can place an order that reduces the position anyway + // + send_tx( + solana, + PerpPlaceOrderInstruction { + account: account_1, + perp_market, + owner, + side: Side::Bid, + price_lots: perp_market_data.native_price_to_lot(I80F48::from_num(4000)), + max_base_lots: 1, + client_order_id: 5, + ..PerpPlaceOrderInstruction::default() + }, + ) + .await + .unwrap(); + + // + // TEST: Can NOT place an order that goes too far + // + let err = send_tx( + solana, + PerpPlaceOrderInstruction { + account: account_1, + perp_market, + owner, + side: Side::Bid, + price_lots: perp_market_data.native_price_to_lot(I80F48::from_num(4000)), + max_base_lots: 5, + client_order_id: 5, + ..PerpPlaceOrderInstruction::default() + }, + ) + .await; + assert!(err.is_err()); + + Ok(()) +} + async fn assert_no_perp_orders(solana: &SolanaCookie, account_0: Pubkey) { let mango_account_0 = solana.get_account::(account_0).await; diff --git a/programs/mango-v4/tests/cases/test_serum.rs b/programs/mango-v4/tests/cases/test_serum.rs index 12e433108..5ce10366a 100644 --- a/programs/mango-v4/tests/cases/test_serum.rs +++ b/programs/mango-v4/tests/cases/test_serum.rs @@ -1002,6 +1002,65 @@ async fn test_serum_reduce_only_deposits2() -> Result<(), TransportError> { Ok(()) } +#[tokio::test] +async fn test_serum_place_reducing_when_liquidatable() -> Result<(), TransportError> { + let mut test_builder = TestContextBuilder::new(); + test_builder.test().set_compute_max_units(150_000); // Serum3PlaceOrder needs lots + let context = test_builder.start_default().await; + let solana = &context.solana.clone(); + + // + // SETUP: Create a group, accounts, market etc + // + let deposit_amount = 1000; + let CommonSetup { + group_with_tokens, + base_token, + mut order_placer, + .. + } = common_setup(&context, deposit_amount).await; + + // Give account some base token borrows (-500) + send_tx( + solana, + TokenWithdrawInstruction { + amount: 1500, + allow_borrow: true, + account: order_placer.account, + owner: order_placer.owner, + token_account: context.users[0].token_accounts[1], + bank_index: 0, + }, + ) + .await + .unwrap(); + + // Change the base price to make the account liquidatable + set_bank_stub_oracle_price( + solana, + group_with_tokens.group, + &base_token, + group_with_tokens.admin, + 10.0, + ) + .await; + + assert!(account_init_health(solana, order_placer.account).await < 0.0); + + // can place an order that would close some of the borrows + order_placer.try_bid(10.0, 200, false).await.unwrap(); + + // if too much base is bought, health would decrease: forbidden + let err = order_placer.try_bid(10.0, 800, false).await; + assert_mango_error( + &err, + MangoError::HealthMustBePositiveOrIncrease.into(), + "".into(), + ); + + Ok(()) +} + struct CommonSetup { group_with_tokens: GroupWithTokens, serum_market_cookie: SpotMarketCookie,