diff --git a/Cargo.lock b/Cargo.lock index 28c466f..ac46993 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1414,7 +1414,7 @@ checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" [[package]] name = "drift-idl-gen" version = "0.2.0" -source = "git+https://github.com/drift-labs/drift-rs?rev=5590796#5590796d50b6049e74435b2497fd3312a9983f5a" +source = "git+https://github.com/drift-labs/drift-rs?rev=17f57b7#17f57b718d34cc870d250123d09ea02ace1a257f" dependencies = [ "proc-macro2", "quote", @@ -1427,7 +1427,7 @@ dependencies = [ [[package]] name = "drift-pubsub-client" version = "0.1.1" -source = "git+https://github.com/drift-labs/drift-rs?rev=5590796#5590796d50b6049e74435b2497fd3312a9983f5a" +source = "git+https://github.com/drift-labs/drift-rs?rev=17f57b7#17f57b718d34cc870d250123d09ea02ace1a257f" dependencies = [ "futures-util", "gjson", @@ -1447,7 +1447,7 @@ dependencies = [ [[package]] name = "drift-rs" version = "1.0.0-alpha.16" -source = "git+https://github.com/drift-labs/drift-rs?rev=5590796#5590796d50b6049e74435b2497fd3312a9983f5a" +source = "git+https://github.com/drift-labs/drift-rs?rev=17f57b7#17f57b718d34cc870d250123d09ea02ace1a257f" dependencies = [ "abi_stable", "ahash 0.8.12", diff --git a/Cargo.toml b/Cargo.toml index f0313d0..0cce584 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ axum = "0.8" clap = { version = "4.5.40", "features" = ["env", "derive"] } crossbeam = { version = "0.8.4", features = ["crossbeam-channel"] } dotenv = "0.15.0" -drift-rs = { git = "https://github.com/drift-labs/drift-rs", rev = "5590796", features = ["unsafe_pub", "titan"] } +drift-rs = { git = "https://github.com/drift-labs/drift-rs", rev = "17f57b7", features = ["unsafe_pub", "titan"] } env_logger = "0.11" # DLOB 'trace' logs are expensive so compile them out log = { version = "0.4", features = ["max_level_trace"] } diff --git a/src/liquidator.rs b/src/liquidator.rs index d06a005..dfb1864 100644 --- a/src/liquidator.rs +++ b/src/liquidator.rs @@ -11,7 +11,7 @@ use futures_util::FutureExt; use std::{ collections::{BTreeMap, HashMap, HashSet}, sync::{Arc, RwLock}, - time::{SystemTime, UNIX_EPOCH}, + time::{Duration, SystemTime, UNIX_EPOCH}, }; use tokio::sync::mpsc::error::TryRecvError; @@ -21,13 +21,21 @@ use drift_rs::{ grpc::{grpc_subscriber::AccountFilter, TransactionUpdate}, jupiter::SwapMode, market_state::MarketStateData, + math::{ + constants::{ + BASE_PRECISION, BASE_PRECISION_U64, MARGIN_PRECISION_U128, PRICE_PRECISION, + QUOTE_PRECISION, SPOT_WEIGHT_PRECISION, SPOT_WEIGHT_PRECISION_U128, + }, + liquidation::{calculate_collateral, CollateralInfo}, + }, priority_fee_subscriber::PriorityFeeSubscriber, titan, types::{ accounts::{PerpMarket, SpotMarket, User}, - MarginRequirementType, MarketId, MarketStatus, MarketType, OracleSource, SpotBalanceType, + MarginRequirementType, MarketId, MarketStatus, MarketType, OracleSource, OrderParams, + OrderType, PerpPosition, PositionDirection, SpotBalanceType, SpotPosition, }, - DriftClient, GrpcSubscribeOpts, MarketState, Pubkey, TransactionBuilder, + DriftClient, GrpcSubscribeOpts, MarketState, Pubkey, TransactionBuilder, Wallet, }; use drift_rs::{jupiter::JupiterSwapApi, titan::TitanSwapApi}; use solana_sdk::{account::Account, clock::Slot, compute_budget::ComputeBudgetInstruction}; @@ -350,6 +358,7 @@ pub trait LiquidationStrategy { cu_limit: u32, slot: u64, pyth_price_update: Option, + collateral_info_per_subaccount: Arc>>, status: UserMarginStatus, ) -> futures_util::future::BoxFuture<'a, ()>; } @@ -395,6 +404,8 @@ pub struct LiquidatorBot { pyth_price_feed: Option>, /// Dashboard state for HTTP API dashboard_state: DashboardStateRef, + /// Track collateral info per subaccount + collateral_info_per_subaccount: Arc>>, } impl LiquidatorBot { @@ -506,6 +517,15 @@ impl LiquidatorBot { log::info!(target: TARGET, "subscribed pyth price feeds"); + let collateral_info_per_subaccount = Arc::new(RwLock::new( + get_collateral_info_per_subaccount(&drift, &config.get_subaccounts()).await, + )); + + let cu_limit = std::env::var("FILL_CU_LIMIT") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(config.fill_cu_limit); + // start liquidation worker let (liq_tx, liq_rx) = tokio::sync::mpsc::channel::<( Pubkey, @@ -518,7 +538,7 @@ impl LiquidatorBot { spawn_liquidation_worker( tx_sender.clone(), // TODO: apply your own liquidation strategy here - Arc::new(LiquidateWithMatchStrategy { + Arc::new(PrimaryLiquidationStrategy { dlob, drift: drift.clone(), market_state: Arc::clone(&market_state), @@ -527,14 +547,26 @@ impl LiquidatorBot { use_spot_liquidation: config.use_spot_liquidation, }), liq_rx, - std::env::var("FILL_CU_LIMIT") - .ok() - .and_then(|s| s.parse::().ok()) - .unwrap_or(config.fill_cu_limit), + cu_limit, Arc::clone(&priority_fee_subscriber), + Arc::clone(&collateral_info_per_subaccount), ); log::info!(target: TARGET, "spawned liquidation worker"); + // Spawn derisk loop + let subaccounts: Vec = config + .get_subaccounts() + .iter() + .map(|id| Wallet::derive_user_account(&drift.wallet.authority(), *id)) + .collect(); + + spawn_derisk_loop( + drift.clone(), + tx_sender.clone(), + subaccounts, + Arc::clone(&priority_fee_subscriber), + cu_limit, + ); LiquidatorBot { drift, @@ -545,6 +577,7 @@ impl LiquidatorBot { liq_tx, pyth_price_feed: Some(pyth_price_feed), dashboard_state, + collateral_info_per_subaccount, } } @@ -685,6 +718,12 @@ impl LiquidatorBot { user, slot: update_slot, } => { + // Update collaterals + let new_collateral = + get_collateral_info_per_subaccount(&drift, &config.get_subaccounts()) + .await; + *self.collateral_info_per_subaccount.write().unwrap() = new_collateral; + let old_user = users.get(&pubkey).map(|m| &m.user); dlob_notifier.user_update(pubkey, old_user, &user, update_slot); let now_ms = current_time_millis(); @@ -1135,6 +1174,157 @@ impl LiquidatorBot { } } +fn derisk_subaccount( + drift: &DriftClient, + tx_sender: &TxSender, + subaccount: Pubkey, + priority_fee: u64, + cu_limit: u32, +) { + let user = match drift.try_get_account::(&subaccount) { + Ok(u) => u, + Err(_) => return, + }; + + for position in user.perp_positions.iter() { + if position.base_asset_amount == 0 && position.quote_asset_amount == 0 { + continue; + } + + let mut tx_builder = TransactionBuilder::new( + drift.program_data(), + subaccount, + std::borrow::Cow::Owned(user.clone()), + false, + ) + .with_priority_fee(priority_fee, Some(cu_limit)); + + if position.base_asset_amount != 0 { + let direction = if position.base_asset_amount > 0 { + PositionDirection::Short + } else { + PositionDirection::Long + }; + + tx_builder = tx_builder.place_orders(vec![OrderParams { + order_type: OrderType::Market, + market_type: MarketType::Perp, + direction, + base_asset_amount: position.base_asset_amount.unsigned_abs(), + market_index: position.market_index, + reduce_only: true, + ..Default::default() + }]); + } else { + tx_builder = tx_builder.settle_pnl(position.market_index, None, None); + } + + tx_sender.send_tx( + tx_builder.build(), + TxIntent::Derisk { + market_index: position.market_index, + subaccount, + }, + cu_limit as u64, + ); + } +} + +fn spawn_derisk_loop( + drift: DriftClient, + tx_sender: TxSender, + subaccounts: Vec, + priority_fee_subscriber: Arc, + cu_limit: u32, +) { + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(5)); + loop { + interval.tick().await; + let priority_fee = priority_fee_subscriber.priority_fee_nth(0.6); + for subaccount in &subaccounts { + derisk_subaccount(&drift, &tx_sender, *subaccount, priority_fee, cu_limit); + } + } + }); +} + +async fn get_collateral_info_per_subaccount( + drift: &DriftClient, + subaccounts: &[u16], +) -> HashMap { + let mut collateral_info_per_subaccount = HashMap::::new(); + for subaccount in subaccounts { + let subaccount_pubkey = Wallet::derive_user_account(&drift.wallet.authority(), *subaccount); + match drift.get_user_account(&subaccount_pubkey).await { + Ok(user_account) => { + match calculate_collateral( + &drift, + &user_account, + MarginRequirementType::Maintenance, + ) { + Ok(collateral_info) => { + collateral_info_per_subaccount.insert(subaccount_pubkey, collateral_info); + log::info!( + target: TARGET, + "Subaccount {}: free_collateral = {}, total_collateral = {}", + subaccount, + collateral_info.free, + collateral_info.total + ); + } + Err(e) => { + log::warn!( + target: TARGET, + "Failed to calculate collateral for subaccount {}: {:?}", + subaccount, + e + ); + } + } + } + Err(e) => { + log::warn!( + target: TARGET, + "Failed to load subaccount {}: {:?}", + subaccount, + e + ); + } + } + } + collateral_info_per_subaccount +} + +fn calculate_perp_collateral_requirement( + market_state: Arc>, + market_index: u16, + base_asset_amount: i64, +) -> Option { + let state = market_state.read().unwrap().load(); + + let perp_market = state.perp_markets.get(&market_index)?; + let oracle = state.perp_oracle_prices.get(&market_index)?; + + let margin_ratio = perp_market + .get_margin_ratio( + base_asset_amount.unsigned_abs() as u128, + MarginRequirementType::Initial, + false, + ) + .ok()?; + + let collateral = (base_asset_amount.abs() as u128) + .saturating_mul(oracle.price as u128) + .saturating_mul(QUOTE_PRECISION) + .saturating_mul(margin_ratio as u128) + .saturating_div(MARGIN_PRECISION_U128) + .saturating_div(PRICE_PRECISION) + .saturating_div(BASE_PRECISION); + + Some(collateral) +} + fn on_transaction_update_fn( tx_sender: TxSender, ) -> impl Fn(&TransactionUpdate) + Send + Sync + 'static { @@ -1359,6 +1549,196 @@ fn try_liquidate_with_match( ); } +/// Try to liquidate by taking over position +fn try_liquidate_with_collateral( + drift: &DriftClient, + market_index: u16, + keeper_subaccount: Pubkey, + liquidatee_subaccount: Pubkey, + base_asset_amount: u64, + tx_sender: TxSender, + priority_fee: u64, + cu_limit: u32, + slot: u64, + pyth_price_update: Option, +) { + let keeper_account_data = drift.try_get_account::(&keeper_subaccount); + if keeper_account_data.is_err() { + log::debug!(target: TARGET, "keeper acc lookup failed={keeper_subaccount:?}"); + return; + } + let liquidatee_subaccount_data = drift.try_get_account::(&liquidatee_subaccount); + if liquidatee_subaccount_data.is_err() { + log::debug!(target: TARGET, "liquidatee acc lookup failed={liquidatee_subaccount:?}"); + return; + } + + let mut tx_builder = TransactionBuilder::new( + drift.program_data(), + keeper_subaccount, + std::borrow::Cow::Owned(keeper_account_data.unwrap()), + false, + ) + .with_priority_fee(priority_fee, Some(cu_limit)); + + if let Some(ref update) = pyth_price_update { + tx_builder = tx_builder.post_pyth_lazer_oracle_update(&[update.feed_id], &update.message); + } + + tx_builder = tx_builder.liquidate_perp( + market_index, + &liquidatee_subaccount_data.unwrap(), + base_asset_amount, + None, + ); + + // large accounts list, bump CU limit to compensate + if let Some(ix) = tx_builder.ixs().last() { + if ix.accounts.len() >= 20 { + tx_builder = tx_builder.set_ix( + 1, + ComputeBudgetInstruction::set_compute_unit_limit(cu_limit * 2), + ); + } + } + + let tx = tx_builder.build(); + + tx_sender.send_tx( + tx, + TxIntent::LiquidatePerp { + market_index, + liquidatee: liquidatee_subaccount, + slot, + }, + cu_limit as u64, + ); +} + +fn try_liquidate_perp_pnl_for_deposit( + drift: &DriftClient, + keeper_subaccount: Pubkey, + liquidatee: Pubkey, + liability: &PositionInfo, + asset: &PositionInfo, + tx_sender: TxSender, + priority_fee: u64, + cu_limit: u32, + slot: u64, + pyth_price_update: Option, +) { + let keeper_account = match drift.try_get_account::(&keeper_subaccount) { + Ok(data) => data, + Err(_) => { + log::warn!(target: TARGET, "keeper account not found"); + return; + } + }; + + let liquidatee_account = match drift.try_get_account::(&liquidatee) { + Ok(data) => data, + Err(_) => { + log::warn!(target: TARGET, "liquidatee account not found"); + return; + } + }; + + let mut tx_builder = TransactionBuilder::new( + drift.program_data(), + keeper_subaccount, + std::borrow::Cow::Owned(keeper_account), + false, + ) + .with_priority_fee(priority_fee, Some(cu_limit)); + + if let Some(ref update) = pyth_price_update { + tx_builder = tx_builder.post_pyth_lazer_oracle_update(&[update.feed_id], &update.message); + } + + tx_builder = tx_builder.liquidate_perp_pnl_for_deposit( + &liquidatee_account, + liability.market_index, + asset.market_index, + drift_rs::types::u128::from(liability.collateral_required.unsigned_abs()), + None, + ); + + let tx = tx_builder.build(); + + tx_sender.send_tx( + tx, + TxIntent::LiquidatePerpPnlForDeposit { + perp_market_index: liability.market_index, + spot_market_index: asset.market_index, + liquidatee, + slot, + }, + cu_limit as u64, + ); +} + +fn try_liquidate_borrow_for_perp_pnl( + drift: &DriftClient, + keeper_subaccount: Pubkey, + liquidatee: Pubkey, + liability: &PositionInfo, + asset: &PositionInfo, + tx_sender: TxSender, + priority_fee: u64, + cu_limit: u32, + slot: u64, + pyth_price_update: Option, +) { + let keeper_account = match drift.try_get_account::(&keeper_subaccount) { + Ok(data) => data, + Err(_) => { + log::warn!(target: TARGET, "keeper account not found"); + return; + } + }; + + let liquidatee_account = match drift.try_get_account::(&liquidatee) { + Ok(data) => data, + Err(_) => { + log::warn!(target: TARGET, "liquidatee account not found"); + return; + } + }; + + let mut tx_builder = TransactionBuilder::new( + drift.program_data(), + keeper_subaccount, + std::borrow::Cow::Owned(keeper_account), + false, + ) + .with_priority_fee(priority_fee, Some(cu_limit)); + + if let Some(ref update) = pyth_price_update { + tx_builder = tx_builder.post_pyth_lazer_oracle_update(&[update.feed_id], &update.message); + } + + tx_builder = tx_builder.liquidate_borrow_for_perp_pnl( + &liquidatee_account, + asset.market_index, + liability.market_index, + drift_rs::types::u128::from(liability.base_amount.unsigned_abs() as u128), + None, + ); + + let tx = tx_builder.build(); + + tx_sender.send_tx( + tx, + TxIntent::LiquidateBorrowForPerpPnl { + perp_market_index: asset.market_index, + spot_market_index: liability.market_index, + liquidatee, + slot, + }, + cu_limit as u64, + ); +} + fn spawn_liquidation_worker( tx_sender: TxSender, strategy: Arc, @@ -1372,6 +1752,7 @@ fn spawn_liquidation_worker( )>, cu_limit: u32, priority_fee_subscriber: Arc, + collateral_info_per_subaccount: Arc>>, ) { let mut rate_limit = HashMap::::new(); @@ -1406,6 +1787,7 @@ fn spawn_liquidation_worker( let liquidatee_clone = liquidatee; let user_account_clone = Arc::new(user_account); let tx_sender_clone = tx_sender.clone(); + let collateral_info_clone = Arc::clone(&collateral_info_per_subaccount); tokio::spawn(async move { let deadline = std::time::Duration::from_millis(LIQUIDATION_DEADLINE_MS); @@ -1420,6 +1802,7 @@ fn spawn_liquidation_worker( cu_limit, slot, pyth_price_update, + collateral_info_clone, status, ), ) @@ -1460,9 +1843,27 @@ fn spawn_liquidation_worker( }); } -/// Default liquidation strategy that matches against top-of-book makers and -/// submits liquidate_with_fill -pub struct LiquidateWithMatchStrategy { +enum LiquidationType { + PerpWithFill, + PerpTakeover, + PerpPnlForDeposit, + BorrowForPerpPnl, + SpotForSpot, + Skip, +} + +#[derive(Debug, Clone)] +struct PositionInfo { + market_type: MarketType, + market_index: u16, + is_asset: bool, + collateral_required: i128, + base_amount: i64, + quote_amount: i64, +} + +/// Primary liquidation strategy: Prefers takeover for large positions, uses maker matching for rest +pub struct PrimaryLiquidationStrategy { pub drift: DriftClient, pub dlob: &'static DLOB, pub market_state: Arc>, @@ -1471,7 +1872,166 @@ pub struct LiquidateWithMatchStrategy { pub use_spot_liquidation: bool, } -impl LiquidateWithMatchStrategy { +impl PrimaryLiquidationStrategy { + /// Liquidation policy: Prefer takeover for large positions (≥$10k), use makers for small positions, + /// fallback to takeover when no makers available. Skip if no makers and insufficient collateral. + fn decide_perp_method( + base_asset_amount_to_liquidate: u64, + collateral_available: i128, + collateral_required: u128, + has_makers: bool, + ) -> LiquidationType { + const LARGE_POSITION_THRESHOLD: u64 = 10_000 * BASE_PRECISION_U64; + + let is_large_position = base_asset_amount_to_liquidate >= LARGE_POSITION_THRESHOLD; + let can_afford_takeover = collateral_available >= collateral_required as i128; + + if is_large_position && can_afford_takeover { + LiquidationType::PerpTakeover + } else if has_makers { + LiquidationType::PerpWithFill + } else if can_afford_takeover { + LiquidationType::PerpTakeover + } else { + LiquidationType::Skip + } + } + + fn decide_liquidation_type( + liability: &PositionInfo, + asset: Option<&PositionInfo>, + ) -> LiquidationType { + match (liability.market_type, asset.map(|a| a.market_type)) { + (MarketType::Perp, None) => LiquidationType::PerpTakeover, + (MarketType::Perp, Some(MarketType::Spot)) => LiquidationType::PerpPnlForDeposit, + (MarketType::Spot, Some(MarketType::Perp)) => LiquidationType::BorrowForPerpPnl, + (MarketType::Spot, Some(MarketType::Spot)) => LiquidationType::SpotForSpot, + _ => LiquidationType::Skip, + } + } + + /// Calculate collateral requirement for all perp positions + fn get_perp_positions_info( + market_state: Arc>, + perp_positions: &[PerpPosition], + ) -> Vec { + let state = market_state.read().unwrap().load(); + + perp_positions + .iter() + .filter(|p| p.base_asset_amount != 0 || p.quote_asset_amount != 0) + .filter_map(|pos| { + let perp_market = state.perp_markets.get(&pos.market_index)?; + let oracle = state.perp_oracle_prices.get(&pos.market_index)?; + + let margin_ratio = perp_market + .get_margin_ratio( + pos.base_asset_amount.unsigned_abs() as u128, + MarginRequirementType::Initial, + false, + ) + .ok()?; + + let collateral = (pos.base_asset_amount.abs() as u128) + .saturating_mul(oracle.price as u128) + .saturating_mul(QUOTE_PRECISION) + .saturating_mul(margin_ratio as u128) + .saturating_div(MARGIN_PRECISION_U128) + .saturating_div(PRICE_PRECISION) + .saturating_div(BASE_PRECISION); + + Some(PositionInfo { + market_type: MarketType::Perp, + market_index: pos.market_index, + is_asset: pos.quote_asset_amount > 0, + collateral_required: collateral as i128, + base_amount: pos.base_asset_amount, + quote_amount: pos.quote_asset_amount, + }) + }) + .collect() + } + + /// Calculate collateral required for all spot positions + fn get_spot_positions_info( + market_state: Arc>, + spot_positions: &[SpotPosition], + ) -> Vec { + let state = market_state.read().unwrap().load(); + + spot_positions + .iter() + .filter(|p| !p.is_available()) + .filter_map(|pos| { + let spot_market = state.spot_markets.get(&pos.market_index)?; + let oracle = state.spot_oracle_prices.get(&pos.market_index)?; + + let token_amount = pos.get_signed_token_amount(&spot_market).ok()?; + + let token_precision = 10_u128.pow(spot_market.decimals); + let weight = if pos.balance_type == SpotBalanceType::Deposit { + spot_market.initial_asset_weight + } else { + spot_market.initial_liability_weight + }; + + let collateral_impact = (token_amount.abs() as u128) + .saturating_mul(oracle.price as u128) + .saturating_div(PRICE_PRECISION) + .saturating_mul(QUOTE_PRECISION) + .saturating_div(token_precision) + .saturating_mul(weight as u128) + .saturating_div(SPOT_WEIGHT_PRECISION_U128); + + Some(PositionInfo { + market_type: MarketType::Spot, + market_index: pos.market_index, + is_asset: pos.balance_type == SpotBalanceType::Deposit, + collateral_required: collateral_impact as i128, + base_amount: token_amount as i64, + quote_amount: 0, + }) + }) + .collect() + } + + /// Pick the largest liability and best matching asset + fn pick_best_asset_liability_combo( + perp_positions: &[PositionInfo], + spot_positions: &[PositionInfo], + ) -> Option<(PositionInfo, Option)> { + let mut liabilities: Vec = perp_positions + .iter() + .chain(spot_positions) + .filter(|p| !p.is_asset) + .cloned() + .collect(); + + let mut assets: Vec = perp_positions + .iter() + .chain(spot_positions) + .filter(|p| p.is_asset) + .cloned() + .collect(); + + liabilities.sort_by(|a, b| { + b.collateral_required + .abs() + .cmp(&a.collateral_required.abs()) + }); + + assets.sort_by(|a, b| { + b.collateral_required + .abs() + .cmp(&a.collateral_required.abs()) + }); + + let largest_liability = liabilities.first()?.clone(); + let best_asset = assets.first().cloned(); + + Some((largest_liability, best_asset)) + } + /// Find top makers for a perp position fn find_top_makers( drift: &DriftClient, @@ -1525,7 +2085,7 @@ impl LiquidateWithMatchStrategy { Some(makers) } - /// Attempt perp liquidation with order matching + /// Attempt perp liquidation with order matching or collateral fn liquidate_perp( drift: &DriftClient, dlob: &'static DLOB, @@ -1539,6 +2099,7 @@ impl LiquidateWithMatchStrategy { cu_limit: u32, slot: u64, pyth_price_update: Option, + collateral_info_per_subaccount: Arc>>, status: &UserMarginStatus, ) { // Check isolated liquidations first @@ -1613,21 +2174,39 @@ impl LiquidateWithMatchStrategy { return; }; - metrics - .liquidation_attempts - .with_label_values(&["perp"]) - .inc(); - - let Some(makers) = Self::find_top_makers( - drift, - dlob, + // Calculate collateral required + let Some(collateral_required) = calculate_perp_collateral_requirement( Arc::clone(&market_state), pos.market_index, pos.base_asset_amount, ) else { + log::warn!(target: TARGET, "failed to calculate collateral requirement for market {}", pos.market_index); return; }; + // Check available collateral + let collateral_map = collateral_info_per_subaccount.read().unwrap(); + let Some(collateral_info) = collateral_map.get(&keeper_subaccount) else { + log::warn!(target: TARGET, "no collateral info for keeper subaccount"); + return; + }; + + // Calculate amount to be liquidated (full or partial) + let base_asset_amount_to_be_liquidated = + if collateral_info.free >= collateral_required as i128 { + pos.base_asset_amount.unsigned_abs() + } else { + // Scale down to 90% of available collateral + let scale_factor = (collateral_info.free as f64 / collateral_required as f64) * 0.9; + (pos.base_asset_amount.unsigned_abs() as f64 * scale_factor) as u64 + }; + + metrics + .liquidation_attempts + .with_label_values(&["perp"]) + .inc(); + + // Get oracle price and pyth update once let oracle_price = { let state = market_state.read().unwrap(); match state.get_perp_oracle_price(pos.market_index) { @@ -1641,18 +2220,55 @@ impl LiquidateWithMatchStrategy { let pyth_update = pyth_price_update.filter(|update| update.price != oracle_price); - try_liquidate_with_match( - &drift, + // Find makers and decide method + let makers = Self::find_top_makers( + drift, + dlob, + Arc::clone(&market_state), pos.market_index, - keeper_subaccount, - liquidatee, - makers.as_slice(), - tx_sender, - priority_fee, - cu_limit, - slot, - pyth_update, + pos.base_asset_amount, ); + + let method = Self::decide_perp_method( + base_asset_amount_to_be_liquidated, + collateral_info.free, + collateral_required, + makers.is_some(), + ); + + match method { + LiquidationType::PerpWithFill => { + try_liquidate_with_match( + drift, + pos.market_index, + keeper_subaccount, + liquidatee, + makers.unwrap().as_slice(), + tx_sender, + priority_fee, + cu_limit, + slot, + pyth_update, + ); + } + LiquidationType::PerpTakeover => { + try_liquidate_with_collateral( + drift, + pos.market_index, + keeper_subaccount, + liquidatee, + base_asset_amount_to_be_liquidated, + tx_sender, + priority_fee, + cu_limit, + slot, + pyth_update, + ); + } + _ => { + log::info!(target: TARGET, "skipping liquidation for {:?}", liquidatee); + } + } } /// Attempt spot liquidation with Jupiter swap @@ -1886,7 +2502,7 @@ impl LiquidateWithMatchStrategy { } } -impl LiquidationStrategy for LiquidateWithMatchStrategy { +impl LiquidationStrategy for PrimaryLiquidationStrategy { fn liquidate_user<'a>( &'a self, liquidatee: Pubkey, @@ -1896,40 +2512,105 @@ impl LiquidationStrategy for LiquidateWithMatchStrategy { cu_limit: u32, slot: u64, pyth_price_update: Option, + collateral_info_per_subaccount: Arc>>, status: UserMarginStatus, ) -> futures_util::future::BoxFuture<'a, ()> { - Self::liquidate_perp( - &self.drift, - self.dlob, + let perp_positions = Self::get_perp_positions_info( Arc::clone(&self.market_state), - Arc::clone(&self.metrics), - self.keeper_subaccount, - liquidatee, - Arc::clone(&user_account), - tx_sender.clone(), - priority_fee, - cu_limit, - slot, - pyth_price_update, - &status, + &user_account.perp_positions, ); - if self.use_spot_liquidation { - Self::liquidate_spot( - self.drift.clone(), - Arc::clone(&self.metrics), - Arc::clone(&self.market_state), - self.keeper_subaccount, - liquidatee, - user_account, - tx_sender.clone(), - priority_fee, - 400_000, - slot, - ) - .boxed() - } else { - async {}.boxed() + let spot_positions = Self::get_spot_positions_info( + Arc::clone(&self.market_state), + &user_account.spot_positions, + ); + + let Some((liability, asset)) = + Self::pick_best_asset_liability_combo(&perp_positions, &spot_positions) + else { + log::warn!(target: TARGET, "no liquidatable positions for {:?}", liquidatee); + return async {}.boxed(); + }; + + let liq_type = Self::decide_liquidation_type(&liability, asset.as_ref()); + + match liq_type { + LiquidationType::PerpTakeover | LiquidationType::PerpWithFill => { + Self::liquidate_perp( + &self.drift, + self.dlob, + Arc::clone(&self.market_state), + Arc::clone(&self.metrics), + self.keeper_subaccount, + liquidatee, + Arc::clone(&user_account), + tx_sender.clone(), + priority_fee, + cu_limit, + slot, + pyth_price_update, + collateral_info_per_subaccount, + &status, + ); + async {}.boxed() + } + LiquidationType::SpotForSpot => { + if self.use_spot_liquidation { + Self::liquidate_spot( + self.drift.clone(), + Arc::clone(&self.metrics), + Arc::clone(&self.market_state), + self.keeper_subaccount, + liquidatee, + user_account, + tx_sender.clone(), + priority_fee, + 400_000, + slot, + ) + .boxed() + } else { + async {}.boxed() + } + } + LiquidationType::PerpPnlForDeposit => { + if let Some(asset) = asset { + try_liquidate_perp_pnl_for_deposit( + &self.drift, + self.keeper_subaccount, + liquidatee, + &liability, + &asset, + tx_sender, + priority_fee, + cu_limit, + slot, + pyth_price_update, + ); + } + async {}.boxed() + } + LiquidationType::BorrowForPerpPnl => { + if let Some(asset) = asset { + try_liquidate_borrow_for_perp_pnl( + &self.drift, + self.keeper_subaccount, + liquidatee, + &liability, + &asset, + tx_sender, + priority_fee, + cu_limit, + slot, + pyth_price_update, + ); + } + async {}.boxed() + } + LiquidationType::Skip => { + log::info!(target: TARGET, "skipping liquidation for {:?}: insufficient collateral", liquidatee); + async {}.boxed() + } } } } diff --git a/src/main.rs b/src/main.rs index e11d71f..c5df290 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,6 +43,9 @@ pub struct Config { /// Comma-separated list of perp market indices to fill for #[clap(long, env = "MARKET_IDS", default_value = "0,1,2")] pub market_ids: String, + /// Comma-separated list of subaccount IDs to use for liquidations + #[clap(long, env = "SUBACCOUNTS", default_value = "0,1,2")] + pub subaccounts: String, /// Use mainnet (otherwise devnet) #[clap(long, env = "MAINNET", default_value = "true")] pub mainnet: bool, @@ -80,6 +83,13 @@ impl Config { ) } } + + pub fn get_subaccounts(&self) -> Vec { + self.subaccounts + .split(',') + .filter_map(|s| s.trim().parse::().ok()) + .collect() + } } #[tokio::main(flavor = "multi_thread", worker_threads = 10)] diff --git a/src/util.rs b/src/util.rs index 50303f0..526e12d 100644 --- a/src/util.rs +++ b/src/util.rs @@ -91,7 +91,7 @@ impl OrderSlotLimiter { } } -#[derive(Clone, Debug, Default)] +#[derive(Clone, Default, Debug)] pub enum TxIntent { #[default] None, @@ -120,12 +120,33 @@ pub enum TxIntent { liquidatee: Pubkey, slot: u64, }, + LiquidatePerp { + market_index: u16, + liquidatee: Pubkey, + slot: u64, + }, + LiquidatePerpPnlForDeposit { + perp_market_index: u16, + spot_market_index: u16, + liquidatee: Pubkey, + slot: u64, + }, + LiquidateBorrowForPerpPnl { + perp_market_index: u16, + spot_market_index: u16, + liquidatee: Pubkey, + slot: u64, + }, LiquidateSpot { asset_market_index: u16, liability_market_index: u16, liquidatee: Pubkey, slot: u64, }, + Derisk { + market_index: u16, + subaccount: Pubkey, + }, } impl TxIntent { @@ -149,7 +170,11 @@ impl TxIntent { TxIntent::LimitUncross { .. } => "limit_uncross", TxIntent::VAMMTakerFill { .. } => "vamm_taker", TxIntent::LiquidateWithFill { .. } => "liq_with_fill", + TxIntent::LiquidatePerp { .. } => "liq_perp", + TxIntent::LiquidatePerpPnlForDeposit { .. } => "liq_perp_pnl_for_deposit", + TxIntent::LiquidateBorrowForPerpPnl { .. } => "liq_borrow_for_perp_pnl", TxIntent::LiquidateSpot { .. } => "liq_spot", + TxIntent::Derisk { .. } => "derisk", } } @@ -165,7 +190,11 @@ impl TxIntent { TxIntent::VAMMTakerFill { .. } => 1, TxIntent::LimitUncross { .. } => 1, TxIntent::LiquidateWithFill { .. } => 1, + TxIntent::LiquidatePerp { .. } => 0, + TxIntent::LiquidatePerpPnlForDeposit { .. } => 0, + TxIntent::LiquidateBorrowForPerpPnl { .. } => 0, TxIntent::LiquidateSpot { .. } => 0, + TxIntent::Derisk { .. } => 0, } } @@ -189,7 +218,11 @@ impl TxIntent { Self::VAMMTakerFill { slot, .. } => (vec![], *slot), Self::LimitUncross { slot, .. } => (vec![], *slot), Self::LiquidateWithFill { slot, .. } => (vec![], *slot), + Self::LiquidatePerp { slot, .. } => (vec![], *slot), + Self::LiquidatePerpPnlForDeposit { slot, .. } => (vec![], *slot), + Self::LiquidateBorrowForPerpPnl { slot, .. } => (vec![], *slot), Self::LiquidateSpot { slot, .. } => (vec![], *slot), + TxIntent::Derisk { .. } => (vec![], 0), } } @@ -198,6 +231,9 @@ impl TxIntent { Self::VAMMTakerFill { slot, .. } | Self::LimitUncross { slot, .. } | Self::LiquidateWithFill { slot, .. } + | Self::LiquidatePerp { slot, .. } + | Self::LiquidatePerpPnlForDeposit { slot, .. } + | Self::LiquidateBorrowForPerpPnl { slot, .. } | Self::LiquidateSpot { slot, .. } => Some(*slot), _ => None, }