From 97eed4081dfb841078c9a4f5de1471f2b71eb9ac Mon Sep 17 00:00:00 2001 From: Conj0iner Date: Fri, 2 Sep 2022 00:07:57 +0800 Subject: [PATCH] Added perp_settle_pnl instruction --- programs/mango-v4/src/error.rs | 10 + programs/mango-v4/src/instructions/mod.rs | 2 + .../src/instructions/perp_settle_pnl.rs | 124 +++ programs/mango-v4/src/lib.rs | 5 +- programs/mango-v4/src/state/mango_account.rs | 34 +- .../src/state/mango_account_components.rs | 5 + .../tests/program_test/mango_client.rs | 55 ++ programs/mango-v4/tests/test_perp_settle.rs | 783 ++++++++++++++++++ 8 files changed, 1006 insertions(+), 12 deletions(-) create mode 100644 programs/mango-v4/src/instructions/perp_settle_pnl.rs create mode 100644 programs/mango-v4/tests/test_perp_settle.rs diff --git a/programs/mango-v4/src/error.rs b/programs/mango-v4/src/error.rs index 84c1d55d6..d7e633045 100644 --- a/programs/mango-v4/src/error.rs +++ b/programs/mango-v4/src/error.rs @@ -39,6 +39,16 @@ pub enum MangoError { InsufficentBankVaultFunds, #[msg("account is currently being liquidated")] BeingLiquidated, + #[msg("invalid bank")] + InvalidBank, + #[msg("account profitability is mismatched")] + ProfitabilityMismatch, + #[msg("cannot settle with self")] + CannotSettleWithSelf, + #[msg("perp position does not exist")] + PerpPositionDoesNotExist, + #[msg("max settle amount must be greater than zero")] + MaxSettleAmountMustBeGreaterThanZero, } pub trait Contextable { diff --git a/programs/mango-v4/src/instructions/mod.rs b/programs/mango-v4/src/instructions/mod.rs index 03283ea81..7b3d7d18d 100644 --- a/programs/mango-v4/src/instructions/mod.rs +++ b/programs/mango-v4/src/instructions/mod.rs @@ -20,6 +20,7 @@ pub use perp_consume_events::*; pub use perp_create_market::*; pub use perp_edit_market::*; pub use perp_place_order::*; +pub use perp_settle_pnl::*; pub use perp_update_funding::*; pub use serum3_cancel_all_orders::*; pub use serum3_cancel_order::*; @@ -64,6 +65,7 @@ mod perp_consume_events; mod perp_create_market; mod perp_edit_market; mod perp_place_order; +mod perp_settle_pnl; mod perp_update_funding; mod serum3_cancel_all_orders; mod serum3_cancel_order; diff --git a/programs/mango-v4/src/instructions/perp_settle_pnl.rs b/programs/mango-v4/src/instructions/perp_settle_pnl.rs new file mode 100644 index 000000000..a79375fc4 --- /dev/null +++ b/programs/mango-v4/src/instructions/perp_settle_pnl.rs @@ -0,0 +1,124 @@ +use anchor_lang::prelude::*; +use checked_math as cm; +use fixed::types::I80F48; + +use crate::accounts_zerocopy::*; +use crate::error::*; +use crate::state::compute_health; +use crate::state::new_fixed_order_account_retriever; +use crate::state::Bank; +use crate::state::HealthType; +use crate::state::MangoAccount; +use crate::state::TokenPosition; +use crate::state::QUOTE_TOKEN_INDEX; +use crate::state::{oracle_price, AccountLoaderDynamic, Group, PerpMarket}; + +#[derive(Accounts)] +pub struct PerpSettlePnl<'info> { + pub group: AccountLoader<'info, Group>, + + #[account(has_one = group, has_one = oracle)] + pub perp_market: AccountLoader<'info, PerpMarket>, + + // This account MUST be profitable + #[account(mut, has_one = group)] + pub account_a: AccountLoaderDynamic<'info, MangoAccount>, + // This account MUST have a loss + #[account(mut, has_one = group)] + pub account_b: AccountLoaderDynamic<'info, MangoAccount>, + + pub oracle: UncheckedAccount<'info>, + + #[account(mut, has_one = group)] + pub quote_bank: AccountLoader<'info, Bank>, +} + +pub fn perp_settle_pnl(ctx: Context, max_settle_amount: I80F48) -> Result<()> { + // Cannot settle with yourself + require!( + ctx.accounts.account_a.to_account_info().key + != ctx.accounts.account_b.to_account_info().key, + MangoError::CannotSettleWithSelf + ); + + // max_settle_amount must greater than zero + require!( + max_settle_amount > 0, + MangoError::MaxSettleAmountMustBeGreaterThanZero + ); + + let mut account_a = ctx.accounts.account_a.load_mut()?; + let mut account_b = ctx.accounts.account_b.load_mut()?; + let mut bank = ctx.accounts.quote_bank.load_mut()?; + let perp_market = ctx.accounts.perp_market.load()?; + + // Verify that the bank is the quote currency bank + require!( + bank.token_index == QUOTE_TOKEN_INDEX, + MangoError::InvalidBank + ); + + // Get oracle price for market. Price is validated inside + let oracle_price = oracle_price( + &AccountInfoRef::borrow(ctx.accounts.oracle.as_ref())?, + perp_market.oracle_config.conf_filter, + perp_market.base_token_decimals, + )?; + + // Fetch perp positions for accounts + let mut a_perp_position = account_a.perp_position_mut(perp_market.perp_market_index)?; + let mut b_perp_position = account_b.perp_position_mut(perp_market.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_base_native = a_perp_position.base_position_native(&perp_market); + let b_base_native = b_perp_position.base_position_native(&perp_market); + let a_pnl: I80F48 = cm!(a_perp_position.quote_position_native + a_base_native * oracle_price); + let b_pnl: I80F48 = cm!(b_perp_position.quote_position_native + b_base_native * oracle_price); + + // Account A must be profitable, and B must be unprofitable + // PnL must be opposite signs for there to be a settlement + require!(a_pnl.is_positive(), MangoError::ProfitabilityMismatch); + require!(b_pnl.is_negative(), MangoError::ProfitabilityMismatch); + + // Settle for the maximum possible capped to max_settle_amount + let settlement = a_pnl.abs().min(b_pnl.abs()).min(max_settle_amount); + a_perp_position.quote_position_native = cm!(a_perp_position.quote_position_native - settlement); + b_perp_position.quote_position_native = cm!(b_perp_position.quote_position_native + settlement); + + // Update the account's net_settled with the new PnL + let settlement_i64 = settlement.checked_to_num::().unwrap(); + account_a.fixed.net_settled = cm!(account_a.fixed.net_settled + settlement_i64); + account_b.fixed.net_settled = cm!(account_b.fixed.net_settled - settlement_i64); + + // Transfer token balances + // TODO: Need to guarantee that QUOTE_TOKEN_INDEX token exists at this point. I.E. create it when placing perp order. + let a_token_position = account_a.ensure_token_position(QUOTE_TOKEN_INDEX)?.0; + let b_token_position = account_b.ensure_token_position(QUOTE_TOKEN_INDEX)?.0; + transfer_token_internal(&mut bank, b_token_position, a_token_position, settlement)?; + + // Bank is dropped to prevent re-borrow from remaining_accounts + drop(bank); + + // Verify that the result of settling did not violate the health of the account that lost money + let retriever = new_fixed_order_account_retriever(ctx.remaining_accounts, &account_b.borrow())?; + let health = compute_health(&account_b.borrow(), HealthType::Init, &retriever)?; + require!(health >= 0, MangoError::HealthMustBePositive); + + msg!("settled pnl = {}", settlement); + Ok(()) +} + +fn transfer_token_internal( + bank: &mut Bank, + from_position: &mut TokenPosition, + to_position: &mut TokenPosition, + native_amount: I80F48, +) -> Result<()> { + bank.deposit(to_position, native_amount)?; + bank.withdraw_with_fee(from_position, native_amount)?; + Ok(()) +} diff --git a/programs/mango-v4/src/lib.rs b/programs/mango-v4/src/lib.rs index f5c95c773..4b5cc28d2 100644 --- a/programs/mango-v4/src/lib.rs +++ b/programs/mango-v4/src/lib.rs @@ -497,6 +497,9 @@ pub mod mango_v4 { instructions::perp_update_funding(ctx) } + pub fn perp_settle_pnl(ctx: Context, max_settle_amount: I80F48) -> Result<()> { + instructions::perp_settle_pnl(ctx, max_settle_amount) + } // TODO // perp_force_cancel_order @@ -504,7 +507,7 @@ pub mod mango_v4 { // liquidate_token_and_perp // liquidate_perp_and_perp - // settle_* - settle_funds, settle_pnl, settle_fees + // settle_* - settle_funds, settle_fees // resolve_banktruptcy diff --git a/programs/mango-v4/src/state/mango_account.rs b/programs/mango-v4/src/state/mango_account.rs index 4fc04a7c5..857763351 100644 --- a/programs/mango-v4/src/state/mango_account.rs +++ b/programs/mango-v4/src/state/mango_account.rs @@ -690,6 +690,18 @@ impl< get_helper_mut(self.dynamic_mut(), offset) } + pub fn perp_position_mut( + &mut self, + market_index: PerpMarketIndex, + ) -> Result<&mut PerpPosition> { + let raw_index_opt = self + .all_perp_positions() + .position(|p| p.is_active_for_market(market_index)); + raw_index_opt + .map(|raw_index| self.perp_position_mut_by_raw_index(raw_index)) + .ok_or_else(|| error!(MangoError::PerpPositionDoesNotExist)) + } + pub fn ensure_perp_position( &mut self, perp_market_index: PerpMarketIndex, @@ -1205,6 +1217,17 @@ mod tests { assert_eq!(pos.market_index, 42); } + { + let pos_res = account.perp_position_mut(1); + assert!(pos_res.is_ok()); + assert_eq!(pos_res.unwrap().market_index, 1) + } + + { + let pos_res = account.perp_position_mut(99); + assert!(pos_res.is_err()); + } + { account.deactivate_perp_position(1); @@ -1228,16 +1251,5 @@ mod tests { assert!(account.perp_position(8).is_ok()); assert!(account.perp_position(42).is_ok()); assert_eq!(account.active_perp_positions().count(), 2); - - /*{ - let (pos, raw) = account.perp_position_mut(42).unwrap(); - assert_eq!(pos.perp_index, 42); - assert_eq!(raw, 2); - } - { - let (pos, raw) = account.perp_position_mut(8).unwrap(); - assert_eq!(pos.perp_index, 8); - assert_eq!(raw, 1); - }*/ } } diff --git a/programs/mango-v4/src/state/mango_account_components.rs b/programs/mango-v4/src/state/mango_account_components.rs index b13d3b46b..46447327c 100644 --- a/programs/mango-v4/src/state/mango_account_components.rs +++ b/programs/mango-v4/src/state/mango_account_components.rs @@ -235,6 +235,11 @@ impl PerpPosition { self.market_index == market_index } + // Return base position in native units for a perp market + pub fn base_position_native(&self, market: &PerpMarket) -> I80F48 { + I80F48::from(cm!(self.base_position_lots * market.base_lot_size)) + } + /// This assumes settle_funding was already called pub fn change_base_position(&mut self, perp_market: &mut PerpMarket, base_change: i64) { let start = self.base_position_lots; diff --git a/programs/mango-v4/tests/program_test/mango_client.rs b/programs/mango-v4/tests/program_test/mango_client.rs index f4b732db9..63ec9e95a 100644 --- a/programs/mango-v4/tests/program_test/mango_client.rs +++ b/programs/mango-v4/tests/program_test/mango_client.rs @@ -2481,6 +2481,61 @@ impl ClientInstruction for PerpUpdateFundingInstruction { } } +pub struct PerpSettlePnlInstruction { + pub group: Pubkey, + pub account_a: Pubkey, + pub account_b: Pubkey, + pub perp_market: Pubkey, + pub oracle: Pubkey, + pub quote_bank: Pubkey, + pub max_settle_amount: I80F48, +} +#[async_trait::async_trait(?Send)] +impl ClientInstruction for PerpSettlePnlInstruction { + type Accounts = mango_v4::accounts::PerpSettlePnl; + type Instruction = mango_v4::instruction::PerpSettlePnl; + async fn to_instruction( + &self, + account_loader: impl ClientAccountLoader + 'async_trait, + ) -> (Self::Accounts, instruction::Instruction) { + let program_id = mango_v4::id(); + let instruction = Self::Instruction { + max_settle_amount: self.max_settle_amount, + }; + let accounts = Self::Accounts { + group: self.group, + perp_market: self.perp_market, + account_a: self.account_a, + account_b: self.account_b, + oracle: self.oracle, + quote_bank: self.quote_bank, + }; + + let perp_market: PerpMarket = account_loader.load(&self.perp_market).await.unwrap(); + let account_b = account_loader + .load_mango_account(&self.account_b) + .await + .unwrap(); + let health_check_metas = derive_health_check_remaining_account_metas( + &account_loader, + &account_b, + None, + false, + Some(perp_market.perp_market_index), + ) + .await; + + let mut instruction = make_instruction(program_id, &accounts, instruction); + instruction.accounts.extend(health_check_metas); + + (accounts, instruction) + } + + fn signers(&self) -> Vec<&Keypair> { + vec![] + } +} + pub struct BenchmarkInstruction {} #[async_trait::async_trait(?Send)] impl ClientInstruction for BenchmarkInstruction { diff --git a/programs/mango-v4/tests/test_perp_settle.rs b/programs/mango-v4/tests/test_perp_settle.rs new file mode 100644 index 000000000..27a72f807 --- /dev/null +++ b/programs/mango-v4/tests/test_perp_settle.rs @@ -0,0 +1,783 @@ +#![cfg(all(feature = "test-bpf"))] + +use anchor_lang::prelude::ErrorCode; +use fixed::types::I80F48; +use mango_v4::{error::MangoError, state::*}; +use program_test::*; +use solana_program::instruction::InstructionError; +use solana_program_test::*; +use solana_sdk::{signature::Keypair, transaction::TransactionError, transport::TransportError}; + +mod program_test; + +#[tokio::test] +async fn test_perp_settle_pnl() -> Result<(), TransportError> { + let context = TestContext::new().await; + let solana = &context.solana.clone(); + + let admin = &Keypair::new(); + let owner = &context.users[0].key; + let payer = &context.users[1].key; + let mints = &context.mints[0..2]; + let payer_mint_accounts = &context.users[1].token_accounts[0..=2]; + + let initial_token_deposit = 10_000; + + // + // SETUP: Create a group and an account + // + + let mango_setup::GroupWithTokens { group, tokens, .. } = mango_setup::GroupWithTokensConfig { + admin, + payer, + mints, + } + .create(solana) + .await; + + let account_0 = send_tx( + solana, + AccountCreateInstruction { + account_num: 0, + token_count: 16, + serum3_count: 8, + perp_count: 8, + perp_oo_count: 8, + group, + owner, + payer, + }, + ) + .await + .unwrap() + .account; + + let account_1 = send_tx( + solana, + AccountCreateInstruction { + account_num: 1, + token_count: 16, + serum3_count: 8, + perp_count: 8, + perp_oo_count: 8, + group, + owner, + payer, + }, + ) + .await + .unwrap() + .account; + + // + // SETUP: Deposit user funds + // + { + let deposit_amount = initial_token_deposit; + + send_tx( + solana, + TokenDepositInstruction { + amount: deposit_amount, + account: account_0, + token_account: payer_mint_accounts[0], + token_authority: payer.clone(), + bank_index: 0, + }, + ) + .await + .unwrap(); + + send_tx( + solana, + TokenDepositInstruction { + amount: deposit_amount, + account: account_0, + token_account: payer_mint_accounts[1], + token_authority: payer.clone(), + bank_index: 0, + }, + ) + .await + .unwrap(); + } + + { + let deposit_amount = initial_token_deposit; + + send_tx( + solana, + TokenDepositInstruction { + amount: deposit_amount, + account: account_1, + token_account: payer_mint_accounts[0], + token_authority: payer.clone(), + bank_index: 0, + }, + ) + .await + .unwrap(); + + send_tx( + solana, + TokenDepositInstruction { + amount: deposit_amount, + account: account_1, + token_account: payer_mint_accounts[1], + token_authority: payer.clone(), + bank_index: 0, + }, + ) + .await + .unwrap(); + } + + // + // TEST: Create a perp market + // + let mango_v4::accounts::PerpCreateMarket { + perp_market, + asks, + bids, + event_queue, + .. + } = send_tx( + solana, + PerpCreateMarketInstruction { + group, + admin, + oracle: tokens[0].oracle, + asks: context + .solana + .create_account_for_type::(&mango_v4::id()) + .await, + bids: context + .solana + .create_account_for_type::(&mango_v4::id()) + .await, + event_queue: { + context + .solana + .create_account_for_type::(&mango_v4::id()) + .await + }, + payer, + perp_market_index: 0, + base_token_index: tokens[0].index, + base_token_decimals: tokens[0].mint.decimals, + quote_lot_size: 10, + base_lot_size: 100, + maint_asset_weight: 0.975, + init_asset_weight: 0.95, + maint_liab_weight: 1.025, + init_liab_weight: 1.05, + liquidation_fee: 0.012, + maker_fee: 0.0002, + taker_fee: 0.000, + }, + ) + .await + .unwrap(); + + // + // TEST: Create another perp market + // + let mango_v4::accounts::PerpCreateMarket { + perp_market: perp_market_2, + .. + } = send_tx( + solana, + PerpCreateMarketInstruction { + group, + admin, + oracle: tokens[1].oracle, + asks: context + .solana + .create_account_for_type::(&mango_v4::id()) + .await, + bids: context + .solana + .create_account_for_type::(&mango_v4::id()) + .await, + event_queue: { + context + .solana + .create_account_for_type::(&mango_v4::id()) + .await + }, + payer, + perp_market_index: 1, + base_token_index: tokens[1].index, + base_token_decimals: tokens[1].mint.decimals, + quote_lot_size: 10, + base_lot_size: 100, + maint_asset_weight: 0.975, + init_asset_weight: 0.95, + maint_liab_weight: 1.025, + init_liab_weight: 1.05, + liquidation_fee: 0.012, + maker_fee: 0.0002, + taker_fee: 0.000, + }, + ) + .await + .unwrap(); + + let price_lots = { + let perp_market = solana.get_account::(perp_market).await; + perp_market.native_price_to_lot(I80F48::from(1000)) + }; + + // Set the initial oracle price + send_tx( + solana, + StubOracleSetInstruction { + group, + admin, + mint: mints[0].pubkey, + payer, + price: "1000.0", + }, + ) + .await + .unwrap(); + + // + // Place orders and create a position + // + send_tx( + solana, + PerpPlaceOrderInstruction { + group, + account: account_0, + perp_market, + asks, + bids, + event_queue, + oracle: tokens[0].oracle, + owner, + side: Side::Bid, + price_lots, + max_base_lots: 1, + max_quote_lots: i64::MAX, + client_order_id: 0, + }, + ) + .await + .unwrap(); + + send_tx( + solana, + PerpPlaceOrderInstruction { + group, + account: account_1, + perp_market, + asks, + bids, + event_queue, + oracle: tokens[0].oracle, + owner, + side: Side::Ask, + price_lots, + max_base_lots: 1, + max_quote_lots: i64::MAX, + client_order_id: 0, + }, + ) + .await + .unwrap(); + + send_tx( + solana, + PerpConsumeEventsInstruction { + group, + perp_market, + event_queue, + mango_accounts: vec![account_0, account_1], + }, + ) + .await + .unwrap(); + + { + let mango_account_0 = solana.get_account::(account_0).await; + let mango_account_1 = solana.get_account::(account_1).await; + + assert_eq!(mango_account_0.perps[0].base_position_lots, 1); + assert_eq!(mango_account_1.perps[0].base_position_lots, -1); + assert_eq!( + mango_account_0.perps[0].quote_position_native.round(), + -100_020 + ); + assert_eq!(mango_account_1.perps[0].quote_position_native, 100_000); + } + + // Bank must be valid for quote currency + let result = send_tx( + solana, + PerpSettlePnlInstruction { + group, + account_a: account_1, + account_b: account_0, + perp_market, + oracle: tokens[0].oracle, + quote_bank: tokens[1].bank, + max_settle_amount: I80F48::MAX, + }, + ) + .await; + + assert_mango_error( + &result, + MangoError::InvalidBank.into(), + "Bank must be valid for quote currency".to_string(), + ); + + // Oracle must be valid for the perp market + let result = send_tx( + solana, + PerpSettlePnlInstruction { + group, + account_a: account_1, + account_b: account_0, + perp_market, + oracle: tokens[1].oracle, // Using oracle for token 1 not 0 + quote_bank: tokens[0].bank, + max_settle_amount: I80F48::MAX, + }, + ) + .await; + + assert_mango_error( + &result, + ErrorCode::ConstraintHasOne.into(), + "Oracle must be valid for perp market".to_string(), + ); + + // Cannot settle with yourself + let result = send_tx( + solana, + PerpSettlePnlInstruction { + group, + account_a: account_0, + account_b: account_0, + perp_market, + oracle: tokens[0].oracle, + quote_bank: tokens[0].bank, + max_settle_amount: I80F48::MAX, + }, + ) + .await; + + assert_mango_error( + &result, + MangoError::CannotSettleWithSelf.into(), + "Cannot settle with yourself".to_string(), + ); + + // Cannot settle position that does not exist + let result = send_tx( + solana, + PerpSettlePnlInstruction { + group, + account_a: account_0, + account_b: account_1, + perp_market: perp_market_2, + oracle: tokens[1].oracle, + quote_bank: tokens[0].bank, + max_settle_amount: I80F48::MAX, + }, + ) + .await; + + assert_mango_error( + &result, + MangoError::PerpPositionDoesNotExist.into(), + "Cannot settle a position that does not exist".to_string(), + ); + + // max_settle_amount must be greater than zero + for max_amnt in vec![I80F48::ZERO, I80F48::from(-100)] { + let result = send_tx( + solana, + PerpSettlePnlInstruction { + group, + account_a: account_0, + account_b: account_1, + perp_market: perp_market, + oracle: tokens[0].oracle, + quote_bank: tokens[0].bank, + max_settle_amount: max_amnt, + }, + ) + .await; + + assert_mango_error( + &result, + MangoError::MaxSettleAmountMustBeGreaterThanZero.into(), + "max_settle_amount must be greater than zero".to_string(), + ); + } + + // TODO: Test funding settlement + + { + let mango_account_0 = solana.get_account::(account_0).await; + let mango_account_1 = solana.get_account::(account_1).await; + let bank = solana.get_account::(tokens[0].bank).await; + assert_eq!( + mango_account_0.tokens[0].native(&bank).round(), + initial_token_deposit, + "account 0 has expected amount of tokens" + ); + assert_eq!( + mango_account_1.tokens[0].native(&bank).round(), + initial_token_deposit, + "account 1 has expected amount of tokens" + ); + } + + // Try and settle with high price + send_tx( + solana, + StubOracleSetInstruction { + group, + admin, + mint: mints[0].pubkey, + payer, + price: "1200.0", + }, + ) + .await + .unwrap(); + + // Account a must be the profitable one + let result = send_tx( + solana, + PerpSettlePnlInstruction { + group, + account_a: account_1, + account_b: account_0, + perp_market, + oracle: tokens[0].oracle, + quote_bank: tokens[0].bank, + max_settle_amount: I80F48::MAX, + }, + ) + .await; + + assert_mango_error( + &result, + MangoError::ProfitabilityMismatch.into(), + "Account a must be the profitable one".to_string(), + ); + + let result = send_tx( + solana, + PerpSettlePnlInstruction { + group, + account_a: account_0, + account_b: account_1, + perp_market, + oracle: tokens[0].oracle, + quote_bank: tokens[0].bank, + max_settle_amount: I80F48::MAX, + }, + ) + .await; + + assert_mango_error( + &result, + MangoError::HealthMustBePositive.into(), + "Health of losing account must be positive to settle".to_string(), + ); + + // Change the oracle to a more reasonable price + send_tx( + solana, + StubOracleSetInstruction { + group, + admin, + mint: mints[0].pubkey, + payer, + price: "1005.0", + }, + ) + .await + .unwrap(); + + let expected_pnl_0 = I80F48::from(480); // Less due to fees + let expected_pnl_1 = I80F48::from(-500); + + { + let mango_account_0 = solana.get_account::(account_0).await; + let mango_account_1 = solana.get_account::(account_1).await; + let perp_market = solana.get_account::(perp_market).await; + assert_eq!( + get_pnl_native(&mango_account_0.perps[0], &perp_market, I80F48::from(1005)).round(), + expected_pnl_0 + ); + assert_eq!( + get_pnl_native(&mango_account_1.perps[0], &perp_market, I80F48::from(1005)), + expected_pnl_1 + ); + } + + solana.advance_clock().await; + + // Partially execute the settle + let partial_settle_amount = I80F48::from(200); + send_tx( + solana, + PerpSettlePnlInstruction { + group, + account_a: account_0, + account_b: account_1, + perp_market, + oracle: tokens[0].oracle, + quote_bank: tokens[0].bank, + max_settle_amount: partial_settle_amount, + }, + ) + .await + .unwrap(); + + { + let bank = solana.get_account::(tokens[0].bank).await; + let mango_account_0 = solana.get_account::(account_0).await; + let mango_account_1 = solana.get_account::(account_1).await; + + assert_eq!( + mango_account_0.perps[0].base_position_lots, 1, + "base position unchanged for account 0" + ); + assert_eq!( + mango_account_1.perps[0].base_position_lots, -1, + "base position unchanged for account 1" + ); + + assert_eq!( + mango_account_0.perps[0].quote_position_native.round(), + I80F48::from(-100_020) - partial_settle_amount, + "quote position reduced for profitable position by max_settle_amount" + ); + assert_eq!( + mango_account_1.perps[0].quote_position_native.round(), + I80F48::from(100_000) + partial_settle_amount, + "quote position increased for losing position by opposite of first account" + ); + + assert_eq!( + mango_account_0.tokens[0].native(&bank).round(), + I80F48::from(initial_token_deposit) + partial_settle_amount, + "account 0 token native position increased (profit) by max_settle_amount" + ); + assert_eq!( + mango_account_1.tokens[0].native(&bank).round(), + I80F48::from(initial_token_deposit) - partial_settle_amount, + "account 1 token native position decreased (loss) by max_settle_amount" + ); + + assert_eq!( + mango_account_0.net_settled, partial_settle_amount, + "net_settled on account 0 updated with profit from settlement" + ); + assert_eq!( + mango_account_1.net_settled, -partial_settle_amount, + "net_settled on account 1 updated with loss from settlement" + ); + } + + solana.advance_clock().await; + + // Fully execute the settle + send_tx( + solana, + PerpSettlePnlInstruction { + group, + account_a: account_0, + account_b: account_1, + perp_market, + oracle: tokens[0].oracle, + quote_bank: tokens[0].bank, + max_settle_amount: I80F48::MAX, + }, + ) + .await + .unwrap(); + + { + let bank = solana.get_account::(tokens[0].bank).await; + let mango_account_0 = solana.get_account::(account_0).await; + let mango_account_1 = solana.get_account::(account_1).await; + + assert_eq!( + mango_account_0.perps[0].base_position_lots, 1, + "base position unchanged for account 0" + ); + assert_eq!( + mango_account_1.perps[0].base_position_lots, -1, + "base position unchanged for account 1" + ); + + assert_eq!( + mango_account_0.perps[0].quote_position_native.round(), + I80F48::from(-100_020) - expected_pnl_0, + "quote position reduced for profitable position" + ); + assert_eq!( + mango_account_1.perps[0].quote_position_native.round(), + I80F48::from(100_000) + expected_pnl_0, + "quote position increased for losing position by opposite of first account" + ); + + assert_eq!( + mango_account_0.tokens[0].native(&bank).round(), + I80F48::from(initial_token_deposit) + expected_pnl_0, + "account 0 token native position increased (profit)" + ); + assert_eq!( + mango_account_1.tokens[0].native(&bank).round(), + I80F48::from(initial_token_deposit) - expected_pnl_0, + "account 1 token native position decreased (loss)" + ); + + assert_eq!( + mango_account_0.net_settled, expected_pnl_0, + "net_settled on account 0 updated with profit from settlement" + ); + assert_eq!( + mango_account_1.net_settled, -expected_pnl_0, + "net_settled on account 1 updated with loss from settlement" + ); + } + + solana.advance_clock().await; + + // Change the oracle to a reasonable price in other direction + send_tx( + solana, + StubOracleSetInstruction { + group, + admin, + mint: mints[0].pubkey, + payer, + price: "995.0", + }, + ) + .await + .unwrap(); + + let expected_pnl_0 = I80F48::from(-1000); + let expected_pnl_1 = I80F48::from(980); + + { + let mango_account_0 = solana.get_account::(account_0).await; + let mango_account_1 = solana.get_account::(account_1).await; + let perp_market = solana.get_account::(perp_market).await; + assert_eq!( + get_pnl_native(&mango_account_0.perps[0], &perp_market, I80F48::from(995)).round(), + expected_pnl_0 + ); + assert_eq!( + get_pnl_native(&mango_account_1.perps[0], &perp_market, I80F48::from(995)).round(), + expected_pnl_1 + ); + } + + solana.advance_clock().await; + + // Fully execute the settle + send_tx( + solana, + PerpSettlePnlInstruction { + group, + account_a: account_1, + account_b: account_0, + perp_market, + oracle: tokens[0].oracle, + quote_bank: tokens[0].bank, + max_settle_amount: I80F48::MAX, + }, + ) + .await + .unwrap(); + + { + let bank = solana.get_account::(tokens[0].bank).await; + let mango_account_0 = solana.get_account::(account_0).await; + let mango_account_1 = solana.get_account::(account_1).await; + + assert_eq!( + mango_account_0.perps[0].base_position_lots, 1, + "base position unchanged for account 0" + ); + assert_eq!( + mango_account_1.perps[0].base_position_lots, -1, + "base position unchanged for account 1" + ); + + assert_eq!( + mango_account_0.perps[0].quote_position_native.round(), + I80F48::from(-100_500) + expected_pnl_1, + "quote position increased for losing position" + ); + assert_eq!( + mango_account_1.perps[0].quote_position_native.round(), + I80F48::from(100_480) - expected_pnl_1, + "quote position reduced for losing position by opposite of first account" + ); + + // 480 was previous settlement + assert_eq!( + mango_account_0.tokens[0].native(&bank).round(), + I80F48::from(initial_token_deposit + 480) - expected_pnl_1, + "account 0 token native position decreased (loss)" + ); + assert_eq!( + mango_account_1.tokens[0].native(&bank).round(), + I80F48::from(initial_token_deposit - 480) + expected_pnl_1, + "account 1 token native position increased (profit)" + ); + + assert_eq!( + mango_account_0.net_settled, + I80F48::from(480) - expected_pnl_1, + "net_settled on account 0 updated with loss from settlement" + ); + assert_eq!( + mango_account_1.net_settled, + I80F48::from(-480) + expected_pnl_1, + "net_settled on account 1 updated with profit from settlement" + ); + } + + Ok(()) +} + +fn get_pnl_native( + perp_position: &PerpPosition, + perp_market: &PerpMarket, + oracle_price: I80F48, +) -> I80F48 { + let contract_size = perp_market.base_lot_size; + let new_quote_pos = + I80F48::from_num(-perp_position.base_position_lots * contract_size) * oracle_price; + perp_position.quote_position_native - new_quote_pos +} + +fn assert_mango_error(result: &Result, expected_error: u32, comment: String) { + match result { + Ok(_) => assert!(false, "No error returned"), + Err(TransportError::TransactionError(tx_err)) => match tx_err { + TransactionError::InstructionError(_, err) => match err { + InstructionError::Custom(err_num) => { + assert_eq!(*err_num, expected_error, "{}", comment); + } + _ => assert!(false, "Not a mango error"), + }, + _ => assert!(false, "Not a mango error"), + }, + _ => assert!(false, "Not a mango error"), + } +}