diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8f48d575e..d798f646b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -139,7 +139,7 @@ jobs: run: yarn - name: install typescript - run: npm install typescript -g + run: npm install typescript@5.4.5 -g - name: install mocha run: | @@ -180,7 +180,7 @@ jobs: - name: Install ts-mocha and typescript run: | npm install -g ts-mocha - npm install -g typescript + npm install typescript@5.4.5 -g - name: Run tests env: diff --git a/README.md b/README.md index 0bb99c9b0..61a1e8d16 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,10 @@ cd sdk/ && yarn && yarn build && cd .. ## Running Rust Test +For running cargo tests, you'll need version 1.70. You'll also need Solana CLI version 1.16.27 + ```bash +rustup override set 1.70 cargo test ``` @@ -60,11 +63,13 @@ bash test-scripts/run-anchor-tests.sh We've provided a devcontainer `Dockerfile` to help you spin up a dev environment with the correct versions of Rust, Solana, and Anchor for program development. Build the container and tag it `drift-dev`: + ``` cd .devcontainer && docker build -t drift-dev . ``` Open a shell to the container: + ``` # Find the container ID first docker ps @@ -83,6 +88,7 @@ Alternatively use an extension provided by your IDE to make use of the dev conta ``` Use the dev container as you would a local build environment: + ``` # build program anchor build diff --git a/programs/drift/src/controller/isolated_position.rs b/programs/drift/src/controller/isolated_position.rs index 3c2a6e971..be91f578f 100644 --- a/programs/drift/src/controller/isolated_position.rs +++ b/programs/drift/src/controller/isolated_position.rs @@ -8,6 +8,7 @@ use crate::math::liquidation::is_isolated_margin_being_liquidated; use crate::math::margin::{validate_spot_margin_trading, MarginRequirementType}; use crate::math::safe_math::SafeMath; use crate::state::events::{DepositDirection, DepositExplanation, DepositRecord}; +use crate::state::margin_calculation::MarginTypeConfig; use crate::state::oracle_map::OracleMap; use crate::state::perp_market::MarketStatus; use crate::state::perp_market_map::PerpMarketMap; @@ -234,7 +235,10 @@ pub fn transfer_isolated_perp_position_deposit<'c: 'info, 'info>( &perp_market_map, &spot_market_map, oracle_map, - MarginRequirementType::Initial, + MarginTypeConfig::CrossMarginOverride { + margin_requirement_type: MarginRequirementType::Initial, + default_margin_requirement_type: MarginRequirementType::Maintenance, + }, spot_market_index, amount as u128, user_stats, @@ -298,7 +302,12 @@ pub fn transfer_isolated_perp_position_deposit<'c: 'info, 'info>( &perp_market_map, &spot_market_map, oracle_map, - MarginRequirementType::Initial, + MarginTypeConfig::IsolatedPositionOverride { + margin_requirement_type: MarginRequirementType::Initial, + default_isolated_margin_requirement_type: MarginRequirementType::Maintenance, + cross_margin_requirement_type: MarginRequirementType::Maintenance, + market_index: perp_market_index, + }, 0, 0, user_stats, diff --git a/programs/drift/src/controller/orders.rs b/programs/drift/src/controller/orders.rs index 9b6807380..7126c1d32 100644 --- a/programs/drift/src/controller/orders.rs +++ b/programs/drift/src/controller/orders.rs @@ -58,7 +58,7 @@ use crate::state::events::{emit_stack, get_order_action_record, OrderActionRecor use crate::state::events::{OrderAction, OrderActionExplanation}; use crate::state::fill_mode::FillMode; use crate::state::fulfillment::{PerpFulfillmentMethod, SpotFulfillmentMethod}; -use crate::state::margin_calculation::{MarginCalculation, MarginContext}; +use crate::state::margin_calculation::{MarginCalculation, MarginContext, MarginTypeConfig}; use crate::state::oracle::{OraclePriceData, StrictOraclePrice}; use crate::state::oracle_map::OracleMap; use crate::state::order_params::{ @@ -379,12 +379,19 @@ pub fn place_perp_order( // when orders are placed in bulk, only need to check margin on last place if options.enforce_margin_check && !options.is_liquidation() { + // if isolated position, use the isolated margin calculation + let isolated_market_index = if user.perp_positions[position_index].is_isolated() { + Some(market_index) + } else { + None + }; meets_place_order_margin_requirement( user, perp_market_map, spot_market_map, oracle_map, options.risk_increasing, + isolated_market_index, )?; } @@ -1795,6 +1802,7 @@ fn fulfill_perp_order( let user_order_position_decreasing = determine_if_user_order_is_position_decreasing(user, market_index, user_order_index)?; + let user_is_isolated_position = user.get_perp_position(market_index)?.is_isolated(); let perp_market = perp_market_map.get_ref(&market_index)?; let limit_price = fill_mode.get_limit_price( @@ -1826,7 +1834,7 @@ fn fulfill_perp_order( let mut base_asset_amount = 0_u64; let mut quote_asset_amount = 0_u64; - let mut maker_fills: BTreeMap = BTreeMap::new(); + let mut maker_fills: BTreeMap = BTreeMap::new(); let maker_direction = user.orders[user_order_index].direction.opposite(); for fulfillment_method in fulfillment_methods.iter() { if user.orders[user_order_index].status != OrderStatus::Open { @@ -1891,6 +1899,8 @@ fn fulfill_perp_order( } PerpFulfillmentMethod::Match(maker_key, maker_order_index, maker_price) => { let mut maker = makers_and_referrer.get_ref_mut(maker_key)?; + let maker_is_isolated_position = + maker.get_perp_position(market_index)?.is_isolated(); let mut maker_stats = if maker.authority == user.authority { None } else { @@ -1939,6 +1949,7 @@ fn fulfill_perp_order( maker_key, maker_direction, maker_fill_base_asset_amount, + maker_is_isolated_position, )?; } @@ -1961,7 +1972,7 @@ fn fulfill_perp_order( quote_asset_amount )?; - let total_maker_fill = maker_fills.values().sum::(); + let total_maker_fill = maker_fills.values().map(|(fill, _)| fill).sum::(); validate!( total_maker_fill.unsigned_abs() <= base_asset_amount, @@ -1979,13 +1990,29 @@ fn fulfill_perp_order( -(base_asset_amount as i64) }; - let mut context = MarginContext::standard(if user_order_position_decreasing { + let margin_requirement_type = if user_order_position_decreasing { MarginRequirementType::Maintenance } else { MarginRequirementType::Fill - }) - .fuel_perp_delta(market_index, taker_base_asset_amount_delta) - .fuel_numerator(user, now); + }; + + let margin_type_config = if user_is_isolated_position { + MarginTypeConfig::IsolatedPositionOverride { + market_index, + margin_requirement_type, + default_isolated_margin_requirement_type: MarginRequirementType::Maintenance, + cross_margin_requirement_type: MarginRequirementType::Maintenance, + } + } else { + MarginTypeConfig::CrossMarginOverride { + margin_requirement_type, + default_margin_requirement_type: MarginRequirementType::Maintenance, + } + }; + + let mut context = MarginContext::standard_with_config(margin_type_config) + .fuel_perp_delta(market_index, taker_base_asset_amount_delta) + .fuel_numerator(user, now); if oracle_stale_for_margin && !user_order_position_decreasing { context = context.margin_ratio_override(MARGIN_PRECISION); @@ -2033,7 +2060,7 @@ fn fulfill_perp_order( } } - for (maker_key, maker_base_asset_amount_filled) in maker_fills { + for (maker_key, (maker_base_asset_amount_filled, maker_is_isolated_position)) in maker_fills { let mut maker = makers_and_referrer.get_ref_mut(&maker_key)?; let maker_stats = if maker.authority == user.authority { @@ -2048,7 +2075,21 @@ fn fulfill_perp_order( market_index, )?; - let mut context = MarginContext::standard(margin_type) + let margin_type_config = if maker_is_isolated_position { + MarginTypeConfig::IsolatedPositionOverride { + market_index, + margin_requirement_type: margin_type, + default_isolated_margin_requirement_type: MarginRequirementType::Maintenance, + cross_margin_requirement_type: MarginRequirementType::Maintenance, + } + } else { + MarginTypeConfig::CrossMarginOverride { + margin_requirement_type: margin_type, + default_margin_requirement_type: MarginRequirementType::Maintenance, + } + }; + + let mut context = MarginContext::standard_with_config(margin_type_config) .fuel_perp_delta(market_index, -maker_base_asset_amount_filled) .fuel_numerator(&maker, now); @@ -2147,10 +2188,11 @@ fn get_referrer<'a>( #[inline(always)] fn update_maker_fills_map( - map: &mut BTreeMap, + map: &mut BTreeMap, maker_key: &Pubkey, maker_direction: PositionDirection, fill: u64, + is_isolated_position: bool, ) -> DriftResult { let signed_fill = match maker_direction { PositionDirection::Long => fill.cast::()?, @@ -2158,9 +2200,9 @@ fn update_maker_fills_map( }; if let Some(maker_filled) = map.get_mut(maker_key) { - *maker_filled = maker_filled.safe_add(signed_fill)?; + *maker_filled = (maker_filled.0.safe_add(signed_fill)?, is_isolated_position); } else { - map.insert(*maker_key, signed_fill); + map.insert(*maker_key, (signed_fill, is_isolated_position)); } Ok(()) @@ -3920,6 +3962,7 @@ pub fn place_spot_order( spot_market_map, oracle_map, options.risk_increasing, + None, // no isolated positions for spot positions )?; } @@ -4574,7 +4617,7 @@ fn fulfill_spot_order( let mut base_asset_amount = 0_u64; let mut quote_asset_amount = 0_u64; - let mut maker_fills: BTreeMap = BTreeMap::new(); + let mut maker_fills: BTreeMap = BTreeMap::new(); let maker_direction = user.orders[user_order_index].direction.opposite(); for fulfillment_method in fulfillment_methods.iter() { if user.orders[user_order_index].status != OrderStatus::Open { @@ -4616,6 +4659,7 @@ fn fulfill_spot_order( maker_key, maker_direction, base_filled, + false, )?; } diff --git a/programs/drift/src/controller/orders/tests.rs b/programs/drift/src/controller/orders/tests.rs index 98684585b..cef4501a8 100644 --- a/programs/drift/src/controller/orders/tests.rs +++ b/programs/drift/src/controller/orders/tests.rs @@ -13433,28 +13433,59 @@ mod update_maker_fills_map { #[test] fn test() { - let mut map: BTreeMap = BTreeMap::new(); + let mut map: BTreeMap = BTreeMap::new(); let maker_key = Pubkey::new_unique(); let fill = 100; let direction = PositionDirection::Long; - update_maker_fills_map(&mut map, &maker_key, direction, fill).unwrap(); + update_maker_fills_map(&mut map, &maker_key, direction, fill, false).unwrap(); - assert_eq!(*map.get(&maker_key).unwrap(), fill as i64); + assert_eq!(map.get(&maker_key).unwrap().0, fill as i64); + assert_eq!(map.get(&maker_key).unwrap().1, false); - update_maker_fills_map(&mut map, &maker_key, direction, fill).unwrap(); + update_maker_fills_map(&mut map, &maker_key, direction, fill, false).unwrap(); - assert_eq!(*map.get(&maker_key).unwrap(), 2 * fill as i64); + assert_eq!(map.get(&maker_key).unwrap().0, 2 * fill as i64); + assert_eq!(map.get(&maker_key).unwrap().1, false); let maker_key = Pubkey::new_unique(); let direction = PositionDirection::Short; - update_maker_fills_map(&mut map, &maker_key, direction, fill).unwrap(); + update_maker_fills_map(&mut map, &maker_key, direction, fill, false).unwrap(); - assert_eq!(*map.get(&maker_key).unwrap(), -(fill as i64)); + assert_eq!(map.get(&maker_key).unwrap().0, -(fill as i64)); + assert_eq!(map.get(&maker_key).unwrap().1, false); - update_maker_fills_map(&mut map, &maker_key, direction, fill).unwrap(); + update_maker_fills_map(&mut map, &maker_key, direction, fill, false).unwrap(); - assert_eq!(*map.get(&maker_key).unwrap(), -2 * fill as i64); + assert_eq!(map.get(&maker_key).unwrap().0, -2 * fill as i64); + assert_eq!(map.get(&maker_key).unwrap().1, false); + } + + #[test] + fn test_isolated_position_true() { + let mut map: BTreeMap = BTreeMap::new(); + + let fill = 100; + + // Single insert with isolated_position true + let maker_key = Pubkey::new_unique(); + update_maker_fills_map(&mut map, &maker_key, PositionDirection::Long, fill, true).unwrap(); + assert_eq!(map.get(&maker_key).unwrap().0, fill as i64); + assert_eq!(map.get(&maker_key).unwrap().1, true); + + // Merge: same maker_key, two updates both with true + update_maker_fills_map(&mut map, &maker_key, PositionDirection::Long, fill, true).unwrap(); + assert_eq!(map.get(&maker_key).unwrap().0, 2 * fill as i64); + assert_eq!(map.get(&maker_key).unwrap().1, true); + + // Last write wins: first false, then true -> final .1 is true + let maker_key2 = Pubkey::new_unique(); + update_maker_fills_map(&mut map, &maker_key2, PositionDirection::Short, fill, false) + .unwrap(); + update_maker_fills_map(&mut map, &maker_key2, PositionDirection::Short, fill, true) + .unwrap(); + assert_eq!(map.get(&maker_key2).unwrap().0, -2 * fill as i64); + assert_eq!(map.get(&maker_key2).unwrap().1, true); } } diff --git a/programs/drift/src/math/margin.rs b/programs/drift/src/math/margin.rs index f7c4cd2fd..b928b6686 100644 --- a/programs/drift/src/math/margin.rs +++ b/programs/drift/src/math/margin.rs @@ -8,6 +8,7 @@ use crate::math::oracle::LogMode; use crate::math::position::calculate_base_asset_value_and_pnl_with_oracle_price; use crate::math::constants::{MARGIN_PRECISION, PRICE_PRECISION_I128, PRICE_PRECISION_I64}; +use crate::state::margin_calculation::MarginTypeConfig; use crate::validate; use crate::validation; @@ -265,19 +266,22 @@ pub fn calculate_margin_requirement_and_total_collateral_and_liability_info( context: MarginContext, ) -> DriftResult { let mut calculation = MarginCalculation::new(context); + let cross_margin_requirement_type = context + .margin_type_config + .get_cross_margin_requirement_type(); - let mut user_custom_margin_ratio = if context.margin_type == MarginRequirementType::Initial { - user.max_margin_ratio - } else { - 0_u32 - }; + let mut spot_user_custom_margin_ratio = + if cross_margin_requirement_type == MarginRequirementType::Initial { + user.max_margin_ratio + } else { + 0_u32 + }; if let Some(margin_ratio_override) = context.margin_ratio_override { - user_custom_margin_ratio = margin_ratio_override.max(user_custom_margin_ratio); + spot_user_custom_margin_ratio = margin_ratio_override.max(spot_user_custom_margin_ratio); } let user_pool_id = user.pool_id; - let user_high_leverage_mode = user.is_high_leverage_mode(context.margin_type); for spot_position in user.spot_positions.iter() { validation::position::validate_spot_position(spot_position)?; @@ -406,12 +410,12 @@ pub fn calculate_margin_requirement_and_total_collateral_and_liability_info( &spot_market, &strict_oracle_price, Some(signed_token_amount), - context.margin_type, + cross_margin_requirement_type, )? .apply_user_custom_margin_ratio( &spot_market, strict_oracle_price.current, - user_custom_margin_ratio, + spot_user_custom_margin_ratio, )?; if worst_case_token_amount == 0 { @@ -575,22 +579,47 @@ pub fn calculate_margin_requirement_and_total_collateral_and_liability_info( Some(LogMode::Margin), )?; - let perp_position_custom_margin_ratio = - if context.margin_type == MarginRequirementType::Initial { - market_position.max_margin_ratio as u32 + let position_margin_type = if market_position.is_isolated() { + context + .margin_type_config + .get_isolated_margin_requirement_type(market_position.market_index) + } else { + context + .margin_type_config + .get_cross_margin_requirement_type() + }; + + let perp_user_custom_margin_ratio = + if position_margin_type == MarginRequirementType::Initial { + user.max_margin_ratio } else { 0_u32 }; + let mut perp_position_custom_margin_ratio = + if position_margin_type == MarginRequirementType::Initial { + perp_user_custom_margin_ratio.max(market_position.max_margin_ratio as u32) + } else { + 0_u32 + }; + + if let Some(margin_ratio_override) = context.margin_ratio_override { + perp_position_custom_margin_ratio = + margin_ratio_override.max(perp_position_custom_margin_ratio); + } + + let perp_position_user_high_leverage_mode = + user.is_high_leverage_mode(position_margin_type); + let (perp_margin_requirement, weighted_pnl, worst_case_liability_value, base_asset_value) = calculate_perp_position_value_and_pnl( market_position, market, oracle_price_data, &strict_quote_price, - context.margin_type, - user_custom_margin_ratio.max(perp_position_custom_margin_ratio), - user_high_leverage_mode, + position_margin_type, + perp_position_custom_margin_ratio, + perp_position_user_high_leverage_mode, )?; calculation.update_fuel_perp_bonus( @@ -652,7 +681,7 @@ pub fn calculate_margin_requirement_and_total_collateral_and_liability_info( ); } - if has_perp_liability || calculation.context.margin_type != MarginRequirementType::Initial { + if has_perp_liability || position_margin_type != MarginRequirementType::Initial { calculation.update_all_liability_oracles_valid(is_oracle_valid_for_action( quote_oracle_validity, Some(DriftAction::MarginCalc), @@ -744,11 +773,23 @@ pub fn meets_place_order_margin_requirement( spot_market_map: &SpotMarketMap, oracle_map: &mut OracleMap, risk_increasing: bool, + isolated_market_index: Option, ) -> DriftResult { - let margin_type = if risk_increasing { - MarginRequirementType::Initial + let margin_type_config = if risk_increasing { + match isolated_market_index { + Some(market_index) => MarginTypeConfig::IsolatedPositionOverride { + market_index, + margin_requirement_type: MarginRequirementType::Initial, + default_isolated_margin_requirement_type: MarginRequirementType::Maintenance, + cross_margin_requirement_type: MarginRequirementType::Maintenance, + }, + None => MarginTypeConfig::CrossMarginOverride { + margin_requirement_type: MarginRequirementType::Initial, + default_margin_requirement_type: MarginRequirementType::Maintenance, + }, + } } else { - MarginRequirementType::Maintenance + MarginTypeConfig::Default(MarginRequirementType::Maintenance) }; let calculation = calculate_margin_requirement_and_total_collateral_and_liability_info( @@ -756,7 +797,7 @@ pub fn meets_place_order_margin_requirement( perp_market_map, spot_market_map, oracle_map, - MarginContext::standard(margin_type).strict(true), + MarginContext::standard_with_config(margin_type_config).strict(true), )?; if !calculation.meets_margin_requirement() { diff --git a/programs/drift/src/math/margin/tests.rs b/programs/drift/src/math/margin/tests.rs index 3f73436cf..02d9066f6 100644 --- a/programs/drift/src/math/margin/tests.rs +++ b/programs/drift/src/math/margin/tests.rs @@ -1204,6 +1204,204 @@ mod calculate_margin_requirement_and_total_collateral { assert_eq!(margin_requirement, 40000000000); } + #[test] + pub fn user_and_position_max_margin_ratio_initial_vs_maintenance() { + // Four scenarios: user vs perp_position max_margin_ratio for Initial vs Maintenance. + // Maintenance always uses market-only (custom = 0). Initial uses max(user, position). + let slot = 0_u64; + + let mut sol_oracle_price = get_pyth_price(100, 6); + let sol_oracle_price_key = + Pubkey::from_str("J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix").unwrap(); + let pyth_program = crate::ids::pyth_program::id(); + create_account_info!( + sol_oracle_price, + &sol_oracle_price_key, + &pyth_program, + oracle_account_info + ); + let mut oracle_map = OracleMap::load_one(&oracle_account_info, slot, None).unwrap(); + + let mut market = PerpMarket { + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 100 * PEG_PRECISION, + order_step_size: 10000000, + oracle: sol_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, + margin_ratio_maintenance: 500, + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + create_anchor_account_info!(market, PerpMarket, market_account_info); + let perp_market_map = PerpMarketMap::load_one(&market_account_info, true).unwrap(); + + let mut usdc_spot_market = SpotMarket { + market_index: 0, + oracle_source: OracleSource::QuoteAsset, + cumulative_deposit_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + decimals: 6, + initial_asset_weight: SPOT_WEIGHT_PRECISION, + maintenance_asset_weight: SPOT_WEIGHT_PRECISION, + deposit_balance: 10000 * SPOT_BALANCE_PRECISION, + liquidator_fee: 0, + historical_oracle_data: HistoricalOracleData::default_quote_oracle(), + ..SpotMarket::default() + }; + create_anchor_account_info!(usdc_spot_market, SpotMarket, usdc_spot_market_account_info); + let mut sol_spot_market = SpotMarket { + market_index: 1, + oracle_source: OracleSource::Pyth, + oracle: sol_oracle_price_key, + cumulative_deposit_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + cumulative_borrow_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + decimals: 9, + initial_asset_weight: 8 * SPOT_WEIGHT_PRECISION / 10, + maintenance_asset_weight: 9 * SPOT_WEIGHT_PRECISION / 10, + initial_liability_weight: 12 * SPOT_WEIGHT_PRECISION / 10, + maintenance_liability_weight: 11 * SPOT_WEIGHT_PRECISION / 10, + liquidator_fee: LIQUIDATION_FEE_PRECISION / 1000, + ..SpotMarket::default() + }; + create_anchor_account_info!(sol_spot_market, SpotMarket, sol_spot_market_account_info); + let spot_market_account_infos = Vec::from([ + &usdc_spot_market_account_info, + &sol_spot_market_account_info, + ]); + let spot_market_map = + SpotMarketMap::load_multiple(spot_market_account_infos, true).unwrap(); + + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 100 * SPOT_BALANCE_PRECISION_U64, + ..SpotPosition::default() + }; + + // Baseline: no custom ratios → maintenance margin = market-only (100 * $100 * 0.05 = 500 in quote → 500000000) + let user_baseline = User { + orders: [Order::default(); 32], + perp_positions: get_positions(PerpPosition { + market_index: 0, + base_asset_amount: 100 * BASE_PRECISION_I64, + max_margin_ratio: 0, + ..PerpPosition::default() + }), + spot_positions, + max_margin_ratio: 0, + ..User::default() + }; + let MarginCalculation { + margin_requirement: maintenance_baseline, + .. + } = calculate_margin_requirement_and_total_collateral_and_liability_info( + &user_baseline, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + MarginContext::standard(MarginRequirementType::Maintenance), + ) + .unwrap(); + assert_eq!(maintenance_baseline, 500000000); // market maintenance only: 10000 * 500 / MARGIN_PRECISION + + // Scenario 1: User max_margin_ratio higher than perp position — Maintenance → market-only (custom = 0) + let user_high = User { + max_margin_ratio: 4 * MARGIN_PRECISION, + perp_positions: get_positions(PerpPosition { + market_index: 0, + base_asset_amount: 100 * BASE_PRECISION_I64, + max_margin_ratio: 2 * MARGIN_PRECISION as u16, + ..PerpPosition::default() + }), + ..user_baseline + }; + let MarginCalculation { + margin_requirement: maintenance_user_higher, + .. + } = calculate_margin_requirement_and_total_collateral_and_liability_info( + &user_high, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + MarginContext::standard(MarginRequirementType::Maintenance), + ) + .unwrap(); + assert_eq!( + maintenance_user_higher, maintenance_baseline, + "Maintenance must use market-only when user ratio is higher than position" + ); + + // Scenario 2: User max_margin_ratio higher than perp position — Initial → use user ratio (4 * MARGIN_PRECISION) + let MarginCalculation { + margin_requirement: initial_user_higher, + .. + } = calculate_margin_requirement_and_total_collateral_and_liability_info( + &user_high, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + MarginContext::standard(MarginRequirementType::Initial), + ) + .unwrap(); + assert_eq!( + initial_user_higher, 40000000000, + "Initial must use user.max_margin_ratio when user > position" + ); + + // Scenario 3: User max_margin_ratio lower than perp position — Maintenance → market-only + let user_low = User { + max_margin_ratio: MARGIN_PRECISION / 2, + perp_positions: get_positions(PerpPosition { + market_index: 0, + base_asset_amount: 100 * BASE_PRECISION_I64, + max_margin_ratio: 4 * MARGIN_PRECISION as u16, + ..PerpPosition::default() + }), + ..user_baseline + }; + let MarginCalculation { + margin_requirement: maintenance_user_lower, + .. + } = calculate_margin_requirement_and_total_collateral_and_liability_info( + &user_low, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + MarginContext::standard(MarginRequirementType::Maintenance), + ) + .unwrap(); + assert_eq!( + maintenance_user_lower, maintenance_baseline, + "Maintenance must use market-only when position ratio is higher than user" + ); + + // Scenario 4: User max_margin_ratio lower than perp position — Initial → use position ratio (4 * MARGIN_PRECISION) + let MarginCalculation { + margin_requirement: initial_user_lower, + .. + } = calculate_margin_requirement_and_total_collateral_and_liability_info( + &user_low, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + MarginContext::standard(MarginRequirementType::Initial), + ) + .unwrap(); + assert_eq!( + initial_user_lower, 40000000000, + "Initial must use perp_position.max_margin_ratio when position > user" + ); + } + #[test] pub fn user_dust_deposit() { let slot = 0_u64; @@ -4775,4 +4973,3601 @@ mod get_margin_calculation_for_disable_high_leverage_mode { // should not change user assert_eq!(user, user_before); } + + mod margin_type_config { + use crate::math::margin::MarginRequirementType; + use crate::state::margin_calculation::MarginTypeConfig; + + #[test] + fn default_returns_same_type_for_cross_and_isolated() { + // Test with Initial + let config = MarginTypeConfig::Default(MarginRequirementType::Initial); + assert_eq!( + config.get_cross_margin_requirement_type(), + MarginRequirementType::Initial + ); + assert_eq!( + config.get_isolated_margin_requirement_type(0), + MarginRequirementType::Initial + ); + assert_eq!( + config.get_isolated_margin_requirement_type(1), + MarginRequirementType::Initial + ); + assert_eq!( + config.get_isolated_margin_requirement_type(99), + MarginRequirementType::Initial + ); + + // Test with Maintenance + let config = MarginTypeConfig::Default(MarginRequirementType::Maintenance); + assert_eq!( + config.get_cross_margin_requirement_type(), + MarginRequirementType::Maintenance + ); + assert_eq!( + config.get_isolated_margin_requirement_type(0), + MarginRequirementType::Maintenance + ); + assert_eq!( + config.get_isolated_margin_requirement_type(1), + MarginRequirementType::Maintenance + ); + + // Test with Fill + let config = MarginTypeConfig::Default(MarginRequirementType::Fill); + assert_eq!( + config.get_cross_margin_requirement_type(), + MarginRequirementType::Fill + ); + assert_eq!( + config.get_isolated_margin_requirement_type(0), + MarginRequirementType::Fill + ); + } + + #[test] + fn isolated_position_override_cross_uses_default() { + // When using IsolatedPositionOverride, cross margin should use the default type + let config = MarginTypeConfig::IsolatedPositionOverride { + market_index: 0, + margin_requirement_type: MarginRequirementType::Initial, + default_isolated_margin_requirement_type: MarginRequirementType::Maintenance, + cross_margin_requirement_type: MarginRequirementType::Initial, + }; + + // Cross margin should get the default (Initial) + assert_eq!( + config.get_cross_margin_requirement_type(), + MarginRequirementType::Initial + ); + } + + #[test] + fn isolated_position_override_matching_market_uses_override() { + let config = MarginTypeConfig::IsolatedPositionOverride { + market_index: 5, + margin_requirement_type: MarginRequirementType::Initial, + default_isolated_margin_requirement_type: MarginRequirementType::Maintenance, + cross_margin_requirement_type: MarginRequirementType::Initial, + }; + + // The matching market index should get the override (Initial) + assert_eq!( + config.get_isolated_margin_requirement_type(5), + MarginRequirementType::Initial + ); + } + + #[test] + fn isolated_position_override_non_matching_market_uses_default() { + let config = MarginTypeConfig::IsolatedPositionOverride { + market_index: 5, + margin_requirement_type: MarginRequirementType::Initial, + default_isolated_margin_requirement_type: MarginRequirementType::Maintenance, + cross_margin_requirement_type: MarginRequirementType::Initial, + }; + + // Non-matching market indexes should get the default (Maintenance) + assert_eq!( + config.get_isolated_margin_requirement_type(0), + MarginRequirementType::Maintenance + ); + assert_eq!( + config.get_isolated_margin_requirement_type(1), + MarginRequirementType::Maintenance + ); + assert_eq!( + config.get_isolated_margin_requirement_type(4), + MarginRequirementType::Maintenance + ); + assert_eq!( + config.get_isolated_margin_requirement_type(6), + MarginRequirementType::Maintenance + ); + assert_eq!( + config.get_isolated_margin_requirement_type(99), + MarginRequirementType::Maintenance + ); + } + + #[test] + fn cross_margin_override_cross_uses_override() { + // When using CrossMarginOverride, cross margin should use the override type + let config = MarginTypeConfig::CrossMarginOverride { + margin_requirement_type: MarginRequirementType::Initial, + default_margin_requirement_type: MarginRequirementType::Maintenance, + }; + + // Cross margin should get the override (Initial) + assert_eq!( + config.get_cross_margin_requirement_type(), + MarginRequirementType::Initial + ); + } + + #[test] + fn cross_margin_override_all_isolated_use_default() { + // When using CrossMarginOverride, all isolated positions should use the default type + let config = MarginTypeConfig::CrossMarginOverride { + margin_requirement_type: MarginRequirementType::Initial, + default_margin_requirement_type: MarginRequirementType::Maintenance, + }; + + // All isolated positions should get the default (Maintenance) + assert_eq!( + config.get_isolated_margin_requirement_type(0), + MarginRequirementType::Maintenance + ); + assert_eq!( + config.get_isolated_margin_requirement_type(1), + MarginRequirementType::Maintenance + ); + assert_eq!( + config.get_isolated_margin_requirement_type(5), + MarginRequirementType::Maintenance + ); + assert_eq!( + config.get_isolated_margin_requirement_type(99), + MarginRequirementType::Maintenance + ); + } + + #[test] + fn scenario_increase_cross_position_size() { + // Scenario: User has cross position + multiple isolated positions + // They want to increase size on cross account (risk increasing) + // Expected: Cross = Initial, All isolated = Maintenance + let config = MarginTypeConfig::CrossMarginOverride { + margin_requirement_type: MarginRequirementType::Initial, + default_margin_requirement_type: MarginRequirementType::Maintenance, + }; + + // Cross position gets Initial (stricter check for risk increasing) + assert_eq!( + config.get_cross_margin_requirement_type(), + MarginRequirementType::Initial + ); + + // SOL-PERP isolated (market 0) gets Maintenance + assert_eq!( + config.get_isolated_margin_requirement_type(0), + MarginRequirementType::Maintenance + ); + + // ETH-PERP isolated (market 1) gets Maintenance + assert_eq!( + config.get_isolated_margin_requirement_type(1), + MarginRequirementType::Maintenance + ); + + // BTC-PERP isolated (market 2) gets Maintenance + assert_eq!( + config.get_isolated_margin_requirement_type(2), + MarginRequirementType::Maintenance + ); + } + + #[test] + fn scenario_increase_isolated_position_size() { + // Scenario: User has cross position + multiple isolated positions + // They want to increase size on SOL-PERP isolated (market 0) (risk increasing) + // Expected: SOL-PERP = Initial, Cross + other isolated = Maintenance + let config = MarginTypeConfig::IsolatedPositionOverride { + market_index: 0, // SOL-PERP + margin_requirement_type: MarginRequirementType::Initial, + default_isolated_margin_requirement_type: MarginRequirementType::Maintenance, + cross_margin_requirement_type: MarginRequirementType::Initial, + }; + + // Cross position gets default (Initial) + assert_eq!( + config.get_cross_margin_requirement_type(), + MarginRequirementType::Initial + ); + + // SOL-PERP isolated (market 0) gets Initial (stricter check for risk increasing) + assert_eq!( + config.get_isolated_margin_requirement_type(0), + MarginRequirementType::Initial + ); + + // ETH-PERP isolated (market 1) gets Maintenance + assert_eq!( + config.get_isolated_margin_requirement_type(1), + MarginRequirementType::Maintenance + ); + + // BTC-PERP isolated (market 2) gets Maintenance + assert_eq!( + config.get_isolated_margin_requirement_type(2), + MarginRequirementType::Maintenance + ); + } + + #[test] + fn scenario_reduce_position_size() { + // Scenario: User is reducing position size (not risk increasing) + // Expected: Everything uses Maintenance + let config = MarginTypeConfig::Default(MarginRequirementType::Maintenance); + + // Cross position gets Maintenance + assert_eq!( + config.get_cross_margin_requirement_type(), + MarginRequirementType::Maintenance + ); + + // All isolated positions get Maintenance + assert_eq!( + config.get_isolated_margin_requirement_type(0), + MarginRequirementType::Maintenance + ); + assert_eq!( + config.get_isolated_margin_requirement_type(1), + MarginRequirementType::Maintenance + ); + assert_eq!( + config.get_isolated_margin_requirement_type(2), + MarginRequirementType::Maintenance + ); + } + + #[test] + fn fill_margin_type_scenarios() { + // Test with Fill margin type (used for maker fills) + let config = MarginTypeConfig::IsolatedPositionOverride { + market_index: 3, + margin_requirement_type: MarginRequirementType::Fill, + default_isolated_margin_requirement_type: MarginRequirementType::Maintenance, + cross_margin_requirement_type: MarginRequirementType::Initial, + }; + + assert_eq!( + config.get_cross_margin_requirement_type(), + MarginRequirementType::Initial + ); + assert_eq!( + config.get_isolated_margin_requirement_type(3), + MarginRequirementType::Fill + ); + assert_eq!( + config.get_isolated_margin_requirement_type(0), + MarginRequirementType::Maintenance + ); + + let config = MarginTypeConfig::CrossMarginOverride { + margin_requirement_type: MarginRequirementType::Fill, + default_margin_requirement_type: MarginRequirementType::Maintenance, + }; + + assert_eq!( + config.get_cross_margin_requirement_type(), + MarginRequirementType::Fill + ); + assert_eq!( + config.get_isolated_margin_requirement_type(0), + MarginRequirementType::Maintenance + ); + } + } + + mod meets_place_order_margin_requirement_with_isolated { + use anchor_lang::Owner; + use std::str::FromStr; + + use anchor_lang::prelude::Pubkey; + + use crate::controller::position::PositionDirection; + use crate::create_account_info; + use crate::math::constants::{ + AMM_RESERVE_PRECISION, BASE_PRECISION_I64, BASE_PRECISION_U64, PEG_PRECISION, + SPOT_BALANCE_PRECISION, SPOT_BALANCE_PRECISION_U64, SPOT_CUMULATIVE_INTEREST_PRECISION, + SPOT_WEIGHT_PRECISION, + }; + use crate::math::margin::meets_place_order_margin_requirement; + use crate::state::oracle::{HistoricalOracleData, OracleSource}; + use crate::state::oracle_map::OracleMap; + use crate::state::perp_market::{MarketStatus, PerpMarket, AMM}; + use crate::state::perp_market_map::PerpMarketMap; + use crate::state::spot_market::{SpotBalanceType, SpotMarket}; + use crate::state::spot_market_map::SpotMarketMap; + use crate::state::user::{ + MarketType, Order, OrderStatus, OrderType, PerpPosition, PositionFlag, SpotPosition, + User, + }; + use crate::test_utils::get_pyth_price; + use crate::test_utils::*; + use crate::{create_anchor_account_info, QUOTE_PRECISION_I64}; + + #[test] + fn cross_order_passes_when_isolated_fails_initial_but_passes_maintenance() { + // Scenario: + // - User has a cross account USDC deposit (collateral) + // - User has an isolated SOL-PERP position that: + // - FAILS initial margin check + // - PASSES maintenance margin check + // - User submits a cross account order (risk increasing) + // - Expected: Order should PASS because isolated only needs maintenance when + // the order is for a cross position + + let slot = 0_u64; + + let mut sol_oracle_price = get_pyth_price(100, 6); + let sol_oracle_price_key = + Pubkey::from_str("J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix").unwrap(); + let pyth_program = crate::ids::pyth_program::id(); + create_account_info!( + sol_oracle_price, + &sol_oracle_price_key, + &pyth_program, + oracle_account_info + ); + let mut oracle_map = OracleMap::load_one(&oracle_account_info, slot, None).unwrap(); + + // SOL-PERP market with 10% initial margin, 5% maintenance margin + let mut sol_perp_market = PerpMarket { + market_index: 0, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 100 * PEG_PRECISION, + order_step_size: 10000000, + oracle: sol_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, // 10% + margin_ratio_maintenance: 500, // 5% + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + create_anchor_account_info!(sol_perp_market, PerpMarket, sol_perp_market_account_info); + let perp_market_map = + PerpMarketMap::load_one(&sol_perp_market_account_info, true).unwrap(); + + let mut usdc_spot_market = SpotMarket { + market_index: 0, + oracle_source: OracleSource::QuoteAsset, + cumulative_deposit_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + decimals: 6, + initial_asset_weight: SPOT_WEIGHT_PRECISION, + maintenance_asset_weight: SPOT_WEIGHT_PRECISION, + deposit_balance: 10000 * SPOT_BALANCE_PRECISION, + liquidator_fee: 0, + historical_oracle_data: HistoricalOracleData::default_quote_oracle(), + ..SpotMarket::default() + }; + create_anchor_account_info!( + usdc_spot_market, + SpotMarket, + usdc_spot_market_account_info + ); + let spot_market_account_infos = Vec::from([&usdc_spot_market_account_info]); + let spot_market_map = + SpotMarketMap::load_multiple(spot_market_account_infos, true).unwrap(); + + // User has: + // - 1000 USDC cross collateral + // - Isolated SOL-PERP position: 10 SOL long @ $100 = $1000 notional + // - With $70 isolated collateral + // - Initial margin required: $1000 * 10% = $100 (FAILS - only has $70) + // - Maintenance margin required: $1000 * 5% = $50 (PASSES - has $70) + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 1000 * SPOT_BALANCE_PRECISION_U64, // 1000 USDC cross collateral + ..SpotPosition::default() + }; + + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 10 * BASE_PRECISION_I64, // 10 SOL long + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, // Entry at $100 + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 70 * SPOT_BALANCE_PRECISION_U64, // $70 isolated collateral + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + // Test: Place a cross order (risk_increasing = true, isolated_market_index = None) + // This should use CrossMarginOverride: cross=Initial, isolated=Maintenance + // The isolated position should pass with maintenance check + let result = meets_place_order_margin_requirement( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + true, // risk_increasing + None, // isolated_market_index = None means this is a cross order + ); + + // Should pass because: + // - Cross margin: 1000 USDC collateral, no cross positions = passes Initial + // - Isolated SOL-PERP: $70 collateral >= $50 maintenance margin = passes Maintenance + assert!( + result.is_ok(), + "Cross order should pass when isolated position passes maintenance margin. Error: {:?}", + result + ); + } + + #[test] + fn cross_order_passes_when_cross_passes_initial_no_other_isolated() { + // Scenario: Cross PI, no isolated positions. Place cross order (risk increasing). + // Expected: PASS (cross must pass Initial when risk increasing; no isolated to check). + let slot = 0_u64; + let mut sol_oracle_price = get_pyth_price(100, 6); + let sol_oracle_price_key = + Pubkey::from_str("J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix").unwrap(); + let pyth_program = crate::ids::pyth_program::id(); + create_account_info!( + sol_oracle_price, + &sol_oracle_price_key, + &pyth_program, + oracle_account_info + ); + let mut oracle_map = OracleMap::load_one(&oracle_account_info, slot, None).unwrap(); + + let mut sol_perp_market = PerpMarket { + market_index: 0, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 100 * PEG_PRECISION, + order_step_size: 10000000, + oracle: sol_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, + margin_ratio_maintenance: 500, + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + create_anchor_account_info!(sol_perp_market, PerpMarket, sol_perp_market_account_info); + let perp_market_map = + PerpMarketMap::load_one(&sol_perp_market_account_info, true).unwrap(); + + let mut usdc_spot_market = SpotMarket { + market_index: 0, + oracle_source: OracleSource::QuoteAsset, + cumulative_deposit_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + decimals: 6, + initial_asset_weight: SPOT_WEIGHT_PRECISION, + maintenance_asset_weight: SPOT_WEIGHT_PRECISION, + deposit_balance: 10000 * SPOT_BALANCE_PRECISION, + liquidator_fee: 0, + historical_oracle_data: HistoricalOracleData::default_quote_oracle(), + ..SpotMarket::default() + }; + create_anchor_account_info!( + usdc_spot_market, + SpotMarket, + usdc_spot_market_account_info + ); + let spot_market_account_infos = Vec::from([&usdc_spot_market_account_info]); + let spot_market_map = + SpotMarketMap::load_multiple(spot_market_account_infos, true).unwrap(); + + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 1000 * SPOT_BALANCE_PRECISION_U64, + ..SpotPosition::default() + }; + let perp_positions = [PerpPosition::default(); 8]; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let result = meets_place_order_margin_requirement( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + true, + None, + ); + assert!( + result.is_ok(), + "Cross order should pass when cross passes initial and no isolated. Error: {:?}", + result + ); + } + + #[test] + fn cross_order_fails_when_other_isolated_fails_maintenance() { + // Scenario: Cross PI, one isolated with collateral < MM ($40 for $50 MM). Place cross order. + // Expected: FAIL (other isolated must pass Maintenance). + let slot = 0_u64; + let mut sol_oracle_price = get_pyth_price(100, 6); + let sol_oracle_price_key = + Pubkey::from_str("J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix").unwrap(); + let pyth_program = crate::ids::pyth_program::id(); + create_account_info!( + sol_oracle_price, + &sol_oracle_price_key, + &pyth_program, + oracle_account_info + ); + let mut oracle_map = OracleMap::load_one(&oracle_account_info, slot, None).unwrap(); + + let mut sol_perp_market = PerpMarket { + market_index: 0, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 100 * PEG_PRECISION, + order_step_size: 10000000, + oracle: sol_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, + margin_ratio_maintenance: 500, + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + create_anchor_account_info!(sol_perp_market, PerpMarket, sol_perp_market_account_info); + let perp_market_map = + PerpMarketMap::load_one(&sol_perp_market_account_info, true).unwrap(); + + let mut usdc_spot_market = SpotMarket { + market_index: 0, + oracle_source: OracleSource::QuoteAsset, + cumulative_deposit_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + decimals: 6, + initial_asset_weight: SPOT_WEIGHT_PRECISION, + maintenance_asset_weight: SPOT_WEIGHT_PRECISION, + deposit_balance: 10000 * SPOT_BALANCE_PRECISION, + liquidator_fee: 0, + historical_oracle_data: HistoricalOracleData::default_quote_oracle(), + ..SpotMarket::default() + }; + create_anchor_account_info!( + usdc_spot_market, + SpotMarket, + usdc_spot_market_account_info + ); + let spot_market_account_infos = Vec::from([&usdc_spot_market_account_info]); + let spot_market_map = + SpotMarketMap::load_multiple(spot_market_account_infos, true).unwrap(); + + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 1000 * SPOT_BALANCE_PRECISION_U64, + ..SpotPosition::default() + }; + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 10 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 40 * SPOT_BALANCE_PRECISION_U64, // $40 < $50 MM + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let result = meets_place_order_margin_requirement( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + true, + None, + ); + assert!( + result.is_err(), + "Cross order should fail when other isolated fails maintenance margin" + ); + } + + #[test] + fn cross_order_fails_when_cross_only_passes_maintenance() { + // Scenario: Cross PM (collateral $70, IM $100, MM $50 for $1000 notional), no isolated. + // Place cross order (risk increasing). Expected: FAIL (cross must pass Initial). + let slot = 0_u64; + let mut sol_oracle_price = get_pyth_price(100, 6); + let sol_oracle_price_key = + Pubkey::from_str("J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix").unwrap(); + let pyth_program = crate::ids::pyth_program::id(); + create_account_info!( + sol_oracle_price, + &sol_oracle_price_key, + &pyth_program, + oracle_account_info + ); + let mut oracle_map = OracleMap::load_one(&oracle_account_info, slot, None).unwrap(); + + let mut sol_perp_market = PerpMarket { + market_index: 0, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 100 * PEG_PRECISION, + order_step_size: 10000000, + oracle: sol_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, + margin_ratio_maintenance: 500, + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + create_anchor_account_info!(sol_perp_market, PerpMarket, sol_perp_market_account_info); + let perp_market_map = + PerpMarketMap::load_one(&sol_perp_market_account_info, true).unwrap(); + + let mut usdc_spot_market = SpotMarket { + market_index: 0, + oracle_source: OracleSource::QuoteAsset, + cumulative_deposit_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + decimals: 6, + initial_asset_weight: SPOT_WEIGHT_PRECISION, + maintenance_asset_weight: SPOT_WEIGHT_PRECISION, + deposit_balance: 10000 * SPOT_BALANCE_PRECISION, + liquidator_fee: 0, + historical_oracle_data: HistoricalOracleData::default_quote_oracle(), + ..SpotMarket::default() + }; + create_anchor_account_info!( + usdc_spot_market, + SpotMarket, + usdc_spot_market_account_info + ); + let spot_market_account_infos = Vec::from([&usdc_spot_market_account_info]); + let spot_market_map = + SpotMarketMap::load_multiple(spot_market_account_infos, true).unwrap(); + + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 70 * SPOT_BALANCE_PRECISION_U64, // $70: >= MM $50, < IM $100 + ..SpotPosition::default() + }; + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 10 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + // Cross position (no isolated flag) + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let result = meets_place_order_margin_requirement( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + true, + None, + ); + assert!( + result.is_err(), + "Cross order should fail when cross only passes maintenance" + ); + } + + #[test] + fn cross_order_fails_when_cross_fails_maintenance() { + // Scenario: Cross FM (collateral $40 < MM $50 for $1000 notional). Place cross order. + // Expected: FAIL. + let slot = 0_u64; + let mut sol_oracle_price = get_pyth_price(100, 6); + let sol_oracle_price_key = + Pubkey::from_str("J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix").unwrap(); + let pyth_program = crate::ids::pyth_program::id(); + create_account_info!( + sol_oracle_price, + &sol_oracle_price_key, + &pyth_program, + oracle_account_info + ); + let mut oracle_map = OracleMap::load_one(&oracle_account_info, slot, None).unwrap(); + + let mut sol_perp_market = PerpMarket { + market_index: 0, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 100 * PEG_PRECISION, + order_step_size: 10000000, + oracle: sol_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, + margin_ratio_maintenance: 500, + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + create_anchor_account_info!(sol_perp_market, PerpMarket, sol_perp_market_account_info); + let perp_market_map = + PerpMarketMap::load_one(&sol_perp_market_account_info, true).unwrap(); + + let mut usdc_spot_market = SpotMarket { + market_index: 0, + oracle_source: OracleSource::QuoteAsset, + cumulative_deposit_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + decimals: 6, + initial_asset_weight: SPOT_WEIGHT_PRECISION, + maintenance_asset_weight: SPOT_WEIGHT_PRECISION, + deposit_balance: 10000 * SPOT_BALANCE_PRECISION, + liquidator_fee: 0, + historical_oracle_data: HistoricalOracleData::default_quote_oracle(), + ..SpotMarket::default() + }; + create_anchor_account_info!( + usdc_spot_market, + SpotMarket, + usdc_spot_market_account_info + ); + let spot_market_account_infos = Vec::from([&usdc_spot_market_account_info]); + let spot_market_map = + SpotMarketMap::load_multiple(spot_market_account_infos, true).unwrap(); + + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 40 * SPOT_BALANCE_PRECISION_U64, // $40 < MM $50 + ..SpotPosition::default() + }; + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 10 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let result = meets_place_order_margin_requirement( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + true, + None, + ); + assert!( + result.is_err(), + "Cross order should fail when cross fails maintenance" + ); + } + + #[test] + fn cross_order_not_risk_increasing_passes_when_all_pass_maintenance() { + // Scenario: Cross PM (or PI), no other isolated. risk_increasing: false -> all Maintenance. + // Expected: PASS. + let slot = 0_u64; + let mut sol_oracle_price = get_pyth_price(100, 6); + let sol_oracle_price_key = + Pubkey::from_str("J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix").unwrap(); + let pyth_program = crate::ids::pyth_program::id(); + create_account_info!( + sol_oracle_price, + &sol_oracle_price_key, + &pyth_program, + oracle_account_info + ); + let mut oracle_map = OracleMap::load_one(&oracle_account_info, slot, None).unwrap(); + + let mut sol_perp_market = PerpMarket { + market_index: 0, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 100 * PEG_PRECISION, + order_step_size: 10000000, + oracle: sol_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, + margin_ratio_maintenance: 500, + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + create_anchor_account_info!(sol_perp_market, PerpMarket, sol_perp_market_account_info); + let perp_market_map = + PerpMarketMap::load_one(&sol_perp_market_account_info, true).unwrap(); + + let mut usdc_spot_market = SpotMarket { + market_index: 0, + oracle_source: OracleSource::QuoteAsset, + cumulative_deposit_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + decimals: 6, + initial_asset_weight: SPOT_WEIGHT_PRECISION, + maintenance_asset_weight: SPOT_WEIGHT_PRECISION, + deposit_balance: 10000 * SPOT_BALANCE_PRECISION, + liquidator_fee: 0, + historical_oracle_data: HistoricalOracleData::default_quote_oracle(), + ..SpotMarket::default() + }; + create_anchor_account_info!( + usdc_spot_market, + SpotMarket, + usdc_spot_market_account_info + ); + let spot_market_account_infos = Vec::from([&usdc_spot_market_account_info]); + let spot_market_map = + SpotMarketMap::load_multiple(spot_market_account_infos, true).unwrap(); + + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 70 * SPOT_BALANCE_PRECISION_U64, // PM: >= MM $50 + ..SpotPosition::default() + }; + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 10 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let result = meets_place_order_margin_requirement( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + false, // not risk increasing -> Maintenance only + None, + ); + assert!( + result.is_ok(), + "Cross order not risk increasing should pass when all pass maintenance. Error: {:?}", + result + ); + } + + #[test] + fn cross_order_not_risk_increasing_fails_when_other_isolated_fails_maintenance() { + // Scenario: Cross PI, other isolated FM. risk_increasing: false. Expected: FAIL. + let slot = 0_u64; + let mut sol_oracle_price = get_pyth_price(100, 6); + let sol_oracle_price_key = + Pubkey::from_str("J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix").unwrap(); + let pyth_program = crate::ids::pyth_program::id(); + create_account_info!( + sol_oracle_price, + &sol_oracle_price_key, + &pyth_program, + oracle_account_info + ); + let mut oracle_map = OracleMap::load_one(&oracle_account_info, slot, None).unwrap(); + + let mut sol_perp_market = PerpMarket { + market_index: 0, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 100 * PEG_PRECISION, + order_step_size: 10000000, + oracle: sol_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, + margin_ratio_maintenance: 500, + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + create_anchor_account_info!(sol_perp_market, PerpMarket, sol_perp_market_account_info); + let perp_market_map = + PerpMarketMap::load_one(&sol_perp_market_account_info, true).unwrap(); + + let mut usdc_spot_market = SpotMarket { + market_index: 0, + oracle_source: OracleSource::QuoteAsset, + cumulative_deposit_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + decimals: 6, + initial_asset_weight: SPOT_WEIGHT_PRECISION, + maintenance_asset_weight: SPOT_WEIGHT_PRECISION, + deposit_balance: 10000 * SPOT_BALANCE_PRECISION, + liquidator_fee: 0, + historical_oracle_data: HistoricalOracleData::default_quote_oracle(), + ..SpotMarket::default() + }; + create_anchor_account_info!( + usdc_spot_market, + SpotMarket, + usdc_spot_market_account_info + ); + let spot_market_account_infos = Vec::from([&usdc_spot_market_account_info]); + let spot_market_map = + SpotMarketMap::load_multiple(spot_market_account_infos, true).unwrap(); + + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 1000 * SPOT_BALANCE_PRECISION_U64, + ..SpotPosition::default() + }; + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 10 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 40 * SPOT_BALANCE_PRECISION_U64, // FM: < $50 MM + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let result = meets_place_order_margin_requirement( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + false, + None, + ); + assert!( + result.is_err(), + "Cross order not risk increasing should fail when other isolated fails maintenance" + ); + } + + #[test] + fn cross_order_not_risk_increasing_fails_when_cross_fails_maintenance() { + // Scenario: Cross FM. risk_increasing: false. Expected: FAIL. + let slot = 0_u64; + let mut sol_oracle_price = get_pyth_price(100, 6); + let sol_oracle_price_key = + Pubkey::from_str("J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix").unwrap(); + let pyth_program = crate::ids::pyth_program::id(); + create_account_info!( + sol_oracle_price, + &sol_oracle_price_key, + &pyth_program, + oracle_account_info + ); + let mut oracle_map = OracleMap::load_one(&oracle_account_info, slot, None).unwrap(); + + let mut sol_perp_market = PerpMarket { + market_index: 0, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 100 * PEG_PRECISION, + order_step_size: 10000000, + oracle: sol_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, + margin_ratio_maintenance: 500, + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + create_anchor_account_info!(sol_perp_market, PerpMarket, sol_perp_market_account_info); + let perp_market_map = + PerpMarketMap::load_one(&sol_perp_market_account_info, true).unwrap(); + + let mut usdc_spot_market = SpotMarket { + market_index: 0, + oracle_source: OracleSource::QuoteAsset, + cumulative_deposit_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + decimals: 6, + initial_asset_weight: SPOT_WEIGHT_PRECISION, + maintenance_asset_weight: SPOT_WEIGHT_PRECISION, + deposit_balance: 10000 * SPOT_BALANCE_PRECISION, + liquidator_fee: 0, + historical_oracle_data: HistoricalOracleData::default_quote_oracle(), + ..SpotMarket::default() + }; + create_anchor_account_info!( + usdc_spot_market, + SpotMarket, + usdc_spot_market_account_info + ); + let spot_market_account_infos = Vec::from([&usdc_spot_market_account_info]); + let spot_market_map = + SpotMarketMap::load_multiple(spot_market_account_infos, true).unwrap(); + + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 40 * SPOT_BALANCE_PRECISION_U64, // < MM $50 + ..SpotPosition::default() + }; + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 10 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let result = meets_place_order_margin_requirement( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + false, + None, + ); + assert!( + result.is_err(), + "Cross order not risk increasing should fail when cross fails maintenance" + ); + } + + #[test] + fn isolated_order_passes_when_other_isolated_fails_initial_but_passes_maintenance() { + // Scenario: + // - User has a cross account USDC deposit (collateral) + // - User has an isolated SOL-PERP position that: + // - FAILS initial margin check + // - PASSES maintenance margin check + // - User submits an ETH-PERP order on an isolated position which increases risk + // - Expected: Order should PASS because separate isolated position should only + // need maintenance margin requirement + + let slot = 0_u64; + + let pyth_program = crate::ids::pyth_program::id(); + let mut sol_oracle_price = get_pyth_price(100, 6); + let sol_oracle_price_key = + Pubkey::from_str("J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix").unwrap(); + create_account_info!( + sol_oracle_price, + &sol_oracle_price_key, + &pyth_program, + sol_oracle_account_info + ); + let mut eth_oracle_price = get_pyth_price(1000, 6); + let eth_oracle_price_key = + Pubkey::from_str("AHRAk64kPiGwkbkisDvjVYzq6Ho5Q2wQSj28vAaAt7Tq").unwrap(); + create_account_info!( + eth_oracle_price, + ð_oracle_price_key, + &pyth_program, + eth_oracle_account_info + ); + + let oracle_account_infos = vec![sol_oracle_account_info, eth_oracle_account_info]; + let mut oracle_map = + OracleMap::load(&mut oracle_account_infos.iter().peekable(), slot, None).unwrap(); + + // SOL-PERP market with 10% initial margin, 5% maintenance margin + let mut sol_perp_market = PerpMarket { + market_index: 0, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 100 * PEG_PRECISION, + order_step_size: 10000000, + oracle: sol_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, // 10% + margin_ratio_maintenance: 500, // 5% + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + + let mut eth_perp_market = PerpMarket { + market_index: 2, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 100 * PEG_PRECISION, + order_step_size: 10000000, + oracle: eth_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, // 10% + margin_ratio_maintenance: 500, // 5% + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + create_anchor_account_info!(sol_perp_market, PerpMarket, sol_perp_market_account_info); + create_anchor_account_info!(eth_perp_market, PerpMarket, eth_perp_market_account_info); + let perp_market_map = PerpMarketMap::load_multiple( + vec![&sol_perp_market_account_info, ð_perp_market_account_info], + true, + ) + .unwrap(); + + let mut usdc_spot_market = SpotMarket { + market_index: 0, + oracle_source: OracleSource::QuoteAsset, + cumulative_deposit_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + decimals: 6, + initial_asset_weight: SPOT_WEIGHT_PRECISION, + maintenance_asset_weight: SPOT_WEIGHT_PRECISION, + deposit_balance: 10000 * SPOT_BALANCE_PRECISION, + liquidator_fee: 0, + historical_oracle_data: HistoricalOracleData::default_quote_oracle(), + ..SpotMarket::default() + }; + create_anchor_account_info!( + usdc_spot_market, + SpotMarket, + usdc_spot_market_account_info + ); + let spot_market_account_infos = Vec::from([&usdc_spot_market_account_info]); + let spot_market_map = + SpotMarketMap::load_multiple(spot_market_account_infos, true).unwrap(); + + // User has: + // - 1000 USDC cross collateral + // - Isolated SOL-PERP position: 10 SOL long @ $100 = $1000 notional + // - With $70 isolated collateral + // - Initial margin required: $1000 * 10% = $100 (PASSES - has $70) + // - Maintenance margin required: $1000 * 5% = $50 (FAILS - has $50) + // - Isolated ETH-PERP position: 1 ETH long @ $1000 = $1000 notional + // - With $200 isolated collateral + // - Initial margin required: $1000 * 10% = $100 (PASSES - has $200) + // - Maintenance margin required: $1000 * 5% = $50 (PASSES - has $70) + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 1000 * SPOT_BALANCE_PRECISION_U64, // 1000 USDC cross collateral + ..SpotPosition::default() + }; + + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 10 * BASE_PRECISION_I64, // 10 SOL long + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, // Entry at $100 + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 70 * SPOT_BALANCE_PRECISION_U64, // $70 isolated collateral + ..PerpPosition::default() + }; + perp_positions[1] = PerpPosition { + market_index: 2, + base_asset_amount: 1 * BASE_PRECISION_I64, // 1 ETH long + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, // Entry at $1000 + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 200 * SPOT_BALANCE_PRECISION_U64, // $1000 isolated collateral + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + // Test: Place a cross order (risk_increasing = true, isolated_market_index = None) + // This should use CrossMarginOverride: cross=Initial, isolated=Maintenance + // The isolated position should pass with maintenance check + let result = meets_place_order_margin_requirement( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + true, // risk_increasing + Some(2), // isolated_market_index = 2 means this is an ETH-PERP order + ); + + // Should pass because: + // - Cross margin: 1000 USDC collateral, no cross positions = passes Initial + // - Isolated ETH-PERP: $1000 collateral >= $500 maintenance margin = passes Maintenance + // - Isolated SOL-PERP: $70 collateral >= $50 maintenance margin = passes Maintenance + assert!( + result.is_ok(), + "Isolated ETH-PERP order should pass when other isolated position passes maintenance margin. Error: {:?}", + result + ); + } + + #[test] + fn isolated_order_fails_when_cross_account_fails_initial_margin() { + // Scenario: + // - User has a cross account that is FAILING initial margin (but passing maintenance) + // because they have a cross perp position that requires more margin than available + // - User tries to increase an isolated position + // - Expected: Order should SUCCEED because collateral for isolated positions comes from + // the cross account, but we already ran initial check at the transfer ix previous to this IRL + // so we only check maintenance at time of placing order + // + // This ensures users can't escape cross margin requirements by moving to isolated positions + + let slot = 0_u64; + + let pyth_program = crate::ids::pyth_program::id(); + let mut sol_oracle_price = get_pyth_price(100, 6); + let sol_oracle_price_key = + Pubkey::from_str("J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix").unwrap(); + create_account_info!( + sol_oracle_price, + &sol_oracle_price_key, + &pyth_program, + sol_oracle_account_info + ); + let mut eth_oracle_price = get_pyth_price(1000, 6); + let eth_oracle_price_key = + Pubkey::from_str("AHRAk64kPiGwkbkisDvjVYzq6Ho5Q2wQSj28vAaAt7Tq").unwrap(); + create_account_info!( + eth_oracle_price, + ð_oracle_price_key, + &pyth_program, + eth_oracle_account_info + ); + + let oracle_account_infos = vec![sol_oracle_account_info, eth_oracle_account_info]; + let mut oracle_map = + OracleMap::load(&mut oracle_account_infos.iter().peekable(), slot, None).unwrap(); + + // SOL-PERP market (cross position) with 10% initial margin, 5% maintenance margin + let mut sol_perp_market = PerpMarket { + market_index: 0, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 100 * PEG_PRECISION, + order_step_size: 10000000, + oracle: sol_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, // 10% + margin_ratio_maintenance: 500, // 5% + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + + // ETH-PERP market (isolated position) with 10% initial margin, 5% maintenance margin + let mut eth_perp_market = PerpMarket { + market_index: 2, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 1000 * PEG_PRECISION, + order_step_size: 10000000, + oracle: eth_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, // 10% + margin_ratio_maintenance: 500, // 5% + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + create_anchor_account_info!(sol_perp_market, PerpMarket, sol_perp_market_account_info); + create_anchor_account_info!(eth_perp_market, PerpMarket, eth_perp_market_account_info); + let perp_market_map = PerpMarketMap::load_multiple( + vec![&sol_perp_market_account_info, ð_perp_market_account_info], + true, + ) + .unwrap(); + + let mut usdc_spot_market = SpotMarket { + market_index: 0, + oracle_source: OracleSource::QuoteAsset, + cumulative_deposit_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + decimals: 6, + initial_asset_weight: SPOT_WEIGHT_PRECISION, + maintenance_asset_weight: SPOT_WEIGHT_PRECISION, + deposit_balance: 100000 * SPOT_BALANCE_PRECISION, + liquidator_fee: 0, + historical_oracle_data: HistoricalOracleData::default_quote_oracle(), + ..SpotMarket::default() + }; + create_anchor_account_info!( + usdc_spot_market, + SpotMarket, + usdc_spot_market_account_info + ); + let spot_market_account_infos = Vec::from([&usdc_spot_market_account_info]); + let spot_market_map = + SpotMarketMap::load_multiple(spot_market_account_infos, true).unwrap(); + + // User has: + // - 80 USDC cross collateral + // - Cross SOL-PERP position: 10 SOL long @ $100 = $1000 notional + // - Initial margin required: $1000 * 10% = $100 (doesn't matter, we check maintenance - only $80 cross collateral) + // - Maintenance margin required: $1000 * 5% = $50 (PASSES - $80 > $50) + // - Isolated ETH-PERP position: 1 ETH long @ $1000 = $1000 notional + // - With $200 isolated collateral + // - Initial margin required: $1000 * 10% = $100 (PASSES - has $200) + // - Maintenance margin required: $1000 * 5% = $50 (PASSES - has $200) + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 80 * SPOT_BALANCE_PRECISION_U64, // Only 80 USDC cross collateral + ..SpotPosition::default() + }; + + let mut perp_positions = [PerpPosition::default(); 8]; + // Cross SOL-PERP position (not isolated) + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 10 * BASE_PRECISION_I64, // 10 SOL long + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, // Entry at $100 + // No isolated flag - this is a cross position + ..PerpPosition::default() + }; + // Isolated ETH-PERP position + perp_positions[1] = PerpPosition { + market_index: 2, + base_asset_amount: 1 * BASE_PRECISION_I64, // 1 ETH long + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, // Entry at $1000 + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 200 * SPOT_BALANCE_PRECISION_U64, // $200 isolated collateral + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + // Test: Try to place an isolated ETH-PERP order (risk_increasing = true) + // This should use IsolatedPositionOverride: ETH-PERP=Initial, cross+others=Maintenance + // But since cross account is failing initial, we shouldn't allow this + let result = meets_place_order_margin_requirement( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + true, // risk_increasing + Some(2), // isolated_market_index = 2 means this is an ETH-PERP order + ); + + // Should SUCCEED because: + // - Cross margin with SOL-PERP: $80 collateral < $100 initial margin required + // - But it's more than $50, which is the maintenance margin required for the ETH-PERP position + assert!( + result.is_ok(), + "Isolated place order should succeed when cross account fails initial margin but has enough maintenance collateral" + ); + } + + #[test] + fn isolated_order_fails_when_isolated_deposit_would_make_cross_fail_initial_margin() { + // Scenario: + // - User has a cross account that is PASSING initial margin + // - User has an isolated position that is currently PASSING initial margin + // - User places an order to increase the isolated position + // - The order increases the worst-case position size, increasing IM required + // - The new IM required exceeds isolated collateral + // - The deposit required to cover the shortfall would make cross fail IM + // - Expected: Order should FAIL + // + // This ensures users can't increase isolated positions when the required + // deposit would make their cross account undercollateralized + // + // Key numbers: + // - Cross: $110 collateral, $100 IM required -> PASSES ($110 > $100) + // - Isolated position: 1 ETH = $1000 notional, $100 IM required + // - Isolated collateral: $110 -> PASSES current IM ($110 > $100) + // - Order: Buy 0.5 ETH more (open_bids = 0.5 ETH) + // - Worst case position: 1.5 ETH = $1500 notional = $150 IM required + // - Isolated collateral: $110 < $150 IM required -> FAILS + // - Shortfall: $40 + // - If cross deposits $40 to isolated: cross has $70 vs $100 IM -> FAILS + + let slot = 0_u64; + + let pyth_program = crate::ids::pyth_program::id(); + let mut sol_oracle_price = get_pyth_price(100, 6); + let sol_oracle_price_key = + Pubkey::from_str("J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix").unwrap(); + create_account_info!( + sol_oracle_price, + &sol_oracle_price_key, + &pyth_program, + sol_oracle_account_info + ); + let mut eth_oracle_price = get_pyth_price(1000, 6); + let eth_oracle_price_key = + Pubkey::from_str("AHRAk64kPiGwkbkisDvjVYzq6Ho5Q2wQSj28vAaAt7Tq").unwrap(); + create_account_info!( + eth_oracle_price, + ð_oracle_price_key, + &pyth_program, + eth_oracle_account_info + ); + + let oracle_account_infos = vec![sol_oracle_account_info, eth_oracle_account_info]; + let mut oracle_map = + OracleMap::load(&mut oracle_account_infos.iter().peekable(), slot, None).unwrap(); + + // SOL-PERP market (cross position) with 10% initial margin, 5% maintenance margin + let mut sol_perp_market = PerpMarket { + market_index: 0, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 100 * PEG_PRECISION, + order_step_size: 10000000, + oracle: sol_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, // 10% + margin_ratio_maintenance: 500, // 5% + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + + // ETH-PERP market (isolated position) with 10% initial margin, 5% maintenance margin + let mut eth_perp_market = PerpMarket { + market_index: 2, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 1000 * PEG_PRECISION, + order_step_size: 10000000, + oracle: eth_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, // 10% + margin_ratio_maintenance: 500, // 5% + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + create_anchor_account_info!(sol_perp_market, PerpMarket, sol_perp_market_account_info); + create_anchor_account_info!(eth_perp_market, PerpMarket, eth_perp_market_account_info); + let perp_market_map = PerpMarketMap::load_multiple( + vec![&sol_perp_market_account_info, ð_perp_market_account_info], + true, + ) + .unwrap(); + + let mut usdc_spot_market = SpotMarket { + market_index: 0, + oracle_source: OracleSource::QuoteAsset, + cumulative_deposit_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + decimals: 6, + initial_asset_weight: SPOT_WEIGHT_PRECISION, + maintenance_asset_weight: SPOT_WEIGHT_PRECISION, + deposit_balance: 100000 * SPOT_BALANCE_PRECISION, + liquidator_fee: 0, + historical_oracle_data: HistoricalOracleData::default_quote_oracle(), + ..SpotMarket::default() + }; + create_anchor_account_info!( + usdc_spot_market, + SpotMarket, + usdc_spot_market_account_info + ); + let spot_market_account_infos = Vec::from([&usdc_spot_market_account_info]); + let spot_market_map = + SpotMarketMap::load_multiple(spot_market_account_infos, true).unwrap(); + + // User has: + // - 110 USDC cross collateral (PASSES initial margin for cross position) + // - Cross SOL-PERP position: 10 SOL long @ $100 = $1000 notional + // - Initial margin required: $1000 * 10% = $100 (PASSES - $110 > $100) + // - Isolated ETH-PERP position: 1 ETH long @ $1000 = $1000 notional + // - With $110 isolated collateral + // - Current IM required: $1000 * 10% = $100 (PASSES - $110 > $100) + // - User places order to buy 0.5 ETH more (open_bids = 0.5 ETH) + // - Worst case position: 1.5 ETH = $1500 notional + // - New IM required: $1500 * 10% = $150 (FAILS - $110 < $150) + // - Shortfall: $40 + // - If cross deposits $40: cross has $70 vs $100 IM -> FAILS + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 110 * SPOT_BALANCE_PRECISION_U64, // 110 USDC cross collateral (passes IM) + ..SpotPosition::default() + }; + + let mut perp_positions = [PerpPosition::default(); 8]; + // Cross SOL-PERP position (not isolated) + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 10 * BASE_PRECISION_I64, // 10 SOL long + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, // Entry at $100 + // No isolated flag - this is a cross position + ..PerpPosition::default() + }; + // Isolated ETH-PERP position with sufficient collateral for current position, + // but with an open order that increases the worst-case position size + perp_positions[1] = PerpPosition { + market_index: 2, + base_asset_amount: 1 * BASE_PRECISION_I64, // 1 ETH long + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, // Entry at $1000 + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 110 * SPOT_BALANCE_PRECISION_U64, // $110 isolated collateral + open_orders: 1, // Has an open order + open_bids: BASE_PRECISION_I64 / 2, // Order to buy 0.5 ETH more + ..PerpPosition::default() + }; + + // Create an order for the isolated position + let mut orders = [Order::default(); 32]; + orders[0] = Order { + status: OrderStatus::Open, + order_type: OrderType::Limit, + market_type: MarketType::Perp, + market_index: 2, + direction: PositionDirection::Long, + base_asset_amount: BASE_PRECISION_U64 / 2, // 0.5 ETH + ..Order::default() + }; + + let user = User { + orders, + perp_positions, + spot_positions, + ..User::default() + }; + + // Test: Check margin after placing an isolated ETH-PERP order (risk_increasing = true) + // The order has already been "placed" by setting open_bids on the position + // This uses IsolatedPositionOverride: ETH-PERP=Initial, cross=Initial + // + // Margin calculation: + // - Worst case position = base_asset_amount + open_bids = 1 + 0.5 = 1.5 ETH + // - Worst case notional = 1.5 * $1000 = $1500 + // - IM required = $1500 * 10% = $150 + // - Isolated collateral = $110 < $150 -> FAILS isolated IM + let result = meets_place_order_margin_requirement( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + true, // risk_increasing + Some(2), // isolated_market_index = 2 means this is an ETH-PERP order + ); + + // Should FAIL because: + // - Worst case isolated ETH-PERP: 1.5 ETH = $1500 notional, $150 IM required + // - Isolated collateral: $110 < $150 -> FAILS + // - Cross passes IM ($110 > $100), but can't spare $40 without failing + // - If cross deposited $40 to isolated: cross would have $70 vs $100 IM -> fails + // + // This is different from the previous test where cross was already failing IM. + // Here, cross is passing IM, but the deposit required to fund the isolated + // position increase would make cross fail. + assert!( + result.is_err(), + "Isolated order should fail when deposit would make cross fail IM" + ); + } + + #[test] + fn isolated_order_passes_when_cross_has_plenty_of_collateral() { + // Scenario: + // - User has a cross account with plenty of USDC collateral + // - User has no existing positions + // - User opens a new isolated position + // - Expected: Order should PASS because cross has plenty of collateral + // + // Key numbers: + // - Cross: $1000 USDC collateral, no positions -> $0 IM required + // - New isolated position: 1 ETH = $1000 notional = $100 IM required + // - Isolated collateral provided: $150 (from cross) + // - After transfer: Cross has $850, still $0 IM required -> PASSES + + let slot = 0_u64; + + let pyth_program = crate::ids::pyth_program::id(); + let mut eth_oracle_price = get_pyth_price(1000, 6); + let eth_oracle_price_key = + Pubkey::from_str("AHRAk64kPiGwkbkisDvjVYzq6Ho5Q2wQSj28vAaAt7Tq").unwrap(); + create_account_info!( + eth_oracle_price, + ð_oracle_price_key, + &pyth_program, + eth_oracle_account_info + ); + + let oracle_account_infos = vec![eth_oracle_account_info]; + let mut oracle_map = + OracleMap::load(&mut oracle_account_infos.iter().peekable(), slot, None).unwrap(); + + // ETH-PERP market (isolated position) with 10% initial margin, 5% maintenance margin + let mut eth_perp_market = PerpMarket { + market_index: 2, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 1000 * PEG_PRECISION, + order_step_size: 10000000, + oracle: eth_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, // 10% + margin_ratio_maintenance: 500, // 5% + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + create_anchor_account_info!(eth_perp_market, PerpMarket, eth_perp_market_account_info); + let perp_market_map = + PerpMarketMap::load_one(ð_perp_market_account_info, true).unwrap(); + + let mut usdc_spot_market = SpotMarket { + market_index: 0, + oracle_source: OracleSource::QuoteAsset, + cumulative_deposit_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + decimals: 6, + initial_asset_weight: SPOT_WEIGHT_PRECISION, + maintenance_asset_weight: SPOT_WEIGHT_PRECISION, + deposit_balance: 100000 * SPOT_BALANCE_PRECISION, + liquidator_fee: 0, + historical_oracle_data: HistoricalOracleData::default_quote_oracle(), + ..SpotMarket::default() + }; + create_anchor_account_info!( + usdc_spot_market, + SpotMarket, + usdc_spot_market_account_info + ); + let spot_market_account_infos = Vec::from([&usdc_spot_market_account_info]); + let spot_market_map = + SpotMarketMap::load_multiple(spot_market_account_infos, true).unwrap(); + + // User has: + // - 1000 USDC cross collateral (plenty of buffer) + // - New isolated ETH-PERP position: 1 ETH long @ $1000 = $1000 notional + // - With $150 isolated collateral + // - IM required: $1000 * 10% = $100 (PASSES - $150 > $100) + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 1000 * SPOT_BALANCE_PRECISION_U64, // 1000 USDC cross collateral + ..SpotPosition::default() + }; + + let mut perp_positions = [PerpPosition::default(); 8]; + // New isolated ETH-PERP position with sufficient collateral + perp_positions[0] = PerpPosition { + market_index: 2, + base_asset_amount: 1 * BASE_PRECISION_I64, // 1 ETH long + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, // Entry at $1000 + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 150 * SPOT_BALANCE_PRECISION_U64, // $150 isolated collateral + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let result = meets_place_order_margin_requirement( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + true, + Some(2), + ); + + // Should PASS because: + // - Cross has $1000, no positions, $0 IM required -> PASSES + // - Isolated ETH-PERP: $150 collateral >= $100 IM required -> PASSES + assert!( + result.is_ok(), + "Isolated order should pass when cross has plenty of collateral. Error: {:?}", + result + ); + } + + #[test] + fn isolated_order_fails_when_other_isolated_fails_maintenance_margin() { + // Scenario: + // - User has a cross account with USDC collateral + // - User has an existing isolated SOL-PERP position that FAILS maintenance margin + // - User tries to open a new isolated ETH-PERP position + // - Expected: Order should FAIL because existing isolated position fails MM + // + // Key numbers: + // - Cross: $1000 USDC collateral + // - Existing isolated SOL-PERP: 10 SOL @ $100 = $1000 notional + // - MM required: $1000 * 5% = $50 + // - Isolated collateral: $40 < $50 -> FAILS MM + // - New isolated ETH-PERP order: would pass on its own + // - But since existing isolated fails MM, can't open new isolated + + let slot = 0_u64; + + let pyth_program = crate::ids::pyth_program::id(); + let mut sol_oracle_price = get_pyth_price(100, 6); + let sol_oracle_price_key = + Pubkey::from_str("J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix").unwrap(); + create_account_info!( + sol_oracle_price, + &sol_oracle_price_key, + &pyth_program, + sol_oracle_account_info + ); + let mut eth_oracle_price = get_pyth_price(1000, 6); + let eth_oracle_price_key = + Pubkey::from_str("AHRAk64kPiGwkbkisDvjVYzq6Ho5Q2wQSj28vAaAt7Tq").unwrap(); + create_account_info!( + eth_oracle_price, + ð_oracle_price_key, + &pyth_program, + eth_oracle_account_info + ); + + let oracle_account_infos = vec![sol_oracle_account_info, eth_oracle_account_info]; + let mut oracle_map = + OracleMap::load(&mut oracle_account_infos.iter().peekable(), slot, None).unwrap(); + + // SOL-PERP market with 10% initial margin, 5% maintenance margin + let mut sol_perp_market = PerpMarket { + market_index: 0, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 100 * PEG_PRECISION, + order_step_size: 10000000, + oracle: sol_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, // 10% + margin_ratio_maintenance: 500, // 5% + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + + // ETH-PERP market (new isolated position) with 10% initial margin, 5% maintenance margin + let mut eth_perp_market = PerpMarket { + market_index: 2, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 1000 * PEG_PRECISION, + order_step_size: 10000000, + oracle: eth_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, // 10% + margin_ratio_maintenance: 500, // 5% + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + create_anchor_account_info!(sol_perp_market, PerpMarket, sol_perp_market_account_info); + create_anchor_account_info!(eth_perp_market, PerpMarket, eth_perp_market_account_info); + let perp_market_map = PerpMarketMap::load_multiple( + vec![&sol_perp_market_account_info, ð_perp_market_account_info], + true, + ) + .unwrap(); + + let mut usdc_spot_market = SpotMarket { + market_index: 0, + oracle_source: OracleSource::QuoteAsset, + cumulative_deposit_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + decimals: 6, + initial_asset_weight: SPOT_WEIGHT_PRECISION, + maintenance_asset_weight: SPOT_WEIGHT_PRECISION, + deposit_balance: 100000 * SPOT_BALANCE_PRECISION, + liquidator_fee: 0, + historical_oracle_data: HistoricalOracleData::default_quote_oracle(), + ..SpotMarket::default() + }; + create_anchor_account_info!( + usdc_spot_market, + SpotMarket, + usdc_spot_market_account_info + ); + let spot_market_account_infos = Vec::from([&usdc_spot_market_account_info]); + let spot_market_map = + SpotMarketMap::load_multiple(spot_market_account_infos, true).unwrap(); + + // User has: + // - 1000 USDC cross collateral + // - Existing isolated SOL-PERP position: 10 SOL long @ $100 = $1000 notional + // - With only $40 isolated collateral + // - MM required: $1000 * 5% = $50 (FAILS - $40 < $50) + // - New isolated ETH-PERP position: 1 ETH long @ $1000 = $1000 notional + // - With $200 isolated collateral + // - IM required: $1000 * 10% = $100 (PASSES - $200 > $100) + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 1000 * SPOT_BALANCE_PRECISION_U64, // 1000 USDC cross collateral + ..SpotPosition::default() + }; + + let mut perp_positions = [PerpPosition::default(); 8]; + // Existing isolated SOL-PERP position that FAILS maintenance margin + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 10 * BASE_PRECISION_I64, // 10 SOL long + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, // Entry at $100 + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 40 * SPOT_BALANCE_PRECISION_U64, // Only $40 - fails MM + ..PerpPosition::default() + }; + // New isolated ETH-PERP position with sufficient collateral + perp_positions[1] = PerpPosition { + market_index: 2, + base_asset_amount: 1 * BASE_PRECISION_I64, // 1 ETH long + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, // Entry at $1000 + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 200 * SPOT_BALANCE_PRECISION_U64, // $200 isolated collateral + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + // Test: Try to place an order on the new ETH-PERP isolated position + // Even though ETH-PERP itself passes IM, the existing SOL-PERP fails MM + let result = meets_place_order_margin_requirement( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + true, + Some(2), // isolated_market_index = 2 (ETH-PERP) + ); + + // Should FAIL because: + // - Existing isolated SOL-PERP: $40 collateral < $50 MM required -> FAILS MM + // - When opening new isolated position, all other isolated positions must pass MM + assert!( + result.is_err(), + "Isolated order should fail when other isolated position fails maintenance margin" + ); + } + + #[test] + fn isolated_order_passes_when_cross_only_passes_maintenance() { + // Scenario: Current isolated PI, cross PM (no other isolated). Place isolated order (risk increasing). + // Cross has no perp position so cross margin req 0; cross $70 is PM-level. Isolated ETH $150 >= IM $100. + // Expected: PASS. + let slot = 0_u64; + let pyth_program = crate::ids::pyth_program::id(); + let mut sol_oracle_price = get_pyth_price(100, 6); + let sol_oracle_price_key = + Pubkey::from_str("J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix").unwrap(); + create_account_info!( + sol_oracle_price, + &sol_oracle_price_key, + &pyth_program, + sol_oracle_account_info + ); + let mut eth_oracle_price = get_pyth_price(1000, 6); + let eth_oracle_price_key = + Pubkey::from_str("AHRAk64kPiGwkbkisDvjVYzq6Ho5Q2wQSj28vAaAt7Tq").unwrap(); + create_account_info!( + eth_oracle_price, + ð_oracle_price_key, + &pyth_program, + eth_oracle_account_info + ); + let oracle_account_infos = vec![sol_oracle_account_info, eth_oracle_account_info]; + let mut oracle_map = + OracleMap::load(&mut oracle_account_infos.iter().peekable(), slot, None).unwrap(); + + let mut sol_perp_market = PerpMarket { + market_index: 0, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 100 * PEG_PRECISION, + order_step_size: 10000000, + oracle: sol_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, + margin_ratio_maintenance: 500, + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + let mut eth_perp_market = PerpMarket { + market_index: 2, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 1000 * PEG_PRECISION, + order_step_size: 10000000, + oracle: eth_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, + margin_ratio_maintenance: 500, + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + create_anchor_account_info!(sol_perp_market, PerpMarket, sol_perp_market_account_info); + create_anchor_account_info!(eth_perp_market, PerpMarket, eth_perp_market_account_info); + let perp_market_map = PerpMarketMap::load_multiple( + vec![&sol_perp_market_account_info, ð_perp_market_account_info], + true, + ) + .unwrap(); + + let mut usdc_spot_market = SpotMarket { + market_index: 0, + oracle_source: OracleSource::QuoteAsset, + cumulative_deposit_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + decimals: 6, + initial_asset_weight: SPOT_WEIGHT_PRECISION, + maintenance_asset_weight: SPOT_WEIGHT_PRECISION, + deposit_balance: 10000 * SPOT_BALANCE_PRECISION, + liquidator_fee: 0, + historical_oracle_data: HistoricalOracleData::default_quote_oracle(), + ..SpotMarket::default() + }; + create_anchor_account_info!( + usdc_spot_market, + SpotMarket, + usdc_spot_market_account_info + ); + let spot_market_account_infos = Vec::from([&usdc_spot_market_account_info]); + let spot_market_map = + SpotMarketMap::load_multiple(spot_market_account_infos, true).unwrap(); + + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 70 * SPOT_BALANCE_PRECISION_U64, // cross PM (no cross position) + ..SpotPosition::default() + }; + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 2, + base_asset_amount: 1 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 150 * SPOT_BALANCE_PRECISION_U64, // PI: >= $100 IM + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let result = meets_place_order_margin_requirement( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + true, + Some(2), + ); + assert!( + result.is_ok(), + "Isolated order should pass when cross only passes maintenance. Error: {:?}", + result + ); + } + + #[test] + fn isolated_order_fails_when_current_isolated_only_passes_maintenance() { + // Scenario: Current isolated PM (collateral $70 < IM $100), cross/other ok. Place isolated order (risk increasing). + // Expected: FAIL (current isolated must pass Initial). + let slot = 0_u64; + let mut eth_oracle_price = get_pyth_price(1000, 6); + let eth_oracle_price_key = + Pubkey::from_str("AHRAk64kPiGwkbkisDvjVYzq6Ho5Q2wQSj28vAaAt7Tq").unwrap(); + let pyth_program = crate::ids::pyth_program::id(); + create_account_info!( + eth_oracle_price, + ð_oracle_price_key, + &pyth_program, + eth_oracle_account_info + ); + let oracle_account_infos = vec![eth_oracle_account_info]; + let mut oracle_map = + OracleMap::load(&mut oracle_account_infos.iter().peekable(), slot, None).unwrap(); + + let mut eth_perp_market = PerpMarket { + market_index: 2, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 1000 * PEG_PRECISION, + order_step_size: 10000000, + oracle: eth_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, + margin_ratio_maintenance: 500, + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + create_anchor_account_info!(eth_perp_market, PerpMarket, eth_perp_market_account_info); + let perp_market_map = + PerpMarketMap::load_one(ð_perp_market_account_info, true).unwrap(); + + let mut usdc_spot_market = SpotMarket { + market_index: 0, + oracle_source: OracleSource::QuoteAsset, + cumulative_deposit_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + decimals: 6, + initial_asset_weight: SPOT_WEIGHT_PRECISION, + maintenance_asset_weight: SPOT_WEIGHT_PRECISION, + deposit_balance: 10000 * SPOT_BALANCE_PRECISION, + liquidator_fee: 0, + historical_oracle_data: HistoricalOracleData::default_quote_oracle(), + ..SpotMarket::default() + }; + create_anchor_account_info!( + usdc_spot_market, + SpotMarket, + usdc_spot_market_account_info + ); + let spot_market_account_infos = Vec::from([&usdc_spot_market_account_info]); + let spot_market_map = + SpotMarketMap::load_multiple(spot_market_account_infos, true).unwrap(); + + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 1000 * SPOT_BALANCE_PRECISION_U64, + ..SpotPosition::default() + }; + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 2, + base_asset_amount: 1 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 70 * SPOT_BALANCE_PRECISION_U64, // PM: $70 < IM $100 + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let result = meets_place_order_margin_requirement( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + true, + Some(2), + ); + assert!( + result.is_err(), + "Isolated order should fail when current isolated only passes maintenance" + ); + } + + #[test] + fn isolated_order_fails_when_current_isolated_fails_maintenance() { + // Scenario: Current isolated FM (collateral $40 < MM $50). Place isolated order. Expected: FAIL. + let slot = 0_u64; + let mut eth_oracle_price = get_pyth_price(1000, 6); + let eth_oracle_price_key = + Pubkey::from_str("AHRAk64kPiGwkbkisDvjVYzq6Ho5Q2wQSj28vAaAt7Tq").unwrap(); + let pyth_program = crate::ids::pyth_program::id(); + create_account_info!( + eth_oracle_price, + ð_oracle_price_key, + &pyth_program, + eth_oracle_account_info + ); + let oracle_account_infos = vec![eth_oracle_account_info]; + let mut oracle_map = + OracleMap::load(&mut oracle_account_infos.iter().peekable(), slot, None).unwrap(); + + let mut eth_perp_market = PerpMarket { + market_index: 2, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 1000 * PEG_PRECISION, + order_step_size: 10000000, + oracle: eth_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, + margin_ratio_maintenance: 500, + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + create_anchor_account_info!(eth_perp_market, PerpMarket, eth_perp_market_account_info); + let perp_market_map = + PerpMarketMap::load_one(ð_perp_market_account_info, true).unwrap(); + + let mut usdc_spot_market = SpotMarket { + market_index: 0, + oracle_source: OracleSource::QuoteAsset, + cumulative_deposit_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + decimals: 6, + initial_asset_weight: SPOT_WEIGHT_PRECISION, + maintenance_asset_weight: SPOT_WEIGHT_PRECISION, + deposit_balance: 10000 * SPOT_BALANCE_PRECISION, + liquidator_fee: 0, + historical_oracle_data: HistoricalOracleData::default_quote_oracle(), + ..SpotMarket::default() + }; + create_anchor_account_info!( + usdc_spot_market, + SpotMarket, + usdc_spot_market_account_info + ); + let spot_market_account_infos = Vec::from([&usdc_spot_market_account_info]); + let spot_market_map = + SpotMarketMap::load_multiple(spot_market_account_infos, true).unwrap(); + + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 1000 * SPOT_BALANCE_PRECISION_U64, + ..SpotPosition::default() + }; + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 2, + base_asset_amount: 1 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 40 * SPOT_BALANCE_PRECISION_U64, // FM: < $50 MM + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let result = meets_place_order_margin_requirement( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + true, + Some(2), + ); + assert!( + result.is_err(), + "Isolated order should fail when current isolated fails maintenance" + ); + } + + #[test] + fn isolated_order_not_risk_increasing_passes_when_all_pass_maintenance() { + // Scenario: Current isolated PM, cross PM. risk_increasing: false -> all Maintenance. Expected: PASS. + let slot = 0_u64; + let pyth_program = crate::ids::pyth_program::id(); + let mut sol_oracle_price = get_pyth_price(100, 6); + let sol_oracle_price_key = + Pubkey::from_str("J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix").unwrap(); + create_account_info!( + sol_oracle_price, + &sol_oracle_price_key, + &pyth_program, + sol_oracle_account_info + ); + let mut eth_oracle_price = get_pyth_price(1000, 6); + let eth_oracle_price_key = + Pubkey::from_str("AHRAk64kPiGwkbkisDvjVYzq6Ho5Q2wQSj28vAaAt7Tq").unwrap(); + create_account_info!( + eth_oracle_price, + ð_oracle_price_key, + &pyth_program, + eth_oracle_account_info + ); + let oracle_account_infos = vec![sol_oracle_account_info, eth_oracle_account_info]; + let mut oracle_map = + OracleMap::load(&mut oracle_account_infos.iter().peekable(), slot, None).unwrap(); + + let mut sol_perp_market = PerpMarket { + market_index: 0, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 100 * PEG_PRECISION, + order_step_size: 10000000, + oracle: sol_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, + margin_ratio_maintenance: 500, + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + let mut eth_perp_market = PerpMarket { + market_index: 2, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 1000 * PEG_PRECISION, + order_step_size: 10000000, + oracle: eth_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, + margin_ratio_maintenance: 500, + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + create_anchor_account_info!(sol_perp_market, PerpMarket, sol_perp_market_account_info); + create_anchor_account_info!(eth_perp_market, PerpMarket, eth_perp_market_account_info); + let perp_market_map = PerpMarketMap::load_multiple( + vec![&sol_perp_market_account_info, ð_perp_market_account_info], + true, + ) + .unwrap(); + + let mut usdc_spot_market = SpotMarket { + market_index: 0, + oracle_source: OracleSource::QuoteAsset, + cumulative_deposit_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + decimals: 6, + initial_asset_weight: SPOT_WEIGHT_PRECISION, + maintenance_asset_weight: SPOT_WEIGHT_PRECISION, + deposit_balance: 10000 * SPOT_BALANCE_PRECISION, + liquidator_fee: 0, + historical_oracle_data: HistoricalOracleData::default_quote_oracle(), + ..SpotMarket::default() + }; + create_anchor_account_info!( + usdc_spot_market, + SpotMarket, + usdc_spot_market_account_info + ); + let spot_market_account_infos = Vec::from([&usdc_spot_market_account_info]); + let spot_market_map = + SpotMarketMap::load_multiple(spot_market_account_infos, true).unwrap(); + + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 70 * SPOT_BALANCE_PRECISION_U64, // cross PM + ..SpotPosition::default() + }; + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 10 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + ..PerpPosition::default() + }; + perp_positions[1] = PerpPosition { + market_index: 2, + base_asset_amount: 1 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 70 * SPOT_BALANCE_PRECISION_U64, // isolated PM + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let result = meets_place_order_margin_requirement( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + false, + Some(2), + ); + assert!( + result.is_ok(), + "Isolated order not risk increasing should pass when all pass maintenance. Error: {:?}", + result + ); + } + + #[test] + fn isolated_order_not_risk_increasing_fails_when_other_isolated_fails_maintenance() { + // Scenario: Current PI, cross PI, other isolated FM. risk_increasing: false. Expected: FAIL. + let slot = 0_u64; + let pyth_program = crate::ids::pyth_program::id(); + let mut sol_oracle_price = get_pyth_price(100, 6); + let sol_oracle_price_key = + Pubkey::from_str("J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix").unwrap(); + create_account_info!( + sol_oracle_price, + &sol_oracle_price_key, + &pyth_program, + sol_oracle_account_info + ); + let mut eth_oracle_price = get_pyth_price(1000, 6); + let eth_oracle_price_key = + Pubkey::from_str("AHRAk64kPiGwkbkisDvjVYzq6Ho5Q2wQSj28vAaAt7Tq").unwrap(); + create_account_info!( + eth_oracle_price, + ð_oracle_price_key, + &pyth_program, + eth_oracle_account_info + ); + let oracle_account_infos = vec![sol_oracle_account_info, eth_oracle_account_info]; + let mut oracle_map = + OracleMap::load(&mut oracle_account_infos.iter().peekable(), slot, None).unwrap(); + + let mut sol_perp_market = PerpMarket { + market_index: 0, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 100 * PEG_PRECISION, + order_step_size: 10000000, + oracle: sol_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, + margin_ratio_maintenance: 500, + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + let mut eth_perp_market = PerpMarket { + market_index: 2, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 1000 * PEG_PRECISION, + order_step_size: 10000000, + oracle: eth_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, + margin_ratio_maintenance: 500, + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + create_anchor_account_info!(sol_perp_market, PerpMarket, sol_perp_market_account_info); + create_anchor_account_info!(eth_perp_market, PerpMarket, eth_perp_market_account_info); + let perp_market_map = PerpMarketMap::load_multiple( + vec![&sol_perp_market_account_info, ð_perp_market_account_info], + true, + ) + .unwrap(); + + let mut usdc_spot_market = SpotMarket { + market_index: 0, + oracle_source: OracleSource::QuoteAsset, + cumulative_deposit_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + decimals: 6, + initial_asset_weight: SPOT_WEIGHT_PRECISION, + maintenance_asset_weight: SPOT_WEIGHT_PRECISION, + deposit_balance: 10000 * SPOT_BALANCE_PRECISION, + liquidator_fee: 0, + historical_oracle_data: HistoricalOracleData::default_quote_oracle(), + ..SpotMarket::default() + }; + create_anchor_account_info!( + usdc_spot_market, + SpotMarket, + usdc_spot_market_account_info + ); + let spot_market_account_infos = Vec::from([&usdc_spot_market_account_info]); + let spot_market_map = + SpotMarketMap::load_multiple(spot_market_account_infos, true).unwrap(); + + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 1000 * SPOT_BALANCE_PRECISION_U64, + ..SpotPosition::default() + }; + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 10 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 40 * SPOT_BALANCE_PRECISION_U64, // other isolated FM + ..PerpPosition::default() + }; + perp_positions[1] = PerpPosition { + market_index: 2, + base_asset_amount: 1 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 200 * SPOT_BALANCE_PRECISION_U64, // current PI + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let result = meets_place_order_margin_requirement( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + false, + Some(2), + ); + assert!( + result.is_err(), + "Isolated order not risk increasing should fail when other isolated fails maintenance" + ); + } + } + + mod fill_perp_order_margin_requirement_with_isolated { + use std::str::FromStr; + + use anchor_lang::prelude::Pubkey; + use anchor_lang::Owner; + + use crate::create_account_info; + use crate::math::constants::{ + AMM_RESERVE_PRECISION, BASE_PRECISION_I64, PEG_PRECISION, SPOT_BALANCE_PRECISION, + SPOT_BALANCE_PRECISION_U64, SPOT_CUMULATIVE_INTEREST_PRECISION, SPOT_WEIGHT_PRECISION, + }; + use crate::math::margin::{ + calculate_margin_requirement_and_total_collateral_and_liability_info, + MarginRequirementType, + }; + use crate::state::margin_calculation::{MarginContext, MarginTypeConfig}; + use crate::state::oracle::{HistoricalOracleData, OracleSource}; + use crate::state::oracle_map::OracleMap; + use crate::state::perp_market::{MarketStatus, PerpMarket, AMM}; + use crate::state::perp_market_map::PerpMarketMap; + use crate::state::spot_market::{SpotBalanceType, SpotMarket}; + use crate::state::spot_market_map::SpotMarketMap; + use crate::state::user::{Order, PerpPosition, PositionFlag, SpotPosition, User}; + use crate::test_utils::get_pyth_price; + use crate::test_utils::*; + use crate::{create_anchor_account_info, QUOTE_PRECISION_I64}; + + const NOW: i64 = 0; + + fn with_sol_eth_setup(slot: u64, f: F) -> R + where + F: FnOnce(&mut OracleMap, &PerpMarketMap, &SpotMarketMap) -> R, + { + let sol_oracle_price_key = + Pubkey::from_str("J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix").unwrap(); + let eth_oracle_price_key = + Pubkey::from_str("AHRAk64kPiGwkbkisDvjVYzq6Ho5Q2wQSj28vAaAt7Tq").unwrap(); + let mut sol_oracle_price = get_pyth_price(100, 6); + let mut eth_oracle_price = get_pyth_price(1000, 6); + let pyth_program = crate::ids::pyth_program::id(); + create_account_info!( + sol_oracle_price, + &sol_oracle_price_key, + &pyth_program, + sol_oracle_account_info + ); + create_account_info!( + eth_oracle_price, + ð_oracle_price_key, + &pyth_program, + eth_oracle_account_info + ); + let oracle_account_infos = vec![sol_oracle_account_info, eth_oracle_account_info]; + let mut oracle_map = + OracleMap::load(&mut oracle_account_infos.iter().peekable(), slot, None).unwrap(); + + let mut sol_perp_market = PerpMarket { + market_index: 0, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 100 * PEG_PRECISION, + order_step_size: 10000000, + oracle: sol_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, + margin_ratio_maintenance: 500, + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + let mut eth_perp_market = PerpMarket { + market_index: 2, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 1000 * PEG_PRECISION, + order_step_size: 10000000, + oracle: eth_oracle_price_key, + ..AMM::default() + }, + margin_ratio_initial: 1000, + margin_ratio_maintenance: 500, + status: MarketStatus::Initialized, + ..PerpMarket::default() + }; + create_anchor_account_info!(sol_perp_market, PerpMarket, sol_perp_market_account_info); + create_anchor_account_info!(eth_perp_market, PerpMarket, eth_perp_market_account_info); + let perp_market_map = PerpMarketMap::load_multiple( + vec![&sol_perp_market_account_info, ð_perp_market_account_info], + true, + ) + .unwrap(); + + let mut usdc_spot_market = SpotMarket { + market_index: 0, + oracle_source: OracleSource::QuoteAsset, + cumulative_deposit_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + decimals: 6, + initial_asset_weight: SPOT_WEIGHT_PRECISION, + maintenance_asset_weight: SPOT_WEIGHT_PRECISION, + deposit_balance: 10000 * SPOT_BALANCE_PRECISION, + liquidator_fee: 0, + historical_oracle_data: HistoricalOracleData::default_quote_oracle(), + ..SpotMarket::default() + }; + create_anchor_account_info!( + usdc_spot_market, + SpotMarket, + usdc_spot_market_account_info + ); + let spot_market_map = + SpotMarketMap::load_multiple(vec![&usdc_spot_market_account_info], true).unwrap(); + + f(&mut oracle_map, &perp_market_map, &spot_market_map) + } + + // --- Scenario 1a: Isolated fill, position increasing (current isolated = Fill) --- + + #[test] + fn isolated_fill_increasing_passes_when_current_isolated_passes_fill_others_maintenance() { + with_sol_eth_setup(0, |mut oracle_map, perp_market_map, spot_market_map| { + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 70 * SPOT_BALANCE_PRECISION_U64, + ..SpotPosition::default() + }; + + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 1 * BASE_PRECISION_I64, + quote_asset_amount: -100 * QUOTE_PRECISION_I64, + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 150 * SPOT_BALANCE_PRECISION_U64, + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let margin_type_config = MarginTypeConfig::IsolatedPositionOverride { + market_index: 0, + margin_requirement_type: MarginRequirementType::Fill, + default_isolated_margin_requirement_type: MarginRequirementType::Maintenance, + cross_margin_requirement_type: MarginRequirementType::Maintenance, + }; + let context = MarginContext::standard_with_config(margin_type_config) + .fuel_perp_delta(0, BASE_PRECISION_I64) + .fuel_numerator(&user, NOW); + + let calculation = + calculate_margin_requirement_and_total_collateral_and_liability_info( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + context, + ) + .unwrap(); + + assert!( + calculation.meets_margin_requirement(), + "Isolated fill increasing should pass when current isolated passes Fill and others pass Maintenance" + ); + }); + } + + #[test] + fn isolated_fill_increasing_fails_when_current_isolated_only_passes_maintenance() { + with_sol_eth_setup(0, |mut oracle_map, perp_market_map, spot_market_map| { + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 1000 * SPOT_BALANCE_PRECISION_U64, + ..SpotPosition::default() + }; + + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 1 * BASE_PRECISION_I64, + quote_asset_amount: -100 * QUOTE_PRECISION_I64, + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 7 * SPOT_BALANCE_PRECISION_U64, + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let margin_type_config = MarginTypeConfig::IsolatedPositionOverride { + market_index: 0, + margin_requirement_type: MarginRequirementType::Fill, + default_isolated_margin_requirement_type: MarginRequirementType::Maintenance, + cross_margin_requirement_type: MarginRequirementType::Maintenance, + }; + let context = MarginContext::standard_with_config(margin_type_config) + .fuel_perp_delta(0, 9 * BASE_PRECISION_I64) + .fuel_numerator(&user, NOW); + + let calculation = + calculate_margin_requirement_and_total_collateral_and_liability_info( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + context, + ) + .unwrap(); + + assert!( + !calculation.meets_margin_requirement(), + "Isolated fill increasing should fail when current isolated only passes Maintenance (needs Fill after delta)" + ); + }); + } + + #[test] + fn isolated_fill_increasing_fails_when_current_isolated_fails_maintenance() { + with_sol_eth_setup(0, |mut oracle_map, perp_market_map, spot_market_map| { + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 1000 * SPOT_BALANCE_PRECISION_U64, + ..SpotPosition::default() + }; + + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 10 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 40 * SPOT_BALANCE_PRECISION_U64, + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let margin_type_config = MarginTypeConfig::IsolatedPositionOverride { + market_index: 0, + margin_requirement_type: MarginRequirementType::Fill, + default_isolated_margin_requirement_type: MarginRequirementType::Maintenance, + cross_margin_requirement_type: MarginRequirementType::Maintenance, + }; + let context = MarginContext::standard_with_config(margin_type_config) + .fuel_perp_delta(0, BASE_PRECISION_I64) + .fuel_numerator(&user, NOW); + + let calculation = + calculate_margin_requirement_and_total_collateral_and_liability_info( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + context, + ) + .unwrap(); + + assert!( + !calculation.meets_margin_requirement(), + "Isolated fill increasing should fail when current isolated fails Maintenance" + ); + }); + } + + #[test] + fn isolated_fill_increasing_fails_when_cross_fails_maintenance() { + with_sol_eth_setup(0, |mut oracle_map, perp_market_map, spot_market_map| { + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 40 * SPOT_BALANCE_PRECISION_U64, + ..SpotPosition::default() + }; + + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 1 * BASE_PRECISION_I64, + quote_asset_amount: -100 * QUOTE_PRECISION_I64, + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 150 * SPOT_BALANCE_PRECISION_U64, + ..PerpPosition::default() + }; + perp_positions[1] = PerpPosition { + market_index: 2, + base_asset_amount: 1 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let margin_type_config = MarginTypeConfig::IsolatedPositionOverride { + market_index: 0, + margin_requirement_type: MarginRequirementType::Fill, + default_isolated_margin_requirement_type: MarginRequirementType::Maintenance, + cross_margin_requirement_type: MarginRequirementType::Maintenance, + }; + let context = MarginContext::standard_with_config(margin_type_config) + .fuel_perp_delta(0, BASE_PRECISION_I64) + .fuel_numerator(&user, NOW); + + let calculation = + calculate_margin_requirement_and_total_collateral_and_liability_info( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + context, + ) + .unwrap(); + + assert!( + !calculation.meets_margin_requirement(), + "Isolated fill increasing should fail when cross fails Maintenance" + ); + }); + } + + #[test] + fn isolated_fill_increasing_fails_when_other_isolated_fails_maintenance() { + with_sol_eth_setup(0, |mut oracle_map, perp_market_map, spot_market_map| { + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 70 * SPOT_BALANCE_PRECISION_U64, + ..SpotPosition::default() + }; + + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 1 * BASE_PRECISION_I64, + quote_asset_amount: -100 * QUOTE_PRECISION_I64, + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 150 * SPOT_BALANCE_PRECISION_U64, + ..PerpPosition::default() + }; + perp_positions[1] = PerpPosition { + market_index: 2, + base_asset_amount: 1 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 40 * SPOT_BALANCE_PRECISION_U64, + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let margin_type_config = MarginTypeConfig::IsolatedPositionOverride { + market_index: 0, + margin_requirement_type: MarginRequirementType::Fill, + default_isolated_margin_requirement_type: MarginRequirementType::Maintenance, + cross_margin_requirement_type: MarginRequirementType::Maintenance, + }; + let context = MarginContext::standard_with_config(margin_type_config) + .fuel_perp_delta(0, BASE_PRECISION_I64) + .fuel_numerator(&user, NOW); + + let calculation = + calculate_margin_requirement_and_total_collateral_and_liability_info( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + context, + ) + .unwrap(); + + assert!( + !calculation.meets_margin_requirement(), + "Isolated fill increasing should fail when other isolated fails Maintenance" + ); + }); + } + + // --- Scenario 1b: Isolated fill, position decreasing (all Maintenance) --- + + #[test] + fn isolated_fill_decreasing_passes_when_all_pass_maintenance() { + with_sol_eth_setup(0, |mut oracle_map, perp_market_map, spot_market_map| { + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 70 * SPOT_BALANCE_PRECISION_U64, + ..SpotPosition::default() + }; + + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 10 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 70 * SPOT_BALANCE_PRECISION_U64, + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let margin_type_config = MarginTypeConfig::IsolatedPositionOverride { + market_index: 0, + margin_requirement_type: MarginRequirementType::Maintenance, + default_isolated_margin_requirement_type: MarginRequirementType::Maintenance, + cross_margin_requirement_type: MarginRequirementType::Maintenance, + }; + let context = MarginContext::standard_with_config(margin_type_config) + .fuel_perp_delta(0, -BASE_PRECISION_I64) + .fuel_numerator(&user, NOW); + + let calculation = + calculate_margin_requirement_and_total_collateral_and_liability_info( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + context, + ) + .unwrap(); + + assert!( + calculation.meets_margin_requirement(), + "Isolated fill decreasing should pass when all pass Maintenance" + ); + }); + } + + #[test] + fn isolated_fill_decreasing_fails_when_current_isolated_fails_maintenance() { + with_sol_eth_setup(0, |mut oracle_map, perp_market_map, spot_market_map| { + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 1000 * SPOT_BALANCE_PRECISION_U64, + ..SpotPosition::default() + }; + + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 10 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 40 * SPOT_BALANCE_PRECISION_U64, + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let margin_type_config = MarginTypeConfig::IsolatedPositionOverride { + market_index: 0, + margin_requirement_type: MarginRequirementType::Maintenance, + default_isolated_margin_requirement_type: MarginRequirementType::Maintenance, + cross_margin_requirement_type: MarginRequirementType::Maintenance, + }; + let context = MarginContext::standard_with_config(margin_type_config) + .fuel_perp_delta(0, -BASE_PRECISION_I64) + .fuel_numerator(&user, NOW); + + let calculation = + calculate_margin_requirement_and_total_collateral_and_liability_info( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + context, + ) + .unwrap(); + + assert!( + !calculation.meets_margin_requirement(), + "Isolated fill decreasing should fail when current isolated fails Maintenance" + ); + }); + } + + #[test] + fn isolated_fill_decreasing_fails_when_other_isolated_fails_maintenance() { + with_sol_eth_setup(0, |mut oracle_map, perp_market_map, spot_market_map| { + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 70 * SPOT_BALANCE_PRECISION_U64, + ..SpotPosition::default() + }; + + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 10 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 70 * SPOT_BALANCE_PRECISION_U64, + ..PerpPosition::default() + }; + perp_positions[1] = PerpPosition { + market_index: 2, + base_asset_amount: 1 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 40 * SPOT_BALANCE_PRECISION_U64, + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let margin_type_config = MarginTypeConfig::IsolatedPositionOverride { + market_index: 0, + margin_requirement_type: MarginRequirementType::Maintenance, + default_isolated_margin_requirement_type: MarginRequirementType::Maintenance, + cross_margin_requirement_type: MarginRequirementType::Maintenance, + }; + let context = MarginContext::standard_with_config(margin_type_config) + .fuel_perp_delta(0, -BASE_PRECISION_I64) + .fuel_numerator(&user, NOW); + + let calculation = + calculate_margin_requirement_and_total_collateral_and_liability_info( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + context, + ) + .unwrap(); + + assert!( + !calculation.meets_margin_requirement(), + "Isolated fill decreasing should fail when other isolated fails Maintenance" + ); + }); + } + + // --- Scenario 2a: Cross fill, position increasing (cross = Fill) --- + + #[test] + fn cross_fill_increasing_passes_when_cross_passes_fill_isolated_maintenance() { + with_sol_eth_setup(0, |mut oracle_map, perp_market_map, spot_market_map| { + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 150 * SPOT_BALANCE_PRECISION_U64, + ..SpotPosition::default() + }; + + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 1 * BASE_PRECISION_I64, + quote_asset_amount: -100 * QUOTE_PRECISION_I64, + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let margin_type_config = MarginTypeConfig::CrossMarginOverride { + margin_requirement_type: MarginRequirementType::Fill, + default_margin_requirement_type: MarginRequirementType::Maintenance, + }; + let context = MarginContext::standard_with_config(margin_type_config) + .fuel_perp_delta(0, BASE_PRECISION_I64) + .fuel_numerator(&user, NOW); + + let calculation = + calculate_margin_requirement_and_total_collateral_and_liability_info( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + context, + ) + .unwrap(); + + assert!( + calculation.meets_margin_requirement(), + "Cross fill increasing should pass when cross passes Fill and isolated pass Maintenance" + ); + }); + } + + #[test] + fn cross_fill_increasing_fails_when_cross_only_passes_maintenance() { + with_sol_eth_setup(0, |mut oracle_map, perp_market_map, spot_market_map| { + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 70 * SPOT_BALANCE_PRECISION_U64, + ..SpotPosition::default() + }; + + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 10 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let margin_type_config = MarginTypeConfig::CrossMarginOverride { + margin_requirement_type: MarginRequirementType::Fill, + default_margin_requirement_type: MarginRequirementType::Maintenance, + }; + let context = MarginContext::standard_with_config(margin_type_config) + .fuel_perp_delta(0, BASE_PRECISION_I64) + .fuel_numerator(&user, NOW); + + let calculation = + calculate_margin_requirement_and_total_collateral_and_liability_info( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + context, + ) + .unwrap(); + + assert!( + !calculation.meets_margin_requirement(), + "Cross fill increasing should fail when cross only passes Maintenance" + ); + }); + } + + #[test] + fn cross_fill_increasing_fails_when_cross_fails_maintenance() { + with_sol_eth_setup(0, |mut oracle_map, perp_market_map, spot_market_map| { + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 40 * SPOT_BALANCE_PRECISION_U64, + ..SpotPosition::default() + }; + + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 10 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let margin_type_config = MarginTypeConfig::CrossMarginOverride { + margin_requirement_type: MarginRequirementType::Fill, + default_margin_requirement_type: MarginRequirementType::Maintenance, + }; + let context = MarginContext::standard_with_config(margin_type_config) + .fuel_perp_delta(0, BASE_PRECISION_I64) + .fuel_numerator(&user, NOW); + + let calculation = + calculate_margin_requirement_and_total_collateral_and_liability_info( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + context, + ) + .unwrap(); + + assert!( + !calculation.meets_margin_requirement(), + "Cross fill increasing should fail when cross fails Maintenance" + ); + }); + } + + #[test] + fn cross_fill_increasing_fails_when_other_isolated_fails_maintenance() { + with_sol_eth_setup(0, |mut oracle_map, perp_market_map, spot_market_map| { + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 150 * SPOT_BALANCE_PRECISION_U64, + ..SpotPosition::default() + }; + + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 1 * BASE_PRECISION_I64, + quote_asset_amount: -100 * QUOTE_PRECISION_I64, + ..PerpPosition::default() + }; + perp_positions[1] = PerpPosition { + market_index: 2, + base_asset_amount: 1 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 40 * SPOT_BALANCE_PRECISION_U64, + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let margin_type_config = MarginTypeConfig::CrossMarginOverride { + margin_requirement_type: MarginRequirementType::Fill, + default_margin_requirement_type: MarginRequirementType::Maintenance, + }; + let context = MarginContext::standard_with_config(margin_type_config) + .fuel_perp_delta(0, BASE_PRECISION_I64) + .fuel_numerator(&user, NOW); + + let calculation = + calculate_margin_requirement_and_total_collateral_and_liability_info( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + context, + ) + .unwrap(); + + assert!( + !calculation.meets_margin_requirement(), + "Cross fill increasing should fail when other isolated fails Maintenance" + ); + }); + } + + // --- Scenario 2b: Cross fill, position decreasing (all Maintenance) --- + + #[test] + fn cross_fill_decreasing_passes_when_all_pass_maintenance() { + with_sol_eth_setup(0, |mut oracle_map, perp_market_map, spot_market_map| { + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 70 * SPOT_BALANCE_PRECISION_U64, + ..SpotPosition::default() + }; + + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 10 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let margin_type_config = MarginTypeConfig::CrossMarginOverride { + margin_requirement_type: MarginRequirementType::Maintenance, + default_margin_requirement_type: MarginRequirementType::Maintenance, + }; + let context = MarginContext::standard_with_config(margin_type_config) + .fuel_perp_delta(0, -BASE_PRECISION_I64) + .fuel_numerator(&user, NOW); + + let calculation = + calculate_margin_requirement_and_total_collateral_and_liability_info( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + context, + ) + .unwrap(); + + assert!( + calculation.meets_margin_requirement(), + "Cross fill decreasing should pass when all pass Maintenance" + ); + }); + } + + #[test] + fn cross_fill_decreasing_fails_when_cross_fails_maintenance() { + with_sol_eth_setup(0, |mut oracle_map, perp_market_map, spot_market_map| { + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 40 * SPOT_BALANCE_PRECISION_U64, + ..SpotPosition::default() + }; + + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 10 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let margin_type_config = MarginTypeConfig::CrossMarginOverride { + margin_requirement_type: MarginRequirementType::Maintenance, + default_margin_requirement_type: MarginRequirementType::Maintenance, + }; + let context = MarginContext::standard_with_config(margin_type_config) + .fuel_perp_delta(0, -BASE_PRECISION_I64) + .fuel_numerator(&user, NOW); + + let calculation = + calculate_margin_requirement_and_total_collateral_and_liability_info( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + context, + ) + .unwrap(); + + assert!( + !calculation.meets_margin_requirement(), + "Cross fill decreasing should fail when cross fails Maintenance" + ); + }); + } + + #[test] + fn cross_fill_decreasing_fails_when_other_isolated_fails_maintenance() { + with_sol_eth_setup(0, |mut oracle_map, perp_market_map, spot_market_map| { + let mut spot_positions = [SpotPosition::default(); 8]; + spot_positions[0] = SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: 70 * SPOT_BALANCE_PRECISION_U64, + ..SpotPosition::default() + }; + + let mut perp_positions = [PerpPosition::default(); 8]; + perp_positions[0] = PerpPosition { + market_index: 0, + base_asset_amount: 10 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + ..PerpPosition::default() + }; + perp_positions[1] = PerpPosition { + market_index: 2, + base_asset_amount: 1 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: 40 * SPOT_BALANCE_PRECISION_U64, + ..PerpPosition::default() + }; + + let user = User { + orders: [Order::default(); 32], + perp_positions, + spot_positions, + ..User::default() + }; + + let margin_type_config = MarginTypeConfig::CrossMarginOverride { + margin_requirement_type: MarginRequirementType::Maintenance, + default_margin_requirement_type: MarginRequirementType::Maintenance, + }; + let context = MarginContext::standard_with_config(margin_type_config) + .fuel_perp_delta(0, -BASE_PRECISION_I64) + .fuel_numerator(&user, NOW); + + let calculation = + calculate_margin_requirement_and_total_collateral_and_liability_info( + &user, + &perp_market_map, + &spot_market_map, + &mut oracle_map, + context, + ) + .unwrap(); + + assert!( + !calculation.meets_margin_requirement(), + "Cross fill decreasing should fail when other isolated fails Maintenance" + ); + }); + } + } } diff --git a/programs/drift/src/state/margin_calculation.rs b/programs/drift/src/state/margin_calculation.rs index 4069a11bf..add2da31a 100644 --- a/programs/drift/src/state/margin_calculation.rs +++ b/programs/drift/src/state/margin_calculation.rs @@ -27,7 +27,7 @@ pub enum MarginCalculationMode { #[derive(Clone, Copy, Debug)] pub struct MarginContext { - pub margin_type: MarginRequirementType, + pub margin_type_config: MarginTypeConfig, pub mode: MarginCalculationMode, pub strict: bool, pub ignore_invalid_deposit_oracles: bool, @@ -64,7 +64,22 @@ impl MarketIdentifier { impl MarginContext { pub fn standard(margin_type: MarginRequirementType) -> Self { Self { - margin_type, + margin_type_config: MarginTypeConfig::Default(margin_type), + mode: MarginCalculationMode::Standard, + strict: false, + ignore_invalid_deposit_oracles: false, + margin_buffer: 0, + fuel_bonus_numerator: 0, + fuel_bonus: 0, + fuel_perp_delta: None, + fuel_spot_deltas: [(0, 0); 2], + margin_ratio_override: None, + } + } + + pub fn standard_with_config(margin_type_config: MarginTypeConfig) -> Self { + Self { + margin_type_config, mode: MarginCalculationMode::Standard, strict: false, ignore_invalid_deposit_oracles: false, @@ -125,7 +140,7 @@ impl MarginContext { pub fn liquidation(margin_buffer: u32) -> Self { Self { - margin_type: MarginRequirementType::Maintenance, + margin_type_config: MarginTypeConfig::Default(MarginRequirementType::Maintenance), mode: MarginCalculationMode::Liquidation { market_to_track_margin_requirement: None, }, @@ -697,3 +712,56 @@ impl MarginCalculation { .contains_key(&market_index) } } + +#[derive(Clone, Debug, Copy)] +pub enum MarginTypeConfig { + Default(MarginRequirementType), + IsolatedPositionOverride { + market_index: u16, + margin_requirement_type: MarginRequirementType, + default_isolated_margin_requirement_type: MarginRequirementType, + cross_margin_requirement_type: MarginRequirementType, + }, + CrossMarginOverride { + margin_requirement_type: MarginRequirementType, + default_margin_requirement_type: MarginRequirementType, + }, +} + +impl MarginTypeConfig { + pub fn get_cross_margin_requirement_type(&self) -> MarginRequirementType { + match self { + MarginTypeConfig::Default(margin_requirement_type) => *margin_requirement_type, + MarginTypeConfig::IsolatedPositionOverride { + cross_margin_requirement_type, + .. + } => *cross_margin_requirement_type, + MarginTypeConfig::CrossMarginOverride { + margin_requirement_type, + .. + } => *margin_requirement_type, + } + } + + pub fn get_isolated_margin_requirement_type(&self, market_index: u16) -> MarginRequirementType { + match self { + MarginTypeConfig::Default(margin_requirement_type) => *margin_requirement_type, + MarginTypeConfig::IsolatedPositionOverride { + margin_requirement_type, + default_isolated_margin_requirement_type, + market_index: self_market_index, + .. + } => { + if *self_market_index == market_index { + *margin_requirement_type + } else { + *default_isolated_margin_requirement_type + } + } + MarginTypeConfig::CrossMarginOverride { + default_margin_requirement_type, + .. + } => *default_margin_requirement_type, + } + } +} diff --git a/programs/drift/src/state/user.rs b/programs/drift/src/state/user.rs index c4f1af4e3..a8d36b19d 100644 --- a/programs/drift/src/state/user.rs +++ b/programs/drift/src/state/user.rs @@ -40,11 +40,13 @@ use crate::math::margin::{ calculate_margin_requirement_and_total_collateral_and_liability_info, validate_any_isolated_tier_requirements, }; -use crate::state::margin_calculation::{MarginCalculation, MarginContext}; +use crate::state::margin_calculation::{MarginCalculation, MarginContext, MarginTypeConfig}; use crate::state::oracle_map::OracleMap; use crate::state::perp_market_map::PerpMarketMap; use crate::state::spot_market_map::SpotMarketMap; +#[cfg(test)] +mod isolated_transfer_tests; #[cfg(test)] mod tests; @@ -760,7 +762,7 @@ impl User { perp_market_map: &PerpMarketMap, spot_market_map: &SpotMarketMap, oracle_map: &mut OracleMap, - margin_requirement_type: MarginRequirementType, + margin_type_config: MarginTypeConfig, withdraw_market_index: u16, withdraw_amount: u128, user_stats: &mut UserStats, @@ -768,8 +770,13 @@ impl User { to_isolated_position: bool, isolated_market_index: u16, ) -> DriftResult { - let strict = margin_requirement_type == MarginRequirementType::Initial; - let context = MarginContext::standard(margin_requirement_type) + let strict = if !to_isolated_position { + margin_type_config.get_isolated_margin_requirement_type(isolated_market_index) + == MarginRequirementType::Initial + } else { + margin_type_config.get_cross_margin_requirement_type() == MarginRequirementType::Initial + }; + let context = MarginContext::standard_with_config(margin_type_config) .strict(strict) .ignore_invalid_deposit_oracles(true) .fuel_spot_delta(withdraw_market_index, withdraw_amount.cast::()?) @@ -793,27 +800,12 @@ impl User { validate_any_isolated_tier_requirements(self, &calculation)?; - if to_isolated_position { - validate!( - calculation.meets_cross_margin_requirement(), - ErrorCode::InsufficientCollateral, - "margin calculation: {:?}", - calculation - )?; - } else { - // may not exist if user withdrew their remaining deposit - if let Some(isolated_margin_calculation) = calculation - .isolated_margin_calculations - .get(&isolated_market_index) - { - validate!( - isolated_margin_calculation.meets_margin_requirement(), - ErrorCode::InsufficientCollateral, - "margin calculation: {:?}", - calculation - )?; - } - } + validate!( + calculation.meets_margin_requirement(), + ErrorCode::InsufficientCollateral, + "margin calculation: {:?}", + calculation + )?; user_stats.update_fuel_bonus( self, diff --git a/programs/drift/src/state/user/isolated_transfer_tests.rs b/programs/drift/src/state/user/isolated_transfer_tests.rs new file mode 100644 index 000000000..a26130964 --- /dev/null +++ b/programs/drift/src/state/user/isolated_transfer_tests.rs @@ -0,0 +1,501 @@ +//! Tests for `User::meets_transfer_isolated_position_deposit_margin_requirement`. +//! Covers transfer-to-isolated and transfer-from-isolated flows with pass/fail scenarios. + +use std::collections::BTreeSet; +use std::str::FromStr; + +use anchor_lang::Owner; +use solana_program::pubkey::Pubkey; + +use crate::create_account_info; +use crate::error::ErrorCode; +use crate::math::constants::{ + AMM_RESERVE_PRECISION, BASE_PRECISION_I64, LIQUIDATION_FEE_PRECISION, PEG_PRECISION, + QUOTE_PRECISION_I128, QUOTE_PRECISION_I64, SPOT_BALANCE_PRECISION_U64, + SPOT_CUMULATIVE_INTEREST_PRECISION, SPOT_WEIGHT_PRECISION, +}; +use crate::math::margin::MarginRequirementType; +use crate::state::margin_calculation::MarginTypeConfig; +use crate::state::oracle::{HistoricalOracleData, OracleSource}; +use crate::state::oracle_map::OracleMap; +use crate::state::perp_market::{MarketStatus, PerpMarket, AMM}; +use crate::state::perp_market_map::PerpMarketMap; +use crate::state::spot_market::{SpotBalanceType, SpotMarket}; +use crate::state::spot_market_map::SpotMarketMap; +use crate::state::user::{PerpPosition, PositionFlag, SpotPosition, User, UserStats}; +use crate::test_utils::{ + create_account_info, get_account_bytes, get_anchor_account_bytes, get_positions, + get_pyth_price, get_spot_positions, +}; +use crate::{create_anchor_account_info, PRICE_PRECISION_I64}; + +#[test] +fn can_transfer_to_isolated_when_cross_still_meets_after_withdraw() { + let now = 0_i64; + let slot = 0_u64; + + let mut oracle_price = get_pyth_price(100, 6); + let oracle_price_key = + Pubkey::from_str("J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix").unwrap(); + let pyth_program = crate::ids::pyth_program::id(); + create_account_info!( + oracle_price, + &oracle_price_key, + &pyth_program, + oracle_account_info + ); + let mut oracle_map = OracleMap::load_one(&oracle_account_info, slot, None).unwrap(); + + let oracle_price_val = oracle_price.agg.price; + let mut market = PerpMarket { + market_index: 0, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 100 * PEG_PRECISION, + max_slippage_ratio: 50, + max_fill_reserve_fraction: 100, + order_step_size: 10000000, + quote_asset_amount: -150 * QUOTE_PRECISION_I128, + base_asset_amount_with_amm: AMM_RESERVE_PRECISION as i128, + oracle: oracle_price_key, + historical_oracle_data: HistoricalOracleData::default_price(oracle_price_val), + ..AMM::default() + }, + margin_ratio_initial: 1000, + margin_ratio_maintenance: 500, + number_of_users_with_base: 1, + status: MarketStatus::Initialized, + liquidator_fee: LIQUIDATION_FEE_PRECISION / 100, + if_liquidation_fee: LIQUIDATION_FEE_PRECISION / 100, + ..PerpMarket::default() + }; + create_anchor_account_info!(market, PerpMarket, market_account_info); + let perp_market_map = PerpMarketMap::load_one(&market_account_info, true).unwrap(); + + let mut spot_market = SpotMarket { + market_index: 0, + oracle_source: OracleSource::QuoteAsset, + cumulative_deposit_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + cumulative_borrow_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + decimals: 6, + initial_asset_weight: SPOT_WEIGHT_PRECISION, + maintenance_asset_weight: SPOT_WEIGHT_PRECISION, + initial_liability_weight: SPOT_WEIGHT_PRECISION, + maintenance_liability_weight: SPOT_WEIGHT_PRECISION, + historical_oracle_data: HistoricalOracleData { + last_oracle_price_twap: PRICE_PRECISION_I64, + last_oracle_price_twap_5min: PRICE_PRECISION_I64, + ..HistoricalOracleData::default() + }, + ..SpotMarket::default() + }; + create_anchor_account_info!(spot_market, SpotMarket, spot_market_account_info); + let spot_market_map = SpotMarketMap::load_one(&spot_market_account_info, true).unwrap(); + + // User state AFTER transfer: cross has 400 USDC, isolated perp has received 100. + // No cross perp positions, so cross margin requirement = 0. Cross still meets. + let transfer_amount = 100 * 1_000_000_u128; // 100 USDC (6 decimals) + let cross_after = 400 * SPOT_BALANCE_PRECISION_U64; + + let mut user = User { + spot_positions: get_spot_positions(SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: cross_after, + ..SpotPosition::default() + }), + perp_positions: get_positions(PerpPosition { + market_index: 0, + base_asset_amount: 0, + quote_asset_amount: 0, + quote_entry_amount: 0, + quote_break_even_amount: 0, + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: transfer_amount as u64, + ..PerpPosition::default() + }), + ..User::default() + }; + + let mut user_stats = UserStats::default(); + + let result = user.meets_transfer_isolated_position_deposit_margin_requirement( + &perp_market_map, + &spot_market_map, + &mut oracle_map, + MarginTypeConfig::CrossMarginOverride { + margin_requirement_type: MarginRequirementType::Initial, + default_margin_requirement_type: MarginRequirementType::Maintenance, + }, + 0, + transfer_amount, + &mut user_stats, + now, + true, + 0, + ); + + assert!(result.is_ok(), "expected Ok, got {:?}", result); + assert_eq!(result.unwrap(), true); +} + +#[test] +fn cannot_transfer_to_isolated_when_cross_would_fail_after_withdraw() { + let now = 0_i64; + let slot = 0_u64; + + let mut oracle_price = get_pyth_price(100, 6); + let oracle_price_key = + Pubkey::from_str("J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix").unwrap(); + let pyth_program = crate::ids::pyth_program::id(); + create_account_info!( + oracle_price, + &oracle_price_key, + &pyth_program, + oracle_account_info + ); + let mut oracle_map = OracleMap::load_one(&oracle_account_info, slot, None).unwrap(); + + let oracle_price_val = oracle_price.agg.price; + let mut market0 = PerpMarket { + market_index: 0, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 100 * PEG_PRECISION, + max_slippage_ratio: 50, + max_fill_reserve_fraction: 100, + order_step_size: 10000000, + quote_asset_amount: -150 * QUOTE_PRECISION_I128, + base_asset_amount_with_amm: AMM_RESERVE_PRECISION as i128, + oracle: oracle_price_key, + historical_oracle_data: HistoricalOracleData::default_price(oracle_price_val), + ..AMM::default() + }, + margin_ratio_initial: 1000, + margin_ratio_maintenance: 500, + number_of_users_with_base: 1, + status: MarketStatus::Initialized, + liquidator_fee: LIQUIDATION_FEE_PRECISION / 100, + if_liquidation_fee: LIQUIDATION_FEE_PRECISION / 100, + ..PerpMarket::default() + }; + create_anchor_account_info!(market0, PerpMarket, market0_account_info); + let mut market1 = market0.clone(); + market1.market_index = 1; + create_anchor_account_info!(market1, PerpMarket, market1_account_info); + let market_account_infos = vec![market0_account_info, market1_account_info]; + let market_set = BTreeSet::default(); + let perp_market_map: PerpMarketMap<'_> = + PerpMarketMap::load(&market_set, &mut market_account_infos.iter().peekable()).unwrap(); + + let mut spot_market = SpotMarket { + market_index: 0, + oracle_source: OracleSource::QuoteAsset, + cumulative_deposit_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + cumulative_borrow_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + decimals: 6, + initial_asset_weight: SPOT_WEIGHT_PRECISION, + maintenance_asset_weight: SPOT_WEIGHT_PRECISION, + initial_liability_weight: SPOT_WEIGHT_PRECISION, + maintenance_liability_weight: SPOT_WEIGHT_PRECISION, + historical_oracle_data: HistoricalOracleData { + last_oracle_price_twap: PRICE_PRECISION_I64, + last_oracle_price_twap_5min: PRICE_PRECISION_I64, + ..HistoricalOracleData::default() + }, + ..SpotMarket::default() + }; + create_anchor_account_info!(spot_market, SpotMarket, spot_market_account_info); + let spot_market_map = SpotMarketMap::load_one(&spot_market_account_info, true).unwrap(); + + // User state AFTER transfer: cross has only 30 USDC (we transferred 50), and has a cross + // perp position on market 1: 10 long @ $100 = $1000 notional, initial margin 10% = $100. + // So cross needs $100 but only has $60 -> fails. + let cross_after = 60 * SPOT_BALANCE_PRECISION_U64; + let transfer_amount = 50 * 1_000_000_u128; + + let mut user = User { + spot_positions: get_spot_positions(SpotPosition { + market_index: 0, + balance_type: SpotBalanceType::Deposit, + scaled_balance: cross_after, + ..SpotPosition::default() + }), + perp_positions: get_positions(PerpPosition { + market_index: 0, + base_asset_amount: 0, + quote_asset_amount: 0, + quote_entry_amount: 0, + quote_break_even_amount: 0, + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: transfer_amount as u64, + ..PerpPosition::default() + }), + ..User::default() + }; + user.perp_positions[1] = PerpPosition { + market_index: 1, + base_asset_amount: 10 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + quote_entry_amount: -1000 * QUOTE_PRECISION_I64, + quote_break_even_amount: -1000 * QUOTE_PRECISION_I64, + position_flag: 0, // cross position + ..PerpPosition::default() + }; + + let mut user_stats = UserStats::default(); + + let result = user.meets_transfer_isolated_position_deposit_margin_requirement( + &perp_market_map, + &spot_market_map, + &mut oracle_map, + MarginTypeConfig::CrossMarginOverride { + margin_requirement_type: MarginRequirementType::Initial, + default_margin_requirement_type: MarginRequirementType::Maintenance, + }, + 0, + transfer_amount, + &mut user_stats, + now, + true, + 0, + ); + + assert_eq!(result, Err(ErrorCode::InsufficientCollateral)); +} + +#[test] +fn can_transfer_from_isolated_when_isolated_still_meets_after_withdraw() { + let now = 0_i64; + let slot = 0_u64; + + let mut oracle_price = get_pyth_price(100, 6); + let oracle_price_key = + Pubkey::from_str("J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix").unwrap(); + let pyth_program = crate::ids::pyth_program::id(); + create_account_info!( + oracle_price, + &oracle_price_key, + &pyth_program, + oracle_account_info + ); + let mut oracle_map = OracleMap::load_one(&oracle_account_info, slot, None).unwrap(); + + let oracle_price_val = oracle_price.agg.price; + let mut market = PerpMarket { + market_index: 0, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 100 * PEG_PRECISION, + max_slippage_ratio: 50, + max_fill_reserve_fraction: 100, + order_step_size: 10000000, + quote_asset_amount: -150 * QUOTE_PRECISION_I128, + base_asset_amount_with_amm: AMM_RESERVE_PRECISION as i128, + oracle: oracle_price_key, + historical_oracle_data: HistoricalOracleData::default_price(oracle_price_val), + ..AMM::default() + }, + margin_ratio_initial: 1000, + margin_ratio_maintenance: 500, + number_of_users_with_base: 1, + status: MarketStatus::Initialized, + liquidator_fee: LIQUIDATION_FEE_PRECISION / 100, + if_liquidation_fee: LIQUIDATION_FEE_PRECISION / 100, + ..PerpMarket::default() + }; + create_anchor_account_info!(market, PerpMarket, market_account_info); + let perp_market_map = PerpMarketMap::load_one(&market_account_info, true).unwrap(); + + let mut spot_market = SpotMarket { + market_index: 0, + oracle_source: OracleSource::QuoteAsset, + cumulative_deposit_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + cumulative_borrow_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + decimals: 6, + initial_asset_weight: SPOT_WEIGHT_PRECISION, + maintenance_asset_weight: SPOT_WEIGHT_PRECISION, + initial_liability_weight: SPOT_WEIGHT_PRECISION, + maintenance_liability_weight: SPOT_WEIGHT_PRECISION, + historical_oracle_data: HistoricalOracleData { + last_oracle_price_twap: PRICE_PRECISION_I64, + last_oracle_price_twap_5min: PRICE_PRECISION_I64, + ..HistoricalOracleData::default() + }, + ..SpotMarket::default() + }; + create_anchor_account_info!(spot_market, SpotMarket, spot_market_account_info); + let spot_market_map = SpotMarketMap::load_one(&spot_market_account_info, true).unwrap(); + + // Isolated position: 1 SOL long @ $100 = $100 notional, initial margin 10% = $10. + // Isolated collateral $200 -> easily meets. Controller passes (0, 0) for withdraw when + // transferring from isolated to cross, so we're checking current state. + let isolated_collateral = 200 * SPOT_BALANCE_PRECISION_U64; + + let mut user = User { + spot_positions: [SpotPosition::default(); 8], + perp_positions: get_positions(PerpPosition { + market_index: 0, + base_asset_amount: 1 * BASE_PRECISION_I64, + quote_asset_amount: -100 * QUOTE_PRECISION_I64, + quote_entry_amount: -100 * QUOTE_PRECISION_I64, + quote_break_even_amount: -100 * QUOTE_PRECISION_I64, + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: isolated_collateral, + ..PerpPosition::default() + }), + ..User::default() + }; + + let mut user_stats = UserStats::default(); + + let result = user.meets_transfer_isolated_position_deposit_margin_requirement( + &perp_market_map, + &spot_market_map, + &mut oracle_map, + MarginTypeConfig::IsolatedPositionOverride { + margin_requirement_type: MarginRequirementType::Initial, + default_isolated_margin_requirement_type: MarginRequirementType::Maintenance, + cross_margin_requirement_type: MarginRequirementType::Maintenance, + market_index: 0, + }, + 0, + 0, + &mut user_stats, + now, + false, + 0, + ); + + assert!(result.is_ok(), "expected Ok, got {:?}", result); + assert_eq!(result.unwrap(), true); +} + +#[test] +fn cannot_transfer_from_isolated_when_isolated_would_fail() { + let now = 0_i64; + let slot = 0_u64; + + let mut oracle_price = get_pyth_price(100, 6); + let oracle_price_key = + Pubkey::from_str("J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix").unwrap(); + let pyth_program = crate::ids::pyth_program::id(); + create_account_info!( + oracle_price, + &oracle_price_key, + &pyth_program, + oracle_account_info + ); + let mut oracle_map = OracleMap::load_one(&oracle_account_info, slot, None).unwrap(); + + let oracle_price_val = oracle_price.agg.price; + let mut market = PerpMarket { + market_index: 0, + amm: AMM { + base_asset_reserve: 100 * AMM_RESERVE_PRECISION, + quote_asset_reserve: 100 * AMM_RESERVE_PRECISION, + bid_base_asset_reserve: 101 * AMM_RESERVE_PRECISION, + bid_quote_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_base_asset_reserve: 99 * AMM_RESERVE_PRECISION, + ask_quote_asset_reserve: 101 * AMM_RESERVE_PRECISION, + sqrt_k: 100 * AMM_RESERVE_PRECISION, + peg_multiplier: 100 * PEG_PRECISION, + max_slippage_ratio: 50, + max_fill_reserve_fraction: 100, + order_step_size: 10000000, + quote_asset_amount: -150 * QUOTE_PRECISION_I128, + base_asset_amount_with_amm: AMM_RESERVE_PRECISION as i128, + oracle: oracle_price_key, + historical_oracle_data: HistoricalOracleData::default_price(oracle_price_val), + ..AMM::default() + }, + margin_ratio_initial: 1000, + margin_ratio_maintenance: 500, + number_of_users_with_base: 1, + status: MarketStatus::Initialized, + liquidator_fee: LIQUIDATION_FEE_PRECISION / 100, + if_liquidation_fee: LIQUIDATION_FEE_PRECISION / 100, + ..PerpMarket::default() + }; + create_anchor_account_info!(market, PerpMarket, market_account_info); + let perp_market_map = PerpMarketMap::load_one(&market_account_info, true).unwrap(); + + let mut spot_market = SpotMarket { + market_index: 0, + oracle_source: OracleSource::QuoteAsset, + cumulative_deposit_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + cumulative_borrow_interest: SPOT_CUMULATIVE_INTEREST_PRECISION, + decimals: 6, + initial_asset_weight: SPOT_WEIGHT_PRECISION, + maintenance_asset_weight: SPOT_WEIGHT_PRECISION, + initial_liability_weight: SPOT_WEIGHT_PRECISION, + maintenance_liability_weight: SPOT_WEIGHT_PRECISION, + historical_oracle_data: HistoricalOracleData { + last_oracle_price_twap: PRICE_PRECISION_I64, + last_oracle_price_twap_5min: PRICE_PRECISION_I64, + ..HistoricalOracleData::default() + }, + ..SpotMarket::default() + }; + create_anchor_account_info!(spot_market, SpotMarket, spot_market_account_info); + let spot_market_map = SpotMarketMap::load_one(&spot_market_account_info, true).unwrap(); + + // Isolated position: 10 SOL long @ $100 = $1000 notional, initial margin 10% = $100. + // Isolated collateral only $30 (e.g. after moving most to cross) -> fails Initial. + let isolated_collateral = 30 * SPOT_BALANCE_PRECISION_U64; + + let mut user = User { + spot_positions: [SpotPosition::default(); 8], + perp_positions: get_positions(PerpPosition { + market_index: 0, + base_asset_amount: 10 * BASE_PRECISION_I64, + quote_asset_amount: -1000 * QUOTE_PRECISION_I64, + quote_entry_amount: -1000 * QUOTE_PRECISION_I64, + quote_break_even_amount: -1000 * QUOTE_PRECISION_I64, + position_flag: PositionFlag::IsolatedPosition as u8, + isolated_position_scaled_balance: isolated_collateral, + ..PerpPosition::default() + }), + ..User::default() + }; + + let mut user_stats = UserStats::default(); + + let result = user.meets_transfer_isolated_position_deposit_margin_requirement( + &perp_market_map, + &spot_market_map, + &mut oracle_map, + MarginTypeConfig::IsolatedPositionOverride { + market_index: 0, + margin_requirement_type: MarginRequirementType::Initial, + default_isolated_margin_requirement_type: MarginRequirementType::Maintenance, + cross_margin_requirement_type: MarginRequirementType::Maintenance, + }, + 0, + 0, + &mut user_stats, + now, + false, + 0, + ); + + assert_eq!(result, Err(ErrorCode::InsufficientCollateral)); +} diff --git a/sdk/src/idl/drift.json b/sdk/src/idl/drift.json index 4ea43185b..6df057787 100644 --- a/sdk/src/idl/drift.json +++ b/sdk/src/idl/drift.json @@ -15693,6 +15693,66 @@ ] } }, + { + "name": "MarginTypeConfig", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Default", + "fields": [ + { + "defined": "MarginRequirementType" + } + ] + }, + { + "name": "IsolatedPositionOverride", + "fields": [ + { + "name": "marketIndex", + "type": "u16" + }, + { + "name": "marginRequirementType", + "type": { + "defined": "MarginRequirementType" + } + }, + { + "name": "defaultIsolatedMarginRequirementType", + "type": { + "defined": "MarginRequirementType" + } + }, + { + "name": "crossMarginRequirementType", + "type": { + "defined": "MarginRequirementType" + } + } + ] + }, + { + "name": "CrossMarginOverride", + "fields": [ + { + "name": "marginRequirementType", + "type": { + "defined": "MarginRequirementType" + } + }, + { + "name": "defaultMarginRequirementType", + "type": { + "defined": "MarginRequirementType" + } + } + ] + } + ] + } + }, { "name": "OracleSource", "type": { diff --git a/test-scripts/run-anchor-tests.sh b/test-scripts/run-anchor-tests.sh old mode 100644 new mode 100755 index 3b87eb2d6..34fcf4705 --- a/test-scripts/run-anchor-tests.sh +++ b/test-scripts/run-anchor-tests.sh @@ -62,6 +62,8 @@ test_files=( oracleFillPriceGuardrails.ts oracleOffsetOrders.ts order.ts + orderMarginChecks.ts + isolatedTransferMarginChecks.ts ordersWithSpread.ts pauseExchange.ts pauseDepositWithdraw.ts diff --git a/tests/isolatedTransferMarginChecks.ts b/tests/isolatedTransferMarginChecks.ts new file mode 100644 index 000000000..564033bd5 --- /dev/null +++ b/tests/isolatedTransferMarginChecks.ts @@ -0,0 +1,741 @@ +import * as anchor from '@coral-xyz/anchor'; +import { assert } from 'chai'; +import { + BN, + OracleSource, + ZERO, + MARGIN_PRECISION, + PositionDirection, + PEG_PRECISION, + SettlePnlMode, +} from '../sdk'; + +import { Program } from '@coral-xyz/anchor'; + +import { TestClient, EventSubscriber } from '../sdk/src'; + +import { + mockUSDCMint, + mockUserUSDCAccount, + mockOracleNoProgram, + setFeedPriceNoProgram, + initializeQuoteSpotMarket, +} from './testHelpers'; +import { startAnchor } from 'solana-bankrun'; +import { TestBulkAccountLoader } from '../sdk/src/accounts/testBulkAccountLoader'; +import { BankrunContextWrapper } from '../sdk/src/bankrun/bankrunConnection'; + +describe('isolated transfer margin checks', () => { + const chProgram = anchor.workspace.Drift as Program; + + let driftClient: TestClient; + let eventSubscriber: EventSubscriber; + + let bankrunContextWrapper: BankrunContextWrapper; + + let bulkAccountLoader: TestBulkAccountLoader; + + let usdcMint; + let userUSDCAccount; + + let solUsd; + let ethUsd; + let btcUsd; + + // ammInvariant == k == x * y + const mantissaSqrtScale = new BN(100000); + const ammInitialQuoteAssetAmount = new anchor.BN(5 * 10 ** 13).mul( + mantissaSqrtScale + ); + const ammInitialBaseAssetAmount = new anchor.BN(5 * 10 ** 13).mul( + mantissaSqrtScale + ); + + // Large amount of USDC for testing + const largeUsdcAmount = new BN(10000 * 10 ** 6); // 10,000 USDC + + // Helper to suppress console output during expected failures + const suppressConsole = () => { + const oldConsoleLog = console.log; + const oldConsoleError = console.error; + console.log = function () { + /* noop */ + }; + console.error = function () { + /* noop */ + }; + return () => { + console.log = oldConsoleLog; + console.error = oldConsoleError; + }; + }; + + before(async () => { + const context = await startAnchor('', [], []); + + bankrunContextWrapper = new BankrunContextWrapper(context); + + bulkAccountLoader = new TestBulkAccountLoader( + bankrunContextWrapper.connection, + 'processed', + 1 + ); + + usdcMint = await mockUSDCMint(bankrunContextWrapper); + userUSDCAccount = await mockUserUSDCAccount( + usdcMint, + largeUsdcAmount, + bankrunContextWrapper + ); + + // Create oracles for SOL, ETH and BTC + solUsd = await mockOracleNoProgram(bankrunContextWrapper, 100); // $100 per SOL + ethUsd = await mockOracleNoProgram(bankrunContextWrapper, 1000); // $1000 per ETH + btcUsd = await mockOracleNoProgram(bankrunContextWrapper, 100000); // $100000 per BTC + + eventSubscriber = new EventSubscriber( + bankrunContextWrapper.connection.toConnection(), + chProgram + ); + + await eventSubscriber.subscribe(); + + driftClient = new TestClient({ + connection: bankrunContextWrapper.connection.toConnection(), + wallet: bankrunContextWrapper.provider.wallet, + programID: chProgram.programId, + opts: { + commitment: 'confirmed', + }, + activeSubAccountId: 0, + perpMarketIndexes: [0, 1, 2], + spotMarketIndexes: [0], + subAccountIds: [], + oracleInfos: [ + { publicKey: solUsd, source: OracleSource.PYTH }, + { publicKey: ethUsd, source: OracleSource.PYTH }, + { publicKey: btcUsd, source: OracleSource.PYTH }, + ], + userStats: true, + accountSubscription: { + type: 'polling', + accountLoader: bulkAccountLoader, + }, + }); + + await driftClient.initialize(usdcMint.publicKey, true); + + await driftClient.subscribe(); + await driftClient.updatePerpAuctionDuration(new BN(0)); + + await initializeQuoteSpotMarket(driftClient, usdcMint.publicKey); + + const periodicity = new BN(60 * 60); // 1 HOUR + + // Initialize SOL-PERP market (index 0) + await driftClient.initializePerpMarket( + 0, + solUsd, + ammInitialBaseAssetAmount, + ammInitialQuoteAssetAmount, + periodicity, + new BN(100 * PEG_PRECISION.toNumber()) + ); + + // Initialize ETH-PERP market (index 1) + await driftClient.initializePerpMarket( + 1, + ethUsd, + ammInitialBaseAssetAmount, + ammInitialQuoteAssetAmount, + periodicity, + new BN(1000 * PEG_PRECISION.toNumber()) + ); + + // Initialize BTC-PERP market (index 2) + await driftClient.initializePerpMarket( + 2, + btcUsd, + ammInitialBaseAssetAmount, + ammInitialQuoteAssetAmount, + periodicity, + new BN(100000 * PEG_PRECISION.toNumber()) + ); + + // Set step sizes + await driftClient.updatePerpMarketStepSizeAndTickSize( + 0, + new BN(1), + new BN(1) + ); + await driftClient.updatePerpMarketStepSizeAndTickSize( + 1, + new BN(1), + new BN(1) + ); + await driftClient.updatePerpMarketStepSizeAndTickSize( + 2, + new BN(1), + new BN(1) + ); + + // Set margin ratios: 50% initial, 33% maintenance + await driftClient.updatePerpMarketMarginRatio( + 0, + MARGIN_PRECISION.toNumber() / 2, // 50% IM + MARGIN_PRECISION.toNumber() / 3 // 33% MM + ); + await driftClient.updatePerpMarketMarginRatio( + 1, + MARGIN_PRECISION.toNumber() / 2, // 50% IM + MARGIN_PRECISION.toNumber() / 3 // 33% MM + ); + await driftClient.updatePerpMarketMarginRatio( + 2, + MARGIN_PRECISION.toNumber() / 2, // 50% IM + MARGIN_PRECISION.toNumber() / 3 // 33% MM + ); + + // Initialize user account + await driftClient.initializeUserAccount(); + console.log('Initialized user account'); + }); + + after(async () => { + await driftClient.unsubscribe(); + await eventSubscriber.unsubscribe(); + }); + + // Reset user state between tests + // Rules: cross must pass IM after transfer, no other isolated may fail MM + async function resetUserState() { + // Restore oracle feeds to default prices so tests start with deterministic state + await setFeedPriceNoProgram(bankrunContextWrapper, 100, solUsd); + await setFeedPriceNoProgram(bankrunContextWrapper, 1000, ethUsd); + await setFeedPriceNoProgram(bankrunContextWrapper, 100000, btcUsd); + + await driftClient.fetchAccounts(); + + // Close any open positions + const user = driftClient.getUserAccount(); + for (const perpPosition of user.perpPositions) { + if (!perpPosition.baseAssetAmount.eq(ZERO)) { + try { + await driftClient.closePosition(perpPosition.marketIndex); + } catch (e) { + // Ignore + } finally { + await driftClient.fetchAccounts(); + } + } + } + + // Settle PNL for all markets + try { + await driftClient.settleMultiplePNLs( + await driftClient.getUserAccountPublicKey(), + driftClient.getUserAccount(), + [0], + SettlePnlMode.TRY_SETTLE + ); + } catch (e) { + // Ignore + } + try { + await driftClient.settlePNL( + await driftClient.getUserAccountPublicKey(), + driftClient.getUserAccount(), + 1 + ); + } catch (e) { + // Ignore + } + try { + await driftClient.settlePNL( + await driftClient.getUserAccountPublicKey(), + driftClient.getUserAccount(), + 2 + ); + } catch (e) { + // Ignore + } + + await driftClient.fetchAccounts(); + + // Transfer isolated collateral back to cross if any + for (const perpPosition of driftClient.getUserAccount().perpPositions) { + const isolatedBalance = driftClient.getIsolatedPerpPositionTokenAmount( + perpPosition.marketIndex + ); + if (isolatedBalance.gt(ZERO)) { + try { + await driftClient.transferIsolatedPerpPositionDeposit( + isolatedBalance.neg(), + perpPosition.marketIndex, + undefined, + undefined, + undefined, + true + ); + } catch (e) { + // Ignore + } + } + } + + // Withdraw all cross collateral + await driftClient.fetchAccounts(); + const crossBalance = driftClient.getQuoteAssetTokenAmount(); + if (crossBalance.gt(ZERO)) { + try { + await driftClient.withdraw(crossBalance, 0, userUSDCAccount.publicKey); + } catch (e) { + // Ignore + } + } + + await driftClient.fetchAccounts(); + } + + describe('Scenario 1: Cross passes IM before and after transfer, no other isolateds', () => { + it('should pass transfer when cross has plenty before and after', async () => { + await resetUserState(); + + // Cross: $1000 USDC, no positions -> $0 IM required + // Transfer $200 to isolated ETH (empty slot). After: cross $800, isolated ETH $200. + // Cross still $0 IM -> PASS + + await driftClient.deposit( + new BN(1000 * 10 ** 6), + 0, + userUSDCAccount.publicKey + ); + + const txSig = await driftClient.transferIsolatedPerpPositionDeposit( + new BN(200 * 10 ** 6), + 1, + undefined, + undefined, + undefined, + true + ); + + assert(txSig, 'Transfer should have passed'); + await driftClient.fetchAccounts(); + assert( + driftClient + .getIsolatedPerpPositionTokenAmount(1) + .eq(new BN(200 * 10 ** 6)), + 'Isolated ETH should have 200' + ); + assert( + driftClient.getQuoteAssetTokenAmount().eq(new BN(800 * 10 ** 6)), + 'Cross should have 800' + ); + }); + }); + + describe('Scenario 2: Cross passes IM before but fails after transfer, no other isolateds', () => { + it('should fail transfer when cross would fail IM after', async () => { + await resetUserState(); + + // Cross: $700, 10 SOL long @ $100 -> $500 IM required + // Transfer $250 to isolated ETH. After: cross $450 < $500 IM -> FAIL + + await driftClient.deposit( + new BN(700 * 10 ** 6), + 0, + userUSDCAccount.publicKey + ); + await driftClient.openPosition( + PositionDirection.LONG, + new BN(10 * 10 ** 9), + 0 + ); + + const restoreConsole = suppressConsole(); + try { + await driftClient.transferIsolatedPerpPositionDeposit( + new BN(250 * 10 ** 6), + 1, + undefined, + undefined, + undefined, + true + ); + assert(false, 'Transfer should have failed - cross would fail IM'); + } catch (e) { + if (e.message.includes('0x1773')) { + assert(true, 'Transfer correctly failed'); + } else { + throw e; + } + } finally { + restoreConsole(); + } + await driftClient.fetchAccounts(); + assert( + driftClient.getQuoteAssetTokenAmount().eq(new BN(700 * 10 ** 6)), + 'Cross should be unchanged' + ); + }); + }); + + describe('Scenario 3: Cross fails IM, no other isolateds', () => { + it('should fail transfer when cross already fails IM', async () => { + await resetUserState(); + + // Cross: $600, 10 SOL long @ $100 -> $500 IM required + // SOL price moves to $70, cross has $300 effective collateral, IM is 10 x 70 x .5 = $350 + // Transfer $100 to isolated. Should fail (cross already below IM) + + await driftClient.deposit( + new BN(600 * 10 ** 6), + 0, + userUSDCAccount.publicKey + ); + await driftClient.openPosition( + PositionDirection.LONG, + new BN(10 * 10 ** 9), + 0 + ); + // 10 SOL @ 100->70: loss 200, effective 300 collateral, need 350 IM + await setFeedPriceNoProgram(bankrunContextWrapper, 70, solUsd); + await driftClient.fetchAccounts(); + + const restoreConsole = suppressConsole(); + try { + await driftClient.transferIsolatedPerpPositionDeposit( + new BN(100 * 10 ** 6), + 1, + undefined, + undefined, + undefined, + true + ); + assert(false, 'Transfer should have failed - cross fails IM'); + } catch (e) { + if (e.message.includes('0x1773')) { + assert(true, 'Transfer correctly failed'); + } else { + throw e; + } + } finally { + restoreConsole(); + } + }); + }); + + describe('Scenario 4: Cross fails MM, no other isolateds', () => { + it('should fail transfer when cross fails MM', async () => { + await resetUserState(); + + // Cross: $300 effective, 10 SOL long -> $333 MM required, cross fails MM + // 10 SOL @ 100->70: loss 300, effective 300, MM 333 + await driftClient.deposit( + new BN(600 * 10 ** 6), + 0, + userUSDCAccount.publicKey + ); + await driftClient.openPosition( + PositionDirection.LONG, + new BN(10 * 10 ** 9), + 0 + ); + await setFeedPriceNoProgram(bankrunContextWrapper, 70, solUsd); + await driftClient.fetchAccounts(); + + const restoreConsole = suppressConsole(); + try { + await driftClient.transferIsolatedPerpPositionDeposit( + new BN(50 * 10 ** 6), + 1, + undefined, + undefined, + undefined, + true + ); + assert(false, 'Transfer should have failed - cross fails MM'); + } catch (e) { + if (e.message.includes('0x1773')) { + assert(true, 'Transfer correctly failed'); + } else { + throw e; + } + } finally { + restoreConsole(); + } + }); + }); + + describe('Scenario 5: Cross passes IM before and after, other isolated fails MM', () => { + it('should fail transfer when other isolated fails MM', async () => { + await resetUserState(); + + // Cross: $2000, no cross positions. Other isolated: SOL 10 long with $600 collateral, + // SOL at 50 -> effective $100 < $333 MM. Transfer $200 to isolated ETH. + // Cross after $600, no IM. But other isolated fails MM -> FAIL + + await driftClient.deposit( + new BN(2000 * 10 ** 6), + 0, + userUSDCAccount.publicKey + ); + await driftClient.transferIsolatedPerpPositionDeposit( + new BN(600 * 10 ** 6), + 0 + ); + await driftClient.openPosition( + PositionDirection.LONG, + new BN(10 * 10 ** 9), + 0 + ); + // SOL at 50: 10*(100-50)=500 loss, 600-500=100 < 333 MM + await setFeedPriceNoProgram(bankrunContextWrapper, 50, solUsd); + await driftClient.fetchAccounts(); + + // Cross has 1400, isolated SOL has 100 effective (fails MM) + const restoreConsole = suppressConsole(); + try { + await driftClient.transferIsolatedPerpPositionDeposit( + new BN(200 * 10 ** 6), + 1, + undefined, + undefined, + undefined, + true + ); + assert(false, 'Transfer should have failed - other isolated fails MM'); + } catch (e) { + if (e.message.includes('0x1773')) { + assert(true, 'Transfer correctly failed'); + } else { + throw e; + } + } finally { + restoreConsole(); + } + }); + }); + + describe('Scenario 6: Cross passes IM before and after, other isolated passes MM', () => { + it('should pass transfer when other isolated passes MM', async () => { + await resetUserState(); + + // Cross: $800, no cross positions. Other isolated: SOL 10 long with $400 collateral, + // SOL at 80 -> effective $400 > $333 MM. Transfer $200 to isolated ETH. + // Cross after $600. Other isolated passes MM -> PASS + + await driftClient.deposit( + new BN(2000 * 10 ** 6), + 0, + userUSDCAccount.publicKey + ); + await driftClient.depositIntoIsolatedPerpPosition( + new BN(600 * 10 ** 6), + 0, + userUSDCAccount.publicKey + ); + await driftClient.openPosition( + PositionDirection.LONG, + new BN(10 * 10 ** 9), + 0 + ); + // SOL at 80: 10*(100-80)=200 loss, 600-200=400 > 333 MM + await setFeedPriceNoProgram(bankrunContextWrapper, 80, solUsd); + await driftClient.fetchAccounts(); + + const txSig = await driftClient.transferIsolatedPerpPositionDeposit( + new BN(200 * 10 ** 6), + 1, + undefined, + undefined, + undefined, + true + ); + + assert(txSig, 'Transfer should have passed'); + await driftClient.fetchAccounts(); + assert( + driftClient + .getIsolatedPerpPositionTokenAmount(1) + .eq(new BN(200 * 10 ** 6)), + 'Isolated ETH should have 200' + ); + }); + }); + + describe('Scenario 7: Cross passes IM before but fails after, other isolated fails MM', () => { + it('should fail when both cross would fail IM after and other isolated fails MM', async () => { + await resetUserState(); + + // Cross: $700, 10 SOL long ($500 IM). Other isolated: ETH 1 long with $600, + // ETH at 700 -> effective $300 < $333 MM. Transfer $250. + // Cross after $450 < $500 IM. Other isolated fails MM. FAIL + + await driftClient.deposit( + new BN(700 * 10 ** 6), + 0, + userUSDCAccount.publicKey + ); + await driftClient.openPosition( + PositionDirection.LONG, + new BN(10 * 10 ** 9), + 0 + ); + await driftClient.depositIntoIsolatedPerpPosition( + new BN(600 * 10 ** 6), + 1, + userUSDCAccount.publicKey + ); + await driftClient.openPosition( + PositionDirection.LONG, + new BN(1 * 10 ** 9), + 1 + ); + // Cross: 700, 10 SOL @ 100. Sol at 100, cross IM 500, cross ok. + // ETH at 700: 1*(1000-700)=300 loss, 600-300=300 < 333 MM + await setFeedPriceNoProgram(bankrunContextWrapper, 700, ethUsd); + await driftClient.fetchAccounts(); + + // const restoreConsole = suppressConsole(); + try { + await driftClient.transferIsolatedPerpPositionDeposit( + new BN(250 * 10 ** 6), + 2, + undefined, + undefined, + undefined, + true + ); + assert(false, 'Transfer should have failed'); + } catch (e) { + if (e.message.includes('0x1773')) { + assert(true, 'Transfer correctly failed'); + } else { + throw e; + } + } finally { + // restoreConsole(); + } + }); + }); + + describe('Scenario 8: Cross passes IM before but fails after, other isolated passes MM', () => { + it('should fail when cross would fail IM after even if other isolated passes MM', async () => { + await resetUserState(); + + // Cross: $700, 10 SOL long ($500 IM). Other isolated: ETH 1 long with $600, + // ETH at 800 -> $200 loss, effective collateral $400 > $333 MM. Transfer $250. + // Cross after transfer is $450 < $500 IM -> FAIL (other isolated is fine) + + await driftClient.deposit( + new BN(700 * 10 ** 6), + 0, + userUSDCAccount.publicKey + ); + await driftClient.openPosition( + PositionDirection.LONG, + new BN(10 * 10 ** 9), + 0 + ); + await driftClient.depositIntoIsolatedPerpPosition( + new BN(600 * 10 ** 6), + 1, + userUSDCAccount.publicKey + ); + await driftClient.openPosition( + PositionDirection.LONG, + new BN(1 * 10 ** 9), + 1 + ); + // ETH at 800: 1*(1000-800)=200 loss, 600-200=400 > 333 MM - passes + await setFeedPriceNoProgram(bankrunContextWrapper, 800, ethUsd); + await driftClient.fetchAccounts(); + + const restoreConsole = suppressConsole(); + try { + await driftClient.transferIsolatedPerpPositionDeposit( + new BN(250 * 10 ** 6), + 1, + undefined, + undefined, + undefined, + true + ); + assert(false, 'Transfer should have failed - cross would fail IM'); + } catch (e) { + if (e.message.includes('0x1773')) { + assert(true, 'Transfer correctly failed'); + } else { + throw e; + } + } finally { + restoreConsole(); + } + }); + }); + + describe('Multi-isolated: one passes MM one fails blocks transfer', () => { + it('should fail when any of multiple other isolated fails MM', async () => { + await resetUserState(); + + // Cross: $2000. Isolated SOL: 10 long, $400 collateral at 80 -> passes MM. + // Isolated ETH: 1 long, $300 collateral at 600 -> fails MM ($333). + // Transfer $100 to... we need a third market. We only have SOL and ETH. + // So: SOL isolated passes, ETH isolated fails. Transfer cross->ETH isolated (adding to failing one) + // actually that would improve ETH. Let me reconsider. + // Transfer cross->SOL isolated (adding to passing one) while ETH fails MM -> FAIL + await driftClient.deposit( + new BN(2000 * 10 ** 6), + 0, + userUSDCAccount.publicKey + ); + await driftClient.depositIntoIsolatedPerpPosition( + new BN(600 * 10 ** 6), + 0, + userUSDCAccount.publicKey + ); + await driftClient.openPosition( + PositionDirection.LONG, + new BN(10 * 10 ** 9), + 0 + ); + await driftClient.depositIntoIsolatedPerpPosition( + new BN(600 * 10 ** 6), + 1, + userUSDCAccount.publicKey + ); + await driftClient.openPosition( + PositionDirection.LONG, + new BN(1 * 10 ** 9), + 1 + ); + // SOL at 80: passes MM. ETH at 600: fails MM + await setFeedPriceNoProgram(bankrunContextWrapper, 80, solUsd); + await setFeedPriceNoProgram(bankrunContextWrapper, 600, ethUsd); + await driftClient.fetchAccounts(); + + const restoreConsole = suppressConsole(); + try { + await driftClient.transferIsolatedPerpPositionDeposit( + new BN(100 * 10 ** 6), + 0, + undefined, + undefined, + undefined, + true + ); + assert(false, 'Transfer should fail - ETH isolated fails MM'); + } catch (e) { + if (e.message.includes('0x1773')) { + assert(true, 'Transfer correctly failed'); + } else { + throw e; + } + } finally { + restoreConsole(); + } + }); + }); +}); diff --git a/tests/orderMarginChecks.ts b/tests/orderMarginChecks.ts new file mode 100644 index 000000000..be519d83d --- /dev/null +++ b/tests/orderMarginChecks.ts @@ -0,0 +1,672 @@ +import * as anchor from '@coral-xyz/anchor'; +import { assert } from 'chai'; +import { + BN, + OracleSource, + ZERO, + MARGIN_PRECISION, + OrderType, + MarketType, + PositionDirection, + PEG_PRECISION, + SettlePnlMode, +} from '../sdk'; + +import { Program } from '@coral-xyz/anchor'; + +import { TestClient, EventSubscriber, getOrderParams } from '../sdk/src'; + +import { + mockUSDCMint, + mockUserUSDCAccount, + mockOracleNoProgram, + setFeedPriceNoProgram, + initializeQuoteSpotMarket, +} from './testHelpers'; +import { startAnchor } from 'solana-bankrun'; +import { TestBulkAccountLoader } from '../sdk/src/accounts/testBulkAccountLoader'; +import { BankrunContextWrapper } from '../sdk/src/bankrun/bankrunConnection'; + +describe('order margin checks with isolated positions', () => { + const chProgram = anchor.workspace.Drift as Program; + + let driftClient: TestClient; + let eventSubscriber: EventSubscriber; + + let bankrunContextWrapper: BankrunContextWrapper; + + let bulkAccountLoader: TestBulkAccountLoader; + + let usdcMint; + let userUSDCAccount; + + let solUsd; + let ethUsd; + + // ammInvariant == k == x * y + const mantissaSqrtScale = new BN(100000); + const ammInitialQuoteAssetAmount = new anchor.BN(5 * 10 ** 13).mul( + mantissaSqrtScale + ); + const ammInitialBaseAssetAmount = new anchor.BN(5 * 10 ** 13).mul( + mantissaSqrtScale + ); + + // Large amount of USDC for testing + const largeUsdcAmount = new BN(10000 * 10 ** 6); // 10,000 USDC + + // Helper to suppress console output during expected failures + const suppressConsole = () => { + const oldConsoleLog = console.log; + const oldConsoleError = console.error; + console.log = function () { + /* noop */ + }; + console.error = function () { + /* noop */ + }; + return () => { + console.log = oldConsoleLog; + console.error = oldConsoleError; + }; + }; + + before(async () => { + const context = await startAnchor('', [], []); + + bankrunContextWrapper = new BankrunContextWrapper(context); + + bulkAccountLoader = new TestBulkAccountLoader( + bankrunContextWrapper.connection, + 'processed', + 1 + ); + + usdcMint = await mockUSDCMint(bankrunContextWrapper); + userUSDCAccount = await mockUserUSDCAccount( + usdcMint, + largeUsdcAmount, + bankrunContextWrapper + ); + + // Create oracles for SOL and ETH + solUsd = await mockOracleNoProgram(bankrunContextWrapper, 100); // $100 per SOL + ethUsd = await mockOracleNoProgram(bankrunContextWrapper, 1000); // $1000 per ETH + + eventSubscriber = new EventSubscriber( + bankrunContextWrapper.connection.toConnection(), + chProgram + ); + + await eventSubscriber.subscribe(); + + driftClient = new TestClient({ + connection: bankrunContextWrapper.connection.toConnection(), + wallet: bankrunContextWrapper.provider.wallet, + programID: chProgram.programId, + opts: { + commitment: 'confirmed', + }, + activeSubAccountId: 0, + perpMarketIndexes: [0, 1], + spotMarketIndexes: [0], + subAccountIds: [], + oracleInfos: [ + { publicKey: solUsd, source: OracleSource.PYTH }, + { publicKey: ethUsd, source: OracleSource.PYTH }, + ], + userStats: true, + accountSubscription: { + type: 'polling', + accountLoader: bulkAccountLoader, + }, + }); + + await driftClient.initialize(usdcMint.publicKey, true); + + await driftClient.subscribe(); + await driftClient.updatePerpAuctionDuration(new BN(0)); + + await initializeQuoteSpotMarket(driftClient, usdcMint.publicKey); + + const periodicity = new BN(60 * 60); // 1 HOUR + + // Initialize SOL-PERP market (index 0) + await driftClient.initializePerpMarket( + 0, + solUsd, + ammInitialBaseAssetAmount, + ammInitialQuoteAssetAmount, + periodicity, + new BN(100 * PEG_PRECISION.toNumber()) + ); + + // Initialize ETH-PERP market (index 1) + await driftClient.initializePerpMarket( + 1, + ethUsd, + ammInitialBaseAssetAmount, + ammInitialQuoteAssetAmount, + periodicity, + new BN(1000 * PEG_PRECISION.toNumber()) + ); + + // Set step sizes + await driftClient.updatePerpMarketStepSizeAndTickSize( + 0, + new BN(1), + new BN(1) + ); + await driftClient.updatePerpMarketStepSizeAndTickSize( + 1, + new BN(1), + new BN(1) + ); + + // Set margin ratios: 50% initial, 33% maintenance + await driftClient.updatePerpMarketMarginRatio( + 0, + MARGIN_PRECISION.toNumber() / 2, // 50% IM + MARGIN_PRECISION.toNumber() / 3 // 33% MM + ); + await driftClient.updatePerpMarketMarginRatio( + 1, + MARGIN_PRECISION.toNumber() / 2, // 50% IM + MARGIN_PRECISION.toNumber() / 3 // 33% MM + ); + + // Initialize user account + await driftClient.initializeUserAccount(); + console.log('Initialized user account'); + }); + + after(async () => { + await driftClient.unsubscribe(); + await eventSubscriber.unsubscribe(); + }); + + // Reset user state between tests + async function resetUserState() { + // Restore oracle feeds to default prices so tests start with deterministic state + await setFeedPriceNoProgram(bankrunContextWrapper, 100, solUsd); + await setFeedPriceNoProgram(bankrunContextWrapper, 1000, ethUsd); + + await driftClient.fetchAccounts(); + + // Close any open positions + const user = driftClient.getUserAccount(); + for (const perpPosition of user.perpPositions) { + if (!perpPosition.baseAssetAmount.eq(ZERO)) { + try { + await driftClient.closePosition(perpPosition.marketIndex); + } catch (e) { + // Ignore errors when closing + } finally { + await driftClient.fetchAccounts(); + } + } + } + + // Settle PNL for all markets + try { + await driftClient.settleMultiplePNLs( + await driftClient.getUserAccountPublicKey(), + driftClient.getUserAccount(), + [0], + SettlePnlMode.TRY_SETTLE + ); + } catch (e) { + // Ignore + } + try { + await driftClient.settlePNL( + await driftClient.getUserAccountPublicKey(), + driftClient.getUserAccount(), + 1 + ); + } catch (e) { + // Ignore + } + + await driftClient.fetchAccounts(); + + // Transfer isolated collateral back to cross if any + for (const perpPosition of driftClient.getUserAccount().perpPositions) { + const isolatedBalance = driftClient.getIsolatedPerpPositionTokenAmount( + perpPosition.marketIndex + ); + if (isolatedBalance.gt(ZERO)) { + try { + await driftClient.transferIsolatedPerpPositionDeposit( + isolatedBalance.neg(), + perpPosition.marketIndex, + undefined, + undefined, + undefined, + true + ); + } catch (e) { + // Ignore + } + } + } + + // Withdraw all cross collateral + await driftClient.fetchAccounts(); + const crossBalance = driftClient.getQuoteAssetTokenAmount(); + if (crossBalance.gt(ZERO)) { + try { + await driftClient.withdraw(crossBalance, 0, userUSDCAccount.publicKey); + } catch (e) { + // Ignore + } + } + + await driftClient.fetchAccounts(); + } + + describe('Scenario 1: Cross below IM -> cannot open isolated position', () => { + it('should fail to open isolated position when cross account fails initial margin', async () => { + await resetUserState(); + + // With 50% IM, 33% MM: + // 10 SOL @ $100 = $1000 notional -> $500 IM required, $333 MM required + // We want cross to fail IM but pass MM + + // Deposit enough to open position first + console.log( + '[LOGGING] depositing into cross', + new BN(600 * 10 ** 6).toString() + ); + await driftClient.deposit( + new BN(600 * 10 ** 6), // $600 + 0, + userUSDCAccount.publicKey + ); + + // Open cross position: 10 SOL + const baseAssetAmount = new BN(10 * 10 ** 9); // 10 SOL + console.log( + '[LOGGING] opening cross position', + baseAssetAmount.toString() + ); + await driftClient.openPosition( + PositionDirection.LONG, + baseAssetAmount, + 0 // SOL-PERP market + ); + + // Lower SOL oracle so user has unrealized losses -> cross below IM but above MM + // (Withdraw would be rejected by program; cannot withdraw below IM.) + // 10 SOL long @ $100 -> drop to $79: loss = $210, effective collateral ~$390, IM required $395, MM ~$261 + await setFeedPriceNoProgram(bankrunContextWrapper, 79, solUsd); + await driftClient.fetchAccounts(); + + // Now try to open isolated ETH-PERP position + // First deposit into isolated + const isolatedDeposit = new BN(600 * 10 ** 6); // $600 - enough for 1 ETH ($500 IM) + console.log( + '[LOGGING] depositing into cross', + isolatedDeposit.toString() + ); + await driftClient.deposit(isolatedDeposit, 0, userUSDCAccount.publicKey); + console.log('[LOGGING] deposited into cross', isolatedDeposit.toString()); + await driftClient.depositIntoIsolatedPerpPosition( + isolatedDeposit, + 1, // ETH-PERP + userUSDCAccount.publicKey + ); + + // Try to place an order on isolated ETH-PERP - should fail because cross fails IM + const restoreConsole = suppressConsole(); + try { + console.log( + '[LOGGING] placing order on isolated ETH-PERP', + new BN(1 * 10 ** 9).toString() + ); + await driftClient.placePerpOrder( + getOrderParams({ + orderType: OrderType.MARKET, + marketType: MarketType.PERP, + marketIndex: 1, + direction: PositionDirection.LONG, + baseAssetAmount: new BN(1 * 10 ** 9), // 1 ETH + }) + ); + assert(false, 'Order should have failed - cross is below IM'); + } catch (e) { + assert(true, 'Order correctly failed'); + } finally { + restoreConsole(); + } + }); + }); + + describe('Scenario 2a: Isolated below IM + increase same market -> FAILS if cross cannot provide shortfall', () => { + it('should fail to increase isolated position when shortfall deposit would make cross fail IM', async () => { + await resetUserState(); + + // With 50% IM, 33% MM: + // 10 SOL @ $100 = $1000 notional -> $500 IM required + // 1 ETH @ $1000 = $1000 notional -> $500 IM required + // 1.5 ETH @ $1000 = $1500 notional -> $750 IM required + // + // Setup: + // Cross: $550 collateral, 10 SOL cross position ($500 IM required) -> passes with $50 buffer + // Isolated: 1 ETH with $550 collateral, want to increase by 0.5 ETH + // After increase: 1.5 ETH = $750 IM required, but only $550 isolated collateral + // Shortfall: $200. If cross provides $200, cross has $350 vs $500 IM -> fails + + // Deposit initial cross collateral + await driftClient.deposit( + new BN(700 * 10 ** 6), // $700 + 0, + userUSDCAccount.publicKey + ); + + // Open cross SOL position + await driftClient.openPosition( + PositionDirection.LONG, + new BN(10 * 10 ** 9), // 10 SOL + 0 + ); + + // Lower SOL oracle so cross has effective $550 (loss $150: 10*(100-85)=150) + await setFeedPriceNoProgram(bankrunContextWrapper, 85, solUsd); + await driftClient.fetchAccounts(); + + // Deposit and setup isolated ETH position + await driftClient.deposit( + new BN(550 * 10 ** 6), // $550 + 0, + userUSDCAccount.publicKey + ); + await driftClient.depositIntoIsolatedPerpPosition( + new BN(550 * 10 ** 6), + 1, + userUSDCAccount.publicKey + ); + + // Open initial isolated ETH position + await driftClient.openPosition( + PositionDirection.LONG, + new BN(1 * 10 ** 9), // 1 ETH + 1 + ); + + // Cross already at effective $550 from oracle move above + await driftClient.fetchAccounts(); + + // Now try to increase isolated position by 0.5 ETH + // This would require $750 IM total, but only have $550 isolated + // Shortfall of $200 from cross would make cross fail ($550 - $200 = $350 < $500 IM) + const restoreConsole = suppressConsole(); + try { + await driftClient.placePerpOrder( + getOrderParams({ + orderType: OrderType.MARKET, + marketType: MarketType.PERP, + marketIndex: 1, + direction: PositionDirection.LONG, + baseAssetAmount: new BN(0.5 * 10 ** 9), // 0.5 ETH + }) + ); + assert( + false, + 'Order should have failed - deposit would make cross fail IM' + ); + } catch (e) { + assert(true, 'Order correctly failed'); + } finally { + restoreConsole(); + } + }); + }); + + describe('Scenario 2b: Isolated below IM + increase same market -> PASSES if cross can provide shortfall', () => { + it('should pass when cross can provide shortfall while still meeting IM', async () => { + await resetUserState(); + + // With 50% IM, 33% MM: + // 10 SOL @ $100 = $1000 notional -> $500 IM required + // 1 ETH @ $1000 = $1000 notional -> $500 IM required + // 1.5 ETH @ $1000 = $1500 notional -> $750 IM required + // + // Setup: + // Cross: $800 collateral, 10 SOL cross position ($500 IM required) -> passes with $300 buffer + // Isolated: 1 ETH with $550 collateral, want to increase by 0.5 ETH + // After increase: 1.5 ETH = $750 IM required, but only $550 isolated collateral + // Shortfall: $202. Cross provides $202, cross has $598 vs $500 IM -> passes + + // Deposit initial cross collateral + await driftClient.deposit( + new BN(900 * 10 ** 6), // $900 + 0, + userUSDCAccount.publicKey + ); + + // Open cross SOL position + await driftClient.openPosition( + PositionDirection.LONG, + new BN(10 * 10 ** 9), // 10 SOL + 0 + ); + + // Lower SOL oracle so cross has effective $800 (loss $100: 10*(100-90)=100) + await setFeedPriceNoProgram(bankrunContextWrapper, 90, solUsd); + await driftClient.fetchAccounts(); + + // Deposit and setup isolated ETH position + await driftClient.deposit( + new BN(550 * 10 ** 6), // $550 + 0, + userUSDCAccount.publicKey + ); + await driftClient.depositIntoIsolatedPerpPosition( + new BN(550 * 10 ** 6), + 1, + userUSDCAccount.publicKey + ); + + // Open initial isolated ETH position + await driftClient.openPosition( + PositionDirection.LONG, + new BN(1 * 10 ** 9), // 1 ETH + 1 + ); + + // Cross already at effective $800 from oracle move above + await driftClient.fetchAccounts(); + + // $202 from cross leaves cross with $598 > $500 IM -> passes (202 because of rounding) + await driftClient.depositIntoIsolatedPerpPosition( + new BN(202 * 10 ** 6), + 1, + userUSDCAccount.publicKey + ); + + // Now increase isolated position by 0.5 ETH - should pass with $750 collateral on IM + const txSig = await driftClient.placePerpOrder( + getOrderParams({ + orderType: OrderType.MARKET, + marketType: MarketType.PERP, + marketIndex: 1, + direction: PositionDirection.LONG, + baseAssetAmount: new BN(0.5 * 10 ** 9), // 0.5 ETH + }) + ); + + assert(txSig, 'Order should have passed - cross can provide shortfall'); + }); + }); + + describe('Scenario 3a: Isolated below IM + open different market -> PASSES if all isolated pass MM', () => { + it('should pass opening new isolated when other isolated fails IM but passes MM', async () => { + await resetUserState(); + + // With 50% IM, 33% MM: + // 10 SOL @ $100 = $1000 notional -> $500 IM required, $333 MM required + // 1 ETH @ $1000 = $1000 notional -> $500 IM required, $333 MM required + // + // Setup: + // Cross: $2000 USDC collateral + // Isolated SOL-PERP: 10 SOL with $400 collateral + // - IM required: $500 (FAILS) + // - MM required: $333 (PASSES - $400 > $333) + // Try to open new isolated ETH-PERP - should pass because SOL-PERP passes MM + + // Deposit cross collateral + await driftClient.deposit( + new BN(2000 * 10 ** 6), // $2000 + 0, + userUSDCAccount.publicKey + ); + + // Setup isolated SOL position with enough to open + await driftClient.depositIntoIsolatedPerpPosition( + new BN(600 * 10 ** 6), // $600 - enough to open ($500 IM) + 0, + userUSDCAccount.publicKey + ); + + // Open SOL position + await driftClient.openPosition( + PositionDirection.LONG, + new BN(10 * 10 ** 9), // 10 SOL + 0 + ); + + // Lower SOL oracle so isolated SOL has effective $400 (loss $200: 10*(100-80)=200), fails IM but passes MM + await setFeedPriceNoProgram(bankrunContextWrapper, 80, solUsd); + await driftClient.fetchAccounts(); + + // Now setup and open isolated ETH position + await driftClient.depositIntoIsolatedPerpPosition( + new BN(600 * 10 ** 6), // $600 - enough for $500 IM + 1, + userUSDCAccount.publicKey + ); + + // Open ETH position - should pass because SOL position passes MM + const txSig = await driftClient.openPosition( + PositionDirection.LONG, + new BN(1 * 10 ** 9), // 1 ETH + 1 + ); + + assert( + txSig, + 'Order should pass when other isolated fails IM but passes MM' + ); + }); + }); + + describe('Scenario 3b: Isolated below IM + open different market -> FAILS if any isolated fails MM', () => { + it('should fail opening new isolated when other isolated fails MM', async () => { + await resetUserState(); + + // With 50% IM, 33% MM: + // 10 SOL @ $100 = $1000 notional -> $500 IM required, $333 MM required + // 1 ETH @ $1000 = $1000 notional -> $500 IM required, $333 MM required + // + // Setup: + // Cross: $2000 USDC collateral + // Isolated SOL-PERP: 10 SOL with $300 collateral + // - MM required: $333 (FAILS - $300 < $333) + // Try to open new isolated ETH-PERP - should fail because SOL-PERP fails MM + + // Deposit cross collateral + await driftClient.deposit( + new BN(2000 * 10 ** 6), // $2000 + 0, + userUSDCAccount.publicKey + ); + + // Setup isolated SOL position with enough to open + await driftClient.depositIntoIsolatedPerpPosition( + new BN(600 * 10 ** 6), // $600 - enough to open ($500 IM) + 0, + userUSDCAccount.publicKey + ); + + // Open SOL position + await driftClient.openPosition( + PositionDirection.LONG, + new BN(10 * 10 ** 9), // 10 SOL + 0 + ); + + // Lower SOL oracle so isolated SOL has effective $300 (loss $300: 10*(100-70)=300), below MM $333 + await setFeedPriceNoProgram(bankrunContextWrapper, 70, solUsd); + await driftClient.fetchAccounts(); + + // Setup isolated ETH collateral + await driftClient.depositIntoIsolatedPerpPosition( + new BN(600 * 10 ** 6), // $600 - enough for $500 IM + 1, + userUSDCAccount.publicKey + ); + + // Try to open ETH position - should fail because SOL position fails MM + const restoreConsole = suppressConsole(); + try { + await driftClient.openPosition( + PositionDirection.LONG, + new BN(1 * 10 ** 9), // 1 ETH + 1 + ); + assert(false, 'Order should have failed - other isolated fails MM'); + } catch (e) { + assert(true, 'Order correctly failed'); + } finally { + restoreConsole(); + } + }); + }); + + describe('Scenario 4: Cross has plenty of USDC -> no issues opening isolated', () => { + it('should pass opening isolated when cross has plenty of collateral', async () => { + await resetUserState(); + + // With 50% IM, 33% MM: + // 1 ETH @ $1000 = $1000 notional -> $500 IM required, $333 MM required + // + // Setup: + // Cross: $2000 USDC collateral, no positions + // Open new isolated ETH-PERP with $600 collateral ($500 IM required) + // Should easily pass + + // Deposit cross collateral + await driftClient.deposit( + new BN(2000 * 10 ** 6), // $2000 + 0, + userUSDCAccount.publicKey + ); + + // Setup isolated ETH collateral + await driftClient.depositIntoIsolatedPerpPosition( + new BN(600 * 10 ** 6), // $600 - enough for $500 IM + 1, + userUSDCAccount.publicKey + ); + + // Open ETH position - should pass easily + const txSig = await driftClient.openPosition( + PositionDirection.LONG, + new BN(1 * 10 ** 9), // 1 ETH + 1 + ); + + assert(txSig, 'Order should pass when cross has plenty of collateral'); + + // Verify position was opened + await driftClient.fetchAccounts(); + const user = driftClient.getUserAccount(); + const ethPosition = user.perpPositions.find((p) => p.marketIndex === 1); + assert( + ethPosition && !ethPosition.baseAssetAmount.eq(ZERO), + 'ETH position should be open' + ); + }); + }); +});