diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ffea4a662..7bacff61d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,16 +4,26 @@ Update this for each program release and mainnet deployment. ## not on mainnet +### v0.24.3, 2024-8- + +- Program: Remove delegate account withdrawal limit (#939) +- Program: Allow the insurance fund to be for any bank (#946) +- Program: add a tier string in banks (#988) +- Program: Allow closing open orders account for force closed spot markets (#995) + +## mainnet + ### v0.24.2, 2024-7- +Deployment: Jul 29, 2024 at 10:46:28 Central European Summer Time, +https://explorer.solana.com/tx/8zFSfPVSw98NL7nnJgfjNEhXcUVJtaCxLxnj9jt6kAnLwBR8LvzrS1Q5p21CuwiueJisdQFurf1ujNy38GiUEB1 + - Program: charge collateral fee directly on borrowed tokens (#973) - Program: fix TokenUpdateIndexAndRateResilient IX (#979) - Program: add support for pyth v2 account (#980) -## mainnet - ### v0.24.1, 2024-7-9 Deployment: Jul 9, 2024 at 15:46:15 Central European Summer Time, @@ -25,18 +35,18 @@ https://explorer.solana.com/tx/5KYBSXV4uRCUK6vaQoZjipNFhTqEY1b1DHJeSh5jo87UUVGBB ### v0.24.0, 2024-4-18 -Deployment: Apr 18, 2024 at 14:53:24 Central European Summer Time, +Deployment: Apr 18, 2024 at 14:53:24 Central European Summer Time, https://explorer.solana.com/tx/2TFCGXQkUjRvkuuojxmiKefUtHPp6q6rM1frYvALByWMGfpWbiGH5hGq5suWEH7TUKoz4jb4KCGxu9DRw7YcXNdh - Allow skipping banks and invalid oracles when computing health (#891) - This is only possible when we know for sure that the operation would not put the account into negative health zone. + This is only possible when we know for sure that the operation would not put the account into negative health zone. - Add support for Raydium CLMM as oracle fallback (#856) - + - Add a `TokenBalanceLog` when charging collateral fees (#894) -- Withdraw instruction: remove overflow error and return appropriate error message instead (#910) +- Withdraw instruction: remove overflow error and return appropriate error message instead (#910) - Banks: add more safety checks (#895) @@ -46,7 +56,7 @@ https://explorer.solana.com/tx/2TFCGXQkUjRvkuuojxmiKefUtHPp6q6rM1frYvALByWMGfpWb - Add a sequence check instruction (#909) - Assert that a transaction was emitted and run with a correct view of the current mango state. + Assert that a transaction was emitted and run with a correct view of the current mango state. ### v0.23.0, 2024-3-8 @@ -154,6 +164,7 @@ Deployment: Dec 13, 2023 at 09:02:46 Central European Standard Time, https://exp The DAO had previously reduced the percentage amount as a mitigation. With this change: + - low-health settlement incentives are capped at 2x the flat fee, removing unlimited percentual incentive fees entirely - incentives are only paid if at least 1% of position value is settled, diff --git a/Cargo.lock b/Cargo.lock index dcb9cfa7d2..915190b932 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3467,7 +3467,7 @@ dependencies = [ [[package]] name = "mango-v4" -version = "0.24.2" +version = "0.24.3" dependencies = [ "anchor-lang", "anchor-spl", diff --git a/audits/Audit_OtterSec_Mango_v0.24.3.pdf b/audits/Audit_OtterSec_Mango_v0.24.3.pdf new file mode 100644 index 0000000000..4208e262b4 Binary files /dev/null and b/audits/Audit_OtterSec_Mango_v0.24.3.pdf differ diff --git a/bin/liquidator/src/cli_args.rs b/bin/liquidator/src/cli_args.rs index 9396d7a353..4d144f21dd 100644 --- a/bin/liquidator/src/cli_args.rs +++ b/bin/liquidator/src/cli_args.rs @@ -136,6 +136,13 @@ pub struct Cli { #[clap(long, env, default_value = "30")] pub(crate) rebalance_refresh_timeout_secs: u64, + #[clap(long, env, value_enum, default_value = "false")] + pub(crate) rebalance_using_limit_order: BoolArg, + + /// distance (in bps) from oracle price at which to place order for rebalancing + #[clap(long, env, default_value = "100")] + pub(crate) rebalance_limit_order_distance_from_oracle_price_bps: u64, + /// if taking tcs orders is enabled /// /// typically only disabled for tests where swaps are unavailable @@ -250,4 +257,8 @@ pub struct Cli { /// override the sanctum http request timeout #[clap(long, env, default_value = "30")] pub(crate) sanctum_timeout_secs: u64, + + /// max number of liquidation/tcs to do concurrently + #[clap(long, env, default_value = "5")] + pub(crate) max_parallel_operations: u64, } diff --git a/bin/liquidator/src/liquidate.rs b/bin/liquidator/src/liquidate.rs index fd39d49646..4b6cd625da 100644 --- a/bin/liquidator/src/liquidate.rs +++ b/bin/liquidator/src/liquidate.rs @@ -9,6 +9,7 @@ use mango_v4_client::{chain_data, MangoClient, PreparedInstructions}; use solana_sdk::signature::Signature; use futures::{stream, StreamExt, TryStreamExt}; +use mango_v4::accounts_ix::HealthCheckKind::MaintRatio; use rand::seq::SliceRandom; use tracing::*; use {anyhow::Context, fixed::types::I80F48, solana_sdk::pubkey::Pubkey}; @@ -260,7 +261,22 @@ impl<'a> LiquidateHelper<'a> { ) .await .context("creating perp_liq_base_or_positive_pnl_instruction")?; + liq_ixs.cu = liq_ixs.cu.max(self.config.compute_limit_for_liq_ix); + + let liqor = &self.client.mango_account().await?; + liq_ixs.append( + self.client + .health_check_instruction( + liqor, + self.config.min_health_ratio, + vec![], + vec![*perp_market_index], + MaintRatio, + ) + .await?, + ); + let txsig = self .client .send_and_confirm_authority_tx(liq_ixs.to_instructions()) @@ -501,6 +517,20 @@ impl<'a> LiquidateHelper<'a> { .await .context("creating liq_token_with_token ix")?; liq_ixs.cu = liq_ixs.cu.max(self.config.compute_limit_for_liq_ix); + + let liqor = self.client.mango_account().await?; + liq_ixs.append( + self.client + .health_check_instruction( + &liqor, + self.config.min_health_ratio, + vec![asset_token_index, liab_token_index], + vec![], + MaintRatio, + ) + .await?, + ); + let txsig = self .client .send_and_confirm_authority_tx(liq_ixs.to_instructions()) @@ -651,14 +681,11 @@ impl<'a> LiquidateHelper<'a> { } #[allow(clippy::too_many_arguments)] -pub async fn maybe_liquidate_account( +pub async fn can_liquidate_account( mango_client: &MangoClient, account_fetcher: &chain_data::AccountFetcher, pubkey: &Pubkey, - config: &Config, ) -> anyhow::Result { - let liqor_min_health_ratio = I80F48::from_num(config.min_health_ratio); - let account = account_fetcher.fetch_mango_account(pubkey)?; let health_cache = mango_client .health_cache(&account) @@ -675,6 +702,18 @@ pub async fn maybe_liquidate_account( "possible candidate", ); + Ok(true) +} + +#[allow(clippy::too_many_arguments)] +pub async fn maybe_liquidate_account( + mango_client: &MangoClient, + account_fetcher: &chain_data::AccountFetcher, + pubkey: &Pubkey, + config: &Config, +) -> anyhow::Result { + let liqor_min_health_ratio = I80F48::from_num(config.min_health_ratio); + // Fetch a fresh account and re-compute // This is -- unfortunately -- needed because the websocket streams seem to not // be great at providing timely updates to the account data. diff --git a/bin/liquidator/src/liquidation_state.rs b/bin/liquidator/src/liquidation_state.rs new file mode 100644 index 0000000000..aedae78908 --- /dev/null +++ b/bin/liquidator/src/liquidation_state.rs @@ -0,0 +1,238 @@ +use crate::cli_args::Cli; +use crate::metrics::Metrics; +use crate::unwrappable_oracle_error::UnwrappableOracleError; +use crate::{liquidate, LiqErrorType, SharedState}; +use anchor_lang::prelude::Pubkey; +use itertools::Itertools; +use mango_v4::state::TokenIndex; +use mango_v4_client::error_tracking::ErrorTracking; +use mango_v4_client::{chain_data, MangoClient, MangoClientError}; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; +use tokio::task::JoinHandle; +use tracing::{error, trace, warn}; + +#[derive(Clone)] +pub struct LiquidationState { + pub mango_client: Arc, + pub account_fetcher: Arc, + pub liquidation_config: liquidate::Config, + + pub errors: Arc>>, + pub oracle_errors: Arc>>, +} + +impl LiquidationState { + async fn find_candidates( + &mut self, + accounts_iter: impl Iterator, + action: impl Fn(Pubkey) -> anyhow::Result<()>, + ) -> anyhow::Result { + let mut found_counter = 0u64; + use rand::seq::SliceRandom; + + let mut accounts = accounts_iter.collect::>(); + { + let mut rng = rand::thread_rng(); + accounts.shuffle(&mut rng); + } + + for pubkey in accounts { + if self.should_skip_execution(pubkey) { + continue; + } + + let result = + liquidate::can_liquidate_account(&self.mango_client, &self.account_fetcher, pubkey) + .await; + + self.log_or_ignore_error(&result, pubkey); + + if result.unwrap_or(false) { + action(*pubkey)?; + found_counter = found_counter + 1; + } + } + + Ok(found_counter) + } + + fn should_skip_execution(&mut self, pubkey: &Pubkey) -> bool { + let now = Instant::now(); + let error_tracking = &mut self.errors; + + // Skip a pubkey if there've been too many errors recently + if let Some(error_entry) = + error_tracking + .read() + .unwrap() + .had_too_many_errors(LiqErrorType::Liq, pubkey, now) + { + trace!( + %pubkey, + error_entry.count, + "skip checking account for liquidation, had errors recently", + ); + return true; + } + + false + } + + fn log_or_ignore_error(&mut self, result: &anyhow::Result, pubkey: &Pubkey) { + let error_tracking = &mut self.errors; + + if let Err(err) = result.as_ref() { + if let Some((ti, ti_name)) = err.try_unwrap_oracle_error() { + if self + .oracle_errors + .read() + .unwrap() + .had_too_many_errors(LiqErrorType::Liq, &ti, Instant::now()) + .is_none() + { + warn!( + "{:?} recording oracle error for token {} {}", + chrono::offset::Utc::now(), + ti_name, + ti + ); + } + + self.oracle_errors + .write() + .unwrap() + .record(LiqErrorType::Liq, &ti, err.to_string()); + return; + } + + // Keep track of pubkeys that had errors + error_tracking + .write() + .unwrap() + .record(LiqErrorType::Liq, pubkey, err.to_string()); + + // Not all errors need to be raised to the user's attention. + let mut is_error = true; + + // Simulation errors due to liqee precondition failures on the liquidation instructions + // will commonly happen if our liquidator is late or if there are chain forks. + match err.downcast_ref::() { + Some(MangoClientError::SendTransactionPreflightFailure { logs, .. }) => { + if logs.iter().any(|line| { + line.contains("HealthMustBeNegative") || line.contains("IsNotBankrupt") + }) { + is_error = false; + } + } + _ => {} + }; + if is_error { + error!("liquidating account {}: {:?}", pubkey, err); + } else { + trace!("liquidating account {}: {:?}", pubkey, err); + } + } else { + error_tracking + .write() + .unwrap() + .clear(LiqErrorType::Liq, pubkey); + } + } + + pub async fn maybe_liquidate_and_log_error(&mut self, pubkey: &Pubkey) -> anyhow::Result { + if self.should_skip_execution(pubkey) { + return Ok(false); + } + + let result = liquidate::maybe_liquidate_account( + &self.mango_client, + &self.account_fetcher, + pubkey, + &self.liquidation_config, + ) + .await; + + self.log_or_ignore_error(&result, pubkey); + return result; + } +} + +pub fn spawn_liquidation_job( + cli: &Cli, + shared_state: &Arc>, + tx_trigger_sender: async_channel::Sender<()>, + mut liquidation: Box, + metrics: &Metrics, +) -> JoinHandle<()> { + tokio::spawn({ + let mut interval = + mango_v4_client::delay_interval(Duration::from_millis(cli.check_interval_ms)); + let mut metric_liquidation_check = metrics.register_latency("liquidation_check".into()); + let mut metric_liquidation_start_end = + metrics.register_latency("liquidation_start_end".into()); + + let mut liquidation_start_time = None; + + let shared_state = shared_state.clone(); + async move { + loop { + interval.tick().await; + + let account_addresses = { + let mut state = shared_state.write().unwrap(); + if !state.one_snapshot_done { + // discard first latency info as it will skew data too much + state.oldest_chain_event_reception_time = None; + continue; + } + if state.oldest_chain_event_reception_time.is_none() + && liquidation_start_time.is_none() + { + // no new update, skip computing + continue; + } + + state.mango_accounts.iter().cloned().collect_vec() + }; + + liquidation.errors.write().unwrap().update(); + liquidation.oracle_errors.write().unwrap().update(); + + if liquidation_start_time.is_none() { + liquidation_start_time = Some(Instant::now()); + } + + let found_candidates = liquidation + .find_candidates(account_addresses.iter(), |p| { + if shared_state + .write() + .unwrap() + .liquidation_candidates_accounts + .insert(p) + { + tx_trigger_sender.try_send(())?; + } + + Ok(()) + }) + .await + .unwrap(); + + if found_candidates > 0 { + tracing::debug!("found {} candidates for liquidation", found_candidates); + } + + let mut state = shared_state.write().unwrap(); + let reception_time = state.oldest_chain_event_reception_time.unwrap(); + let current_time = Instant::now(); + + state.oldest_chain_event_reception_time = None; + + metric_liquidation_check.push(current_time - reception_time); + metric_liquidation_start_end.push(current_time - liquidation_start_time.unwrap()); + liquidation_start_time = None; + } + } + }) +} diff --git a/bin/liquidator/src/main.rs b/bin/liquidator/src/main.rs index 76a30771d6..800e92edb7 100644 --- a/bin/liquidator/src/main.rs +++ b/bin/liquidator/src/main.rs @@ -4,33 +4,41 @@ use std::sync::{Arc, RwLock}; use std::time::{Duration, Instant}; use anchor_client::Cluster; -use anyhow::Context; use clap::Parser; +use futures_util::StreamExt; use mango_v4::state::{PerpMarketIndex, TokenIndex}; -use mango_v4_client::AsyncChannelSendUnlessFull; use mango_v4_client::{ account_update_stream, chain_data, error_tracking::ErrorTracking, keypair_from_cli, - snapshot_source, websocket_source, Client, MangoClient, MangoClientError, MangoGroupContext, + snapshot_source, websocket_source, Client, MangoClient, MangoGroupContext, TransactionBuilderConfig, }; +use crate::cli_args::{BoolArg, Cli, CliDotenv}; +use crate::liquidation_state::LiquidationState; +use crate::rebalance::Rebalancer; +use crate::tcs_state::TcsState; +use crate::token_swap_info::TokenSwapInfoUpdater; use itertools::Itertools; use solana_sdk::commitment_config::CommitmentConfig; use solana_sdk::program_stubs::{set_syscall_stubs, SyscallStubs}; use solana_sdk::pubkey::Pubkey; +use solana_sdk::signer::Signer; +use tokio::task::JoinHandle; use tracing::*; pub mod cli_args; pub mod liquidate; +mod liquidation_state; pub mod metrics; pub mod rebalance; +mod tcs_state; pub mod telemetry; pub mod token_swap_info; pub mod trigger_tcs; +mod tx_sender; mod unwrappable_oracle_error; pub mod util; -use crate::unwrappable_oracle_error::UnwrappableOracleError; use crate::util::{is_mango_account, is_mint_info, is_perp_market}; // jemalloc seems to be better at keeping the memory footprint reasonable over @@ -87,7 +95,7 @@ async fn main() -> anyhow::Result<()> { // Client setup // let liqor_owner = Arc::new(keypair_from_cli(&cli.liqor_owner)); - let rpc_url = cli.rpc_url; + let rpc_url = cli.rpc_url.clone(); let ws_url = rpc_url.replace("https", "wss"); let rpc_timeout = Duration::from_secs(10); let cluster = Cluster::Custom(rpc_url.clone(), ws_url.clone()); @@ -110,7 +118,7 @@ async fn main() -> anyhow::Result<()> { .build() .unwrap(), ) - .override_send_transaction_urls(cli.override_send_transaction_url) + .override_send_transaction_urls(cli.override_send_transaction_url.clone()) .build() .unwrap(); @@ -127,6 +135,11 @@ async fn main() -> anyhow::Result<()> { .await?; let mango_group = mango_account.fixed.group; + let signer_is_owner = mango_account.fixed.owner == liqor_owner.pubkey(); + if cli.rebalance == BoolArg::True && !signer_is_owner { + warn!("rebalancing on delegated accounts will be unable to free token positions reliably, withdraw dust manually"); + } + let group_context = MangoGroupContext::new_from_rpc(client.rpc_async(), mango_group).await?; let mango_oracles = group_context @@ -230,17 +243,18 @@ async fn main() -> anyhow::Result<()> { compute_limit_for_liq_ix: cli.compute_limit_for_liquidation, max_cu_per_transaction: 1_000_000, refresh_timeout: Duration::from_secs(cli.liquidation_refresh_timeout_secs as u64), - only_allowed_tokens: cli_args::cli_to_hashset::(cli.only_allow_tokens), - forbidden_tokens: cli_args::cli_to_hashset::(cli.forbidden_tokens), + only_allowed_tokens: cli_args::cli_to_hashset::(cli.only_allow_tokens.clone()), + forbidden_tokens: cli_args::cli_to_hashset::(cli.forbidden_tokens.clone()), only_allowed_perp_markets: cli_args::cli_to_hashset::( - cli.liquidation_only_allow_perp_markets, + cli.liquidation_only_allow_perp_markets.clone(), ), forbidden_perp_markets: cli_args::cli_to_hashset::( - cli.liquidation_forbidden_perp_markets, + cli.liquidation_forbidden_perp_markets.clone(), ), }; let tcs_config = trigger_tcs::Config { + refresh_timeout: Duration::from_secs(cli.tcs_refresh_timeout_secs), min_health_ratio: cli.min_health_ratio, max_trigger_quote_amount: (cli.tcs_max_trigger_amount * 1e6) as u64, compute_limit_for_trigger: cli.compute_limit_for_tcs, @@ -257,17 +271,19 @@ async fn main() -> anyhow::Result<()> { forbidden_tokens: liq_config.forbidden_tokens.clone(), }; - let mut rebalance_interval = tokio::time::interval(Duration::from_secs(30)); let (rebalance_trigger_sender, rebalance_trigger_receiver) = async_channel::bounded::<()>(1); + let (tx_tcs_trigger_sender, tx_tcs_trigger_receiver) = async_channel::unbounded::<()>(); + let (tx_liq_trigger_sender, tx_liq_trigger_receiver) = async_channel::unbounded::<()>(); let rebalance_config = rebalance::Config { enabled: cli.rebalance == BoolArg::True, slippage_bps: cli.rebalance_slippage_bps, borrow_settle_excess: (1f64 + cli.rebalance_borrow_settle_excess).max(1f64), refresh_timeout: Duration::from_secs(cli.rebalance_refresh_timeout_secs), jupiter_version: cli.jupiter_version.into(), - skip_tokens: cli.rebalance_skip_tokens.unwrap_or_default(), + skip_tokens: cli.rebalance_skip_tokens.clone().unwrap_or(Vec::new()), alternate_jupiter_route_tokens: cli .rebalance_alternate_jupiter_route_tokens + .clone() .unwrap_or_default(), alternate_sanctum_route_tokens: cli .rebalance_alternate_sanctum_route_tokens @@ -275,6 +291,9 @@ async fn main() -> anyhow::Result<()> { .unwrap_or_default(), use_sanctum: cli.sanctum_enabled == BoolArg::True, allow_withdraws: true, + use_limit_order: cli.rebalance_using_limit_order == BoolArg::True, + limit_order_distance_from_oracle_price_bps: cli + .rebalance_limit_order_distance_from_oracle_price_bps, }; rebalance_config.validate(&mango_client.context); @@ -286,23 +305,39 @@ async fn main() -> anyhow::Result<()> { sanctum_supported_mints: HashSet::::new(), }); - let mut liquidation = Box::new(LiquidationState { + let liquidation = Box::new(LiquidationState { mango_client: mango_client.clone(), - account_fetcher, + account_fetcher: account_fetcher.clone(), liquidation_config: liq_config, + errors: Arc::new(RwLock::new( + ErrorTracking::builder() + .skip_threshold(2) + .skip_threshold_for_type(LiqErrorType::Liq, 5) + .skip_duration(Duration::from_secs(120)) + .build()?, + )), + oracle_errors: Arc::new(RwLock::new( + ErrorTracking::builder() + .skip_threshold(1) + .skip_duration(Duration::from_secs( + cli.skip_oracle_error_in_logs_duration_secs, + )) + .build()?, + )), + }); + + let tcs = Box::new(TcsState { + mango_client: mango_client.clone(), + account_fetcher, trigger_tcs_config: tcs_config, token_swap_info: token_swap_info_updater.clone(), - errors: ErrorTracking::builder() - .skip_threshold(2) - .skip_threshold_for_type(LiqErrorType::Liq, 5) - .skip_duration(Duration::from_secs(120)) - .build()?, - oracle_errors: ErrorTracking::builder() - .skip_threshold(1) - .skip_duration(Duration::from_secs( - cli.skip_oracle_error_in_logs_duration_secs, - )) - .build()?, + errors: Arc::new(RwLock::new( + ErrorTracking::builder() + .skip_threshold(2) + .skip_threshold_for_type(LiqErrorType::Liq, 5) + .skip_duration(Duration::from_secs(120)) + .build()?, + )), }); info!("main loop"); @@ -403,126 +438,87 @@ async fn main() -> anyhow::Result<()> { } }); + let mut optional_jobs = vec![]; + // Could be refactored to only start the below jobs when the first snapshot is done. // But need to take care to abort if the above job aborts beforehand. + if cli.rebalance == BoolArg::True { + let rebalance_job = + spawn_rebalance_job(&shared_state, rebalance_trigger_receiver, rebalancer); + optional_jobs.push(rebalance_job); + } - let rebalance_job = tokio::spawn({ - let shared_state = shared_state.clone(); - async move { - loop { - tokio::select! { - _ = rebalance_interval.tick() => {} - _ = rebalance_trigger_receiver.recv() => {} - } - if !shared_state.read().unwrap().one_snapshot_done { - continue; - } - if let Err(err) = rebalancer.zero_all_non_quote().await { - error!("failed to rebalance liqor: {:?}", err); - - // Workaround: We really need a sequence enforcer in the liquidator since we don't want to - // accidentally send a similar tx again when we incorrectly believe an earlier one got forked - // off. For now, hard sleep on error to avoid the most frequent error cases. - tokio::time::sleep(Duration::from_secs(10)).await; - } - } - } - }); - - let liquidation_job = tokio::spawn({ - let mut interval = - mango_v4_client::delay_interval(Duration::from_millis(cli.check_interval_ms)); - let mut metric_liquidation_check = metrics.register_latency("liquidation_check".into()); - let mut metric_liquidation_start_end = - metrics.register_latency("liquidation_start_end".into()); - - let mut liquidation_start_time = None; - let mut tcs_start_time = None; - - let shared_state = shared_state.clone(); - async move { - loop { - interval.tick().await; - - let account_addresses = { - let mut state = shared_state.write().unwrap(); - if !state.one_snapshot_done { - // discard first latency info as it will skew data too much - state.oldest_chain_event_reception_time = None; - continue; - } - if state.oldest_chain_event_reception_time.is_none() - && liquidation_start_time.is_none() - { - // no new update, skip computing - continue; - } - - state.mango_accounts.iter().cloned().collect_vec() - }; - - liquidation.errors.update(); - liquidation.oracle_errors.update(); - - if liquidation_start_time.is_none() { - liquidation_start_time = Some(Instant::now()); - } - - let liquidated = liquidation - .maybe_liquidate_one(account_addresses.iter()) - .await; + if cli.liquidation_enabled == BoolArg::True { + let liquidation_job = liquidation_state::spawn_liquidation_job( + &cli, + &shared_state, + tx_liq_trigger_sender.clone(), + liquidation.clone(), + &metrics, + ); + optional_jobs.push(liquidation_job); + } - if !liquidated { - // This will be incorrect if we liquidate the last checked account - // (We will wait for next full run, skewing latency metrics) - // Probability is very low, might not need to be fixed + if cli.take_tcs == BoolArg::True { + let tcs_job = tcs_state::spawn_tcs_job( + &cli, + &shared_state, + tx_tcs_trigger_sender.clone(), + tcs.clone(), + &metrics, + ); + optional_jobs.push(tcs_job); + } - let mut state = shared_state.write().unwrap(); - let reception_time = state.oldest_chain_event_reception_time.unwrap(); - let current_time = Instant::now(); + if cli.liquidation_enabled == BoolArg::True || cli.take_tcs == BoolArg::True { + let mut tx_sender_jobs = tx_sender::spawn_tx_senders_job( + cli.max_parallel_operations, + cli.liquidation_enabled == BoolArg::True, + tx_liq_trigger_receiver, + tx_tcs_trigger_receiver, + tx_tcs_trigger_sender, + rebalance_trigger_sender, + shared_state.clone(), + liquidation, + tcs, + ); + optional_jobs.append(&mut tx_sender_jobs); + } - state.oldest_chain_event_reception_time = None; + if cli.telemetry == BoolArg::True { + optional_jobs.push(spawn_telemetry_job(&cli, mango_client.clone())); + } - metric_liquidation_check.push(current_time - reception_time); - metric_liquidation_start_end - .push(current_time - liquidation_start_time.unwrap()); - liquidation_start_time = None; - } + let token_swap_info_job = + spawn_token_swap_refresh_job(&cli, shared_state, token_swap_info_updater); + let check_changes_for_abort_job = spawn_context_change_watchdog_job(mango_client.clone()); - let mut took_tcs = false; - if !liquidated && cli.take_tcs == BoolArg::True { - tcs_start_time = Some(tcs_start_time.unwrap_or(Instant::now())); - - took_tcs = liquidation - .maybe_take_token_conditional_swap(account_addresses.iter()) - .await - .unwrap_or_else(|err| { - error!("error during maybe_take_token_conditional_swap: {err}"); - false - }); - - if !took_tcs { - let current_time = Instant::now(); - let mut metric_tcs_start_end = - metrics.register_latency("tcs_start_end".into()); - metric_tcs_start_end.push(current_time - tcs_start_time.unwrap()); - tcs_start_time = None; - } - } + let mut jobs: futures::stream::FuturesUnordered<_> = vec![ + data_job, + token_swap_info_job, + check_changes_for_abort_job, + snapshot_job, + ] + .into_iter() + .chain(optional_jobs) + .chain(prio_jobs.into_iter()) + .collect(); + jobs.next().await; - if liquidated || took_tcs { - rebalance_trigger_sender.send_unless_full(()).unwrap(); - } - } - } - }); + error!("a critical job aborted, exiting"); + Ok(()) +} - let token_swap_info_job = tokio::spawn({ +fn spawn_token_swap_refresh_job( + cli: &Cli, + shared_state: Arc>, + token_swap_info_updater: Arc, +) -> JoinHandle<()> { + tokio::spawn({ let mut interval = mango_v4_client::delay_interval(Duration::from_secs( cli.token_swap_refresh_interval_secs, )); let mut startup_wait = mango_v4_client::delay_interval(Duration::from_secs(1)); - let shared_state = shared_state.clone(); async move { loop { if !shared_state.read().unwrap().one_snapshot_done { @@ -546,42 +542,57 @@ async fn main() -> anyhow::Result<()> { token_swap_info_updater.log_all(); } } - }); + }) +} - let check_changes_for_abort_job = - tokio::spawn(MangoClient::loop_check_for_context_changes_and_abort( - mango_client.clone(), - Duration::from_secs(300), - )); +fn spawn_context_change_watchdog_job(mango_client: Arc) -> JoinHandle<()> { + tokio::spawn(MangoClient::loop_check_for_context_changes_and_abort( + mango_client, + Duration::from_secs(300), + )) +} - if cli.telemetry == BoolArg::True { - tokio::spawn(telemetry::report_regularly( - mango_client, - cli.min_health_ratio, - )); - } +fn spawn_telemetry_job(cli: &Cli, mango_client: Arc) -> JoinHandle<()> { + tokio::spawn(telemetry::report_regularly( + mango_client, + cli.min_health_ratio, + )) +} - use cli_args::{BoolArg, Cli, CliDotenv}; - use futures::StreamExt; - let mut jobs: futures::stream::FuturesUnordered<_> = vec![ - data_job, - rebalance_job, - liquidation_job, - token_swap_info_job, - check_changes_for_abort_job, - snapshot_job, - ] - .into_iter() - .chain(prio_jobs.into_iter()) - .collect(); - jobs.next().await; +fn spawn_rebalance_job( + shared_state: &Arc>, + rebalance_trigger_receiver: async_channel::Receiver<()>, + rebalancer: Arc, +) -> JoinHandle<()> { + let mut rebalance_interval = tokio::time::interval(Duration::from_secs(30)); - error!("a critical job aborted, exiting"); - Ok(()) + tokio::spawn({ + let shared_state = shared_state.clone(); + async move { + loop { + tokio::select! { + _ = rebalance_interval.tick() => {} + _ = rebalance_trigger_receiver.recv() => {} + } + if !shared_state.read().unwrap().one_snapshot_done { + continue; + } + if let Err(err) = rebalancer.zero_all_non_quote().await { + error!("failed to rebalance liqor: {:?}", err); + + // TODO FAS Are there other scenario where this sleep is useful ? + // Workaround: We really need a sequence enforcer in the liquidator since we don't want to + // accidentally send a similar tx again when we incorrectly believe an earlier one got forked + // off. For now, hard sleep on error to avoid the most frequent error cases. + tokio::time::sleep(Duration::from_secs(10)).await; + } + } + } + }) } #[derive(Default)] -struct SharedState { +pub struct SharedState { /// Addresses of the MangoAccounts belonging to the mango program. /// Needed to check health of them all when the cache updates. mango_accounts: HashSet, @@ -591,6 +602,18 @@ struct SharedState { /// Oldest chain event not processed yet oldest_chain_event_reception_time: Option, + + /// Liquidation candidates (locally identified as liquidatable) + liquidation_candidates_accounts: indexmap::set::IndexSet, + + /// Interesting TCS that should be triggered + interesting_tcs: indexmap::set::IndexSet<(Pubkey, u64, u64)>, + + /// Liquidation currently being processed by a worker + processing_liquidation: HashSet, + + // TCS currently being processed by a worker + processing_tcs: HashSet<(Pubkey, u64, u64)>, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -614,218 +637,6 @@ impl std::fmt::Display for LiqErrorType { } } -struct LiquidationState { - mango_client: Arc, - account_fetcher: Arc, - token_swap_info: Arc, - liquidation_config: liquidate::Config, - trigger_tcs_config: trigger_tcs::Config, - - errors: ErrorTracking, - oracle_errors: ErrorTracking, -} - -impl LiquidationState { - async fn maybe_liquidate_one<'b>( - &mut self, - accounts_iter: impl Iterator, - ) -> bool { - use rand::seq::SliceRandom; - - let mut accounts = accounts_iter.collect::>(); - { - let mut rng = rand::thread_rng(); - accounts.shuffle(&mut rng); - } - - for pubkey in accounts { - if self - .maybe_liquidate_and_log_error(pubkey) - .await - .unwrap_or(false) - { - return true; - } - } - - false - } - - async fn maybe_liquidate_and_log_error(&mut self, pubkey: &Pubkey) -> anyhow::Result { - let now = Instant::now(); - let error_tracking = &mut self.errors; - - // Skip a pubkey if there've been too many errors recently - if let Some(error_entry) = - error_tracking.had_too_many_errors(LiqErrorType::Liq, pubkey, now) - { - trace!( - %pubkey, - error_entry.count, - "skip checking account for liquidation, had errors recently", - ); - return Ok(false); - } - - let result = liquidate::maybe_liquidate_account( - &self.mango_client, - &self.account_fetcher, - pubkey, - &self.liquidation_config, - ) - .await; - - if let Err(err) = result.as_ref() { - if let Some((ti, ti_name)) = err.try_unwrap_oracle_error() { - if self - .oracle_errors - .had_too_many_errors(LiqErrorType::Liq, &ti, Instant::now()) - .is_none() - { - warn!( - "{:?} recording oracle error for token {} {}", - chrono::offset::Utc::now(), - ti_name, - ti - ); - } - - self.oracle_errors - .record(LiqErrorType::Liq, &ti, err.to_string()); - return result; - } - - // Keep track of pubkeys that had errors - error_tracking.record(LiqErrorType::Liq, pubkey, err.to_string()); - - // Not all errors need to be raised to the user's attention. - let mut is_error = true; - - // Simulation errors due to liqee precondition failures on the liquidation instructions - // will commonly happen if our liquidator is late or if there are chain forks. - match err.downcast_ref::() { - Some(MangoClientError::SendTransactionPreflightFailure { logs, .. }) => { - if logs.iter().any(|line| { - line.contains("HealthMustBeNegative") || line.contains("IsNotBankrupt") - }) { - is_error = false; - } - } - _ => {} - }; - if is_error { - error!("liquidating account {}: {:?}", pubkey, err); - } else { - trace!("liquidating account {}: {:?}", pubkey, err); - } - } else { - error_tracking.clear(LiqErrorType::Liq, pubkey); - } - - result - } - - async fn maybe_take_token_conditional_swap( - &mut self, - accounts_iter: impl Iterator, - ) -> anyhow::Result { - let accounts = accounts_iter.collect::>(); - - let now = Instant::now(); - let now_ts: u64 = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_secs(); - - let tcs_context = trigger_tcs::Context { - mango_client: self.mango_client.clone(), - account_fetcher: self.account_fetcher.clone(), - token_swap_info: self.token_swap_info.clone(), - config: self.trigger_tcs_config.clone(), - jupiter_quote_cache: Arc::new(trigger_tcs::JupiterQuoteCache::default()), - now_ts, - }; - - // Find interesting (pubkey, tcsid, volume) - let mut interesting_tcs = Vec::with_capacity(accounts.len()); - for pubkey in accounts.iter() { - if let Some(error_entry) = - self.errors - .had_too_many_errors(LiqErrorType::TcsCollectionHard, pubkey, now) - { - trace!( - %pubkey, - error_entry.count, - "skip checking account for tcs, had errors recently", - ); - continue; - } - - match tcs_context.find_interesting_tcs_for_account(pubkey) { - Ok(v) => { - self.errors.clear(LiqErrorType::TcsCollectionHard, pubkey); - if v.is_empty() { - self.errors - .clear(LiqErrorType::TcsCollectionPartial, pubkey); - self.errors.clear(LiqErrorType::TcsExecution, pubkey); - } else if v.iter().all(|it| it.is_ok()) { - self.errors - .clear(LiqErrorType::TcsCollectionPartial, pubkey); - } else { - for it in v.iter() { - if let Err(e) = it { - self.errors.record( - LiqErrorType::TcsCollectionPartial, - pubkey, - e.to_string(), - ); - } - } - } - interesting_tcs.extend(v.iter().filter_map(|it| it.as_ref().ok())); - } - Err(e) => { - self.errors - .record(LiqErrorType::TcsCollectionHard, pubkey, e.to_string()); - } - } - } - if interesting_tcs.is_empty() { - return Ok(false); - } - - let (txsigs, mut changed_pubkeys) = tcs_context - .execute_tcs(&mut interesting_tcs, &mut self.errors) - .await?; - for pubkey in changed_pubkeys.iter() { - self.errors.clear(LiqErrorType::TcsExecution, pubkey); - } - if txsigs.is_empty() { - return Ok(false); - } - changed_pubkeys.push(self.mango_client.mango_account_address); - - // Force a refresh of affected accounts - let slot = self - .account_fetcher - .transaction_max_slot(&txsigs) - .await - .context("transaction_max_slot")?; - if let Err(e) = self - .account_fetcher - .refresh_accounts_via_rpc_until_slot( - &changed_pubkeys, - slot, - self.liquidation_config.refresh_timeout, - ) - .await - { - info!(slot, "could not refresh after tcs execution: {}", e); - } - - Ok(true) - } -} - fn start_chain_data_metrics(chain: Arc>, metrics: &metrics::Metrics) { let mut interval = mango_v4_client::delay_interval(Duration::from_secs(600)); diff --git a/bin/liquidator/src/rebalance.rs b/bin/liquidator/src/rebalance.rs index 625e2a4703..6069117642 100644 --- a/bin/liquidator/src/rebalance.rs +++ b/bin/liquidator/src/rebalance.rs @@ -2,21 +2,27 @@ use itertools::Itertools; use mango_v4::accounts_zerocopy::KeyedAccountSharedData; use mango_v4::state::{ Bank, BookSide, MangoAccountValue, OracleAccountInfos, PerpMarket, PerpPosition, - PlaceOrderType, Side, TokenIndex, QUOTE_TOKEN_INDEX, + PlaceOrderType, Serum3MarketIndex, Side, TokenIndex, QUOTE_TOKEN_INDEX, }; use mango_v4_client::{ - chain_data, perp_pnl, swap, MangoClient, MangoGroupContext, PerpMarketContext, TokenContext, - TransactionBuilder, TransactionSize, + chain_data, perp_pnl, swap, MangoClient, MangoGroupContext, PerpMarketContext, + PreparedInstructions, Serum3MarketContext, TokenContext, TransactionBuilder, TransactionSize, }; use solana_client::nonblocking::rpc_client::RpcClient; use {fixed::types::I80F48, solana_sdk::pubkey::Pubkey}; +use fixed::types::extra::U48; +use fixed::FixedI128; +use mango_v4::accounts_ix::{Serum3OrderType, Serum3SelfTradeBehavior, Serum3Side}; +use mango_v4::serum3_cpi; +use mango_v4::serum3_cpi::{OpenOrdersAmounts, OpenOrdersSlim}; +use solana_sdk::account::ReadableAccount; use solana_sdk::signature::Signature; use std::collections::{HashMap, HashSet}; use std::future::Future; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tracing::*; #[derive(Clone)] @@ -24,6 +30,8 @@ pub struct Config { pub enabled: bool, /// Maximum slippage allowed in Jupiter pub slippage_bps: u64, + /// Maximum slippage from oracle price for limit orders + pub limit_order_distance_from_oracle_price_bps: u64, /// When closing borrows, the rebalancer can't close token positions exactly. /// Instead it purchases too much and then gets rid of the excess in a second step. /// If this is 1.05, then it'll swap borrow_value * 1.05 quote token into borrow token. @@ -35,6 +43,7 @@ pub struct Config { pub alternate_sanctum_route_tokens: Vec, pub allow_withdraws: bool, pub use_sanctum: bool, + pub use_limit_order: bool, } impl Config { @@ -141,6 +150,7 @@ impl Rebalancer { /// Otherwise use the best of 4. async fn token_swap_buy( &self, + account: &MangoAccountValue, output_mint: Pubkey, in_amount_quote: u64, ) -> anyhow::Result<(Signature, swap::Quote)> { @@ -182,8 +192,13 @@ impl Rebalancer { let results = futures::future::join_all(jobs).await; let routes: Vec<_> = results.into_iter().filter_map(|v| v.ok()).collect_vec(); + let seq_check_ix = self + .mango_client + .sequence_check_instruction(&self.mango_account_address, account) + .await?; + let best_route_res = self - .determine_best_swap_tx(routes, quote_mint, output_mint) + .determine_best_swap_tx(routes, quote_mint, output_mint, seq_check_ix.clone()) .await; let (tx_builder, route) = match best_route_res { @@ -206,6 +221,7 @@ impl Rebalancer { }, quote_mint, output_mint, + seq_check_ix, ) .await? } @@ -226,6 +242,7 @@ impl Rebalancer { /// Otherwise use the best of 4. async fn token_swap_sell( &self, + account: &MangoAccountValue, input_mint: Pubkey, in_amount: u64, ) -> anyhow::Result<(Signature, swap::Quote)> { @@ -256,8 +273,13 @@ impl Rebalancer { let results = futures::future::join_all(jobs).await; let routes: Vec<_> = results.into_iter().filter_map(|v| v.ok()).collect_vec(); + let seq_check_ix = self + .mango_client + .sequence_check_instruction(&self.mango_account_address, account) + .await?; + let best_route_res = self - .determine_best_swap_tx(routes, input_mint, quote_mint) + .determine_best_swap_tx(routes, input_mint, quote_mint, seq_check_ix.clone()) .await; let (tx_builder, route) = match best_route_res { @@ -278,6 +300,7 @@ impl Rebalancer { }, input_mint, quote_mint, + seq_check_ix, ) .await? } @@ -328,6 +351,7 @@ impl Rebalancer { quote_fetcher: impl Fn(&u16) -> anyhow::Result, original_input_mint: Pubkey, original_output_mint: Pubkey, + seq_check_ix: PreparedInstructions, ) -> anyhow::Result<(TransactionBuilder, swap::Quote)> { let mut alt_jobs = vec![]; for in_token_index in &self.config.alternate_jupiter_route_tokens { @@ -337,7 +361,12 @@ impl Rebalancer { let alt_routes: Vec<_> = alt_results.into_iter().filter_map(|v| v.ok()).collect_vec(); let best_route = self - .determine_best_swap_tx(alt_routes, original_input_mint, original_output_mint) + .determine_best_swap_tx( + alt_routes, + original_input_mint, + original_output_mint, + seq_check_ix, + ) .await?; Ok(best_route) } @@ -347,6 +376,7 @@ impl Rebalancer { mut routes: Vec, original_input_mint: Pubkey, original_output_mint: Pubkey, + seq_check_ix: PreparedInstructions, ) -> anyhow::Result<(TransactionBuilder, swap::Quote)> { let mut prices = HashMap::::new(); let mut get_or_fetch_price = |m| { @@ -391,11 +421,13 @@ impl Rebalancer { }); for route in routes { - let builder = self + let mut builder = self .mango_client .swap() .prepare_swap_transaction(&route) .await?; + builder.append(seq_check_ix.clone()); + let tx_size = builder.transaction_size()?; if tx_size.is_within_limit() { return Ok((builder, route.clone())); @@ -426,6 +458,7 @@ impl Rebalancer { } async fn rebalance_tokens(&self) -> anyhow::Result<()> { + self.close_and_settle_all_openbook_orders().await?; let account = self.mango_account()?; // TODO: configurable? @@ -448,12 +481,24 @@ impl Rebalancer { // Imagine SOL at 0.04 USDC-native per SOL-native: Any amounts below 25 SOL-native // would not be worth a single USDC-native. // - // To avoid errors, we consider all amounts below 1000 * (1/oracle) dust and don't try + // To avoid errors, we consider all amounts below 2 * (1/oracle) dust and don't try // to sell them. Instead they will be withdrawn at the end. // Purchases will aim to purchase slightly more than is needed, such that we can // again withdraw the dust at the end. - // 1000 USD-native is $0.001; note that delegates are allowed to withdraw up to DELEGATE_WITHDRAW_MAX ($0.1) - let dust_threshold = I80F48::from(1_000) / token_price; + let dust_threshold_res = if self.config.use_limit_order { + self.dust_threshold_for_limit_order(token) + .await + .map(|x| I80F48::from(x)) + } else { + Ok(I80F48::from(2) / token_price) + }; + + let Ok(dust_threshold) = dust_threshold_res + else { + let e = dust_threshold_res.unwrap_err(); + error!("Cannot rebalance token {}, probably missing USDC market ? - error: {}", token.name, e); + continue; + }; // Some rebalancing can actually change non-USDC positions (rebalancing to SOL) // So re-fetch the current token position amount @@ -467,60 +512,29 @@ impl Rebalancer { }; let mut amount = fresh_amount()?; - trace!(token_index, %amount, %dust_threshold, "checking"); - if amount < 0 { - // Buy - let buy_amount = - amount.abs().ceil() + (dust_threshold - I80F48::ONE).max(I80F48::ZERO); - let input_amount = - buy_amount * token_price * I80F48::from_num(self.config.borrow_settle_excess); - let (txsig, route) = self - .token_swap_buy(token_mint, input_amount.to_num()) - .await?; - let in_token = self - .mango_client - .context - .token_by_mint(&route.input_mint) - .unwrap(); - info!( - %txsig, - "bought {} {} for {} {}", - token.native_to_ui(I80F48::from(route.out_amount)), - token.name, - in_token.native_to_ui(I80F48::from(route.in_amount)), - in_token.name, - ); - if !self.refresh_mango_account_after_tx(txsig).await? { - return Ok(()); - } - amount = fresh_amount()?; - } + trace!(token_index, token.name, %amount, %dust_threshold, "checking"); - if amount > dust_threshold { - // Sell - - // To avoid creating a borrow when paying flash loan fees, sell only a fraction - let input_amount = amount * I80F48::from_num(0.99); - let (txsig, route) = self - .token_swap_sell(token_mint, input_amount.to_num::()) + if self.config.use_limit_order { + self.unwind_using_limit_orders( + &account, + token, + token_price, + dust_threshold, + amount, + ) + .await?; + } else { + amount = self + .unwind_using_swap( + &account, + token, + token_mint, + token_price, + dust_threshold, + fresh_amount, + amount, + ) .await?; - let out_token = self - .mango_client - .context - .token_by_mint(&route.output_mint) - .unwrap(); - info!( - %txsig, - "sold {} {} for {} {}", - token.native_to_ui(I80F48::from(route.in_amount)), - token.name, - out_token.native_to_ui(I80F48::from(route.out_amount)), - out_token.name, - ); - if !self.refresh_mango_account_after_tx(txsig).await? { - return Ok(()); - } - amount = fresh_amount()?; } // Any remainder that could not be sold just gets withdrawn to ensure the @@ -555,14 +569,299 @@ impl Rebalancer { Ok(()) } + async fn dust_threshold_for_limit_order(&self, token: &TokenContext) -> anyhow::Result { + let (_, market) = self + .mango_client + .context + .serum3_markets + .iter() + .find(|(_, context)| { + context.base_token_index == token.token_index + && context.quote_token_index == QUOTE_TOKEN_INDEX + }) + .ok_or(anyhow::format_err!( + "could not find market for token {}", + token.name + ))?; + + Ok(market.coin_lot_size - 1) + } + + async fn unwind_using_limit_orders( + &self, + account: &Box, + token: &TokenContext, + token_price: I80F48, + dust_threshold: FixedI128, + native_amount: I80F48, + ) -> anyhow::Result<()> { + if native_amount >= 0 && native_amount < dust_threshold { + return Ok(()); + } + + let (market_index, market) = self + .mango_client + .context + .serum3_markets + .iter() + .find(|(_, context)| { + context.base_token_index == token.token_index + && context.quote_token_index == QUOTE_TOKEN_INDEX + }) + .ok_or(anyhow::format_err!( + "could not find market for token {}", + token.name + ))?; + + let side = if native_amount < 0 { + Serum3Side::Bid + } else { + Serum3Side::Ask + }; + + let distance_from_oracle_price_bp = + I80F48::from_num(self.config.limit_order_distance_from_oracle_price_bps) + * match side { + Serum3Side::Bid => 1, + Serum3Side::Ask => -1, + }; + let price_adjustment_factor = + (I80F48::from_num(10_000) + distance_from_oracle_price_bp) / I80F48::from_num(10_000); + + let limit_price = + (token_price * price_adjustment_factor * I80F48::from_num(market.coin_lot_size)) + .to_num::() + / market.pc_lot_size; + let mut max_base_lots = + (native_amount.abs() / I80F48::from_num(market.coin_lot_size)).to_num::(); + + debug!( + side = match side { + Serum3Side::Bid => "Buy", + Serum3Side::Ask => "Sell", + }, + token = token.name, + oracle_price = token_price.to_num::(), + price_adjustment_factor = price_adjustment_factor.to_num::(), + coin_lot_size = market.coin_lot_size, + pc_lot_size = market.pc_lot_size, + limit_price, + native_amount = native_amount.to_num::(), + max_base_lots = max_base_lots, + "building order for rebalancing" + ); + + // Try to buy enough to close the borrow + if max_base_lots == 0 && native_amount < 0 { + info!( + "Buying a whole lot for token {} to cover borrow of {}", + token.name, native_amount + ); + max_base_lots = 1; + } + + if max_base_lots == 0 { + warn!("Could not rebalance token '{}' (native_amount={}) using limit order, below base lot size", token.name, native_amount); + return Ok(()); + } + + let mut account = account.clone(); + let create_or_replace_account_ixs = self + .mango_client + .serum3_create_or_replace_account_instruction(&mut account, *market_index, side) + .await?; + let cancel_ixs = + self.mango_client + .serum3_cancel_all_orders_instruction(&account, *market_index, 10)?; + let place_order_ixs = self + .mango_client + .serum3_place_order_instruction( + &account, + *market_index, + side, + limit_price, + max_base_lots, + ((limit_price * max_base_lots * market.pc_lot_size) as f64 * 1.01) as u64, + Serum3SelfTradeBehavior::CancelProvide, + Serum3OrderType::Limit, + SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as u64, + 10, + ) + .await?; + + let seq_check_ixs = self + .mango_client + .sequence_check_instruction(&self.mango_account_address, &account) + .await?; + + let mut ixs = PreparedInstructions::new(); + ixs.append(create_or_replace_account_ixs); + ixs.append(cancel_ixs); + ixs.append(place_order_ixs); + ixs.append(seq_check_ixs); + + let txsig = self + .mango_client + .send_and_confirm_authority_tx(ixs.to_instructions()) + .await?; + + info!( + %txsig, + "placed order for {} {} at price = {}", + token.native_to_ui(I80F48::from(native_amount)), + token.name, + limit_price, + ); + if !self.refresh_mango_account_after_tx(txsig).await? { + return Ok(()); + } + + Ok(()) + } + + async fn close_and_settle_all_openbook_orders(&self) -> anyhow::Result<()> { + let account = self.mango_account()?; + + for x in Self::shuffle(account.active_serum3_orders()) { + let token = self.mango_client.context.token(x.base_token_index); + let quote = self.mango_client.context.token(x.quote_token_index); + let market_index = x.market_index; + let market = self + .mango_client + .context + .serum3_markets + .get(&market_index) + .expect("no openbook market found"); + self.close_and_settle_openbook_orders(&account, token, &market_index, market, quote) + .await?; + } + Ok(()) + } + + /// This will only settle funds when there is no more active orders (avoid doing too many settle tx) + async fn close_and_settle_openbook_orders( + &self, + account: &Box, + token: &TokenContext, + market_index: &Serum3MarketIndex, + market: &Serum3MarketContext, + quote: &TokenContext, + ) -> anyhow::Result<()> { + let Ok(open_orders) = account.serum3_orders(*market_index).map(|x| x.open_orders) + else { + return Ok(()); + }; + + let oo_acc = self.account_fetcher.fetch_raw(&open_orders)?; + let oo = serum3_cpi::load_open_orders_bytes(oo_acc.data())?; + let oo_slim = OpenOrdersSlim::from_oo(oo); + + if oo_slim.native_base_reserved() != 0 || oo_slim.native_quote_reserved() != 0 { + return Ok(()); + } + + let settle_ixs = + self.mango_client + .serum3_settle_funds_instruction(market, token, quote, open_orders); + + let close_ixs = self + .mango_client + .serum3_close_open_orders_instruction(*market_index); + + let mut ixs = PreparedInstructions::new(); + ixs.append(close_ixs); + ixs.append(settle_ixs); + + let txsig = self + .mango_client + .send_and_confirm_authority_tx(ixs.to_instructions()) + .await?; + + info!( + %txsig, + "settle spot funds for {}", + token.name, + ); + if !self.refresh_mango_account_after_tx(txsig).await? { + return Ok(()); + } + + Ok(()) + } + + async fn unwind_using_swap( + &self, + account: &Box, + token: &TokenContext, + token_mint: Pubkey, + token_price: I80F48, + dust_threshold: FixedI128, + fresh_amount: impl Fn() -> anyhow::Result, + amount: I80F48, + ) -> anyhow::Result { + if amount < 0 { + // Buy + let buy_amount = amount.abs().ceil() + (dust_threshold - I80F48::ONE).max(I80F48::ZERO); + let input_amount = + buy_amount * token_price * I80F48::from_num(self.config.borrow_settle_excess); + let (txsig, route) = self + .token_swap_buy(&account, token_mint, input_amount.to_num()) + .await?; + let in_token = self + .mango_client + .context + .token_by_mint(&route.input_mint) + .unwrap(); + info!( + %txsig, + "bought {} {} for {} {}", + token.native_to_ui(I80F48::from(route.out_amount)), + token.name, + in_token.native_to_ui(I80F48::from(route.in_amount)), + in_token.name, + ); + if !self.refresh_mango_account_after_tx(txsig).await? { + return Ok(amount); + } + } + + if amount > dust_threshold { + // Sell + + // To avoid creating a borrow when paying flash loan fees, sell only a fraction + let input_amount = amount * I80F48::from_num(0.99); + let (txsig, route) = self + .token_swap_sell(&account, token_mint, input_amount.to_num::()) + .await?; + let out_token = self + .mango_client + .context + .token_by_mint(&route.output_mint) + .unwrap(); + info!( + %txsig, + "sold {} {} for {} {}", + token.native_to_ui(I80F48::from(route.in_amount)), + token.name, + out_token.native_to_ui(I80F48::from(route.out_amount)), + out_token.name, + ); + if !self.refresh_mango_account_after_tx(txsig).await? { + return Ok(amount); + } + } + + Ok(fresh_amount()?) + } + #[instrument( - skip_all, - fields( - perp_market_name = perp.name, - base_lots = perp_position.base_position_lots(), - effective_lots = perp_position.effective_base_position_lots(), - quote_native = %perp_position.quote_position_native() - ) + skip_all, + fields( + perp_market_name = perp.name, + base_lots = perp_position.base_position_lots(), + effective_lots = perp_position.effective_base_position_lots(), + quote_native = %perp_position.quote_position_native() + ) )] async fn rebalance_perp( &self, @@ -624,7 +923,7 @@ impl Rebalancer { return Ok(true); } - let ixs = self + let mut ixs = self .mango_client .perp_place_order_instruction( account, @@ -642,6 +941,12 @@ impl Rebalancer { ) .await?; + let seq_check_ix = self + .mango_client + .sequence_check_instruction(&self.mango_account_address, account) + .await?; + ixs.append(seq_check_ix); + let tx_builder = TransactionBuilder { instructions: ixs.to_instructions(), signers: vec![self.mango_client.authority.clone()], diff --git a/bin/liquidator/src/tcs_state.rs b/bin/liquidator/src/tcs_state.rs new file mode 100644 index 0000000000..6434a212e4 --- /dev/null +++ b/bin/liquidator/src/tcs_state.rs @@ -0,0 +1,218 @@ +use crate::cli_args::Cli; +use crate::metrics::Metrics; +use crate::token_swap_info::TokenSwapInfoUpdater; +use crate::{trigger_tcs, LiqErrorType, SharedState}; +use anchor_lang::prelude::Pubkey; +use anyhow::Context; +use itertools::Itertools; +use mango_v4_client::error_tracking::ErrorTracking; +use mango_v4_client::{chain_data, MangoClient}; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; +use tokio::task::JoinHandle; +use tracing::{error, info, trace}; + +pub fn spawn_tcs_job( + cli: &Cli, + shared_state: &Arc>, + tx_trigger_sender: async_channel::Sender<()>, + mut tcs: Box, + metrics: &Metrics, +) -> JoinHandle<()> { + tokio::spawn({ + let mut interval = + mango_v4_client::delay_interval(Duration::from_millis(cli.tcs_check_interval_ms)); + let mut tcs_start_time = None; + let mut metric_tcs_start_end = metrics.register_latency("tcs_start_end".into()); + let shared_state = shared_state.clone(); + + async move { + loop { + interval.tick().await; + + let account_addresses = { + let state = shared_state.write().unwrap(); + if !state.one_snapshot_done { + continue; + } + state.mango_accounts.iter().cloned().collect_vec() + }; + + tcs.errors.write().unwrap().update(); + + tcs_start_time = Some(tcs_start_time.unwrap_or(Instant::now())); + + let found_candidates = tcs + .find_candidates(account_addresses.iter(), |candidate| { + if shared_state + .write() + .unwrap() + .interesting_tcs + .insert(candidate) + { + tx_trigger_sender.try_send(())?; + } + + Ok(()) + }) + .await + .unwrap_or_else(|err| { + error!("error during find_candidate: {err}"); + 0 + }); + + if found_candidates > 0 { + tracing::debug!("found {} candidates for triggering", found_candidates); + } + + let current_time = Instant::now(); + metric_tcs_start_end.push(current_time - tcs_start_time.unwrap()); + tcs_start_time = None; + } + } + }) +} + +#[derive(Clone)] +pub struct TcsState { + pub mango_client: Arc, + pub account_fetcher: Arc, + pub token_swap_info: Arc, + pub trigger_tcs_config: trigger_tcs::Config, + + pub errors: Arc>>, +} + +impl TcsState { + async fn find_candidates( + &mut self, + accounts_iter: impl Iterator, + action: impl Fn((Pubkey, u64, u64)) -> anyhow::Result<()>, + ) -> anyhow::Result { + let accounts = accounts_iter.collect::>(); + + let now = Instant::now(); + let now_ts: u64 = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs(); + + let tcs_context = trigger_tcs::Context { + mango_client: self.mango_client.clone(), + account_fetcher: self.account_fetcher.clone(), + token_swap_info: self.token_swap_info.clone(), + config: self.trigger_tcs_config.clone(), + jupiter_quote_cache: Arc::new(trigger_tcs::JupiterQuoteCache::default()), + now_ts, + }; + + let mut found_counter = 0; + + // Find interesting (pubkey, tcsid, volume) + for pubkey in accounts.iter() { + if let Some(error_entry) = self.errors.read().unwrap().had_too_many_errors( + LiqErrorType::TcsCollectionHard, + pubkey, + now, + ) { + trace!( + %pubkey, + error_entry.count, + "skip checking account for tcs, had errors recently", + ); + continue; + } + + let candidates = tcs_context.find_interesting_tcs_for_account(pubkey); + let mut error_guard = self.errors.write().unwrap(); + + match candidates { + Ok(v) => { + error_guard.clear(LiqErrorType::TcsCollectionHard, pubkey); + if v.is_empty() { + error_guard.clear(LiqErrorType::TcsCollectionPartial, pubkey); + error_guard.clear(LiqErrorType::TcsExecution, pubkey); + } else if v.iter().all(|it| it.is_ok()) { + error_guard.clear(LiqErrorType::TcsCollectionPartial, pubkey); + } else { + for it in v.iter() { + if let Err(e) = it { + error_guard.record( + LiqErrorType::TcsCollectionPartial, + pubkey, + e.to_string(), + ); + } + } + } + for interesting_candidate_res in v.iter() { + if let Ok(interesting_candidate) = interesting_candidate_res { + action(*interesting_candidate).expect("failed to send TCS candidate"); + found_counter += 1; + } + } + } + Err(e) => { + error_guard.record(LiqErrorType::TcsCollectionHard, pubkey, e.to_string()); + } + } + } + + return Ok(found_counter); + } + + pub async fn maybe_take_token_conditional_swap( + &mut self, + mut interesting_tcs: Vec<(Pubkey, u64, u64)>, + ) -> anyhow::Result { + let now_ts: u64 = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs(); + + let tcs_context = trigger_tcs::Context { + mango_client: self.mango_client.clone(), + account_fetcher: self.account_fetcher.clone(), + token_swap_info: self.token_swap_info.clone(), + config: self.trigger_tcs_config.clone(), + jupiter_quote_cache: Arc::new(trigger_tcs::JupiterQuoteCache::default()), + now_ts, + }; + + if interesting_tcs.is_empty() { + return Ok(false); + } + + let (txsigs, mut changed_pubkeys) = tcs_context + .execute_tcs(&mut interesting_tcs, self.errors.clone()) + .await?; + for pubkey in changed_pubkeys.iter() { + self.errors + .write() + .unwrap() + .clear(LiqErrorType::TcsExecution, pubkey); + } + if txsigs.is_empty() { + return Ok(false); + } + changed_pubkeys.push(self.mango_client.mango_account_address); + + // Force a refresh of affected accounts + let slot = self + .account_fetcher + .transaction_max_slot(&txsigs) + .await + .context("transaction_max_slot")?; + if let Err(e) = self + .account_fetcher + .refresh_accounts_via_rpc_until_slot( + &changed_pubkeys, + slot, + self.trigger_tcs_config.refresh_timeout, + ) + .await + { + info!(slot, "could not refresh after tcs execution: {}", e); + } + + Ok(true) + } +} diff --git a/bin/liquidator/src/trigger_tcs.rs b/bin/liquidator/src/trigger_tcs.rs index 04d802f3fd..db80cbb99c 100644 --- a/bin/liquidator/src/trigger_tcs.rs +++ b/bin/liquidator/src/trigger_tcs.rs @@ -1,4 +1,5 @@ use std::collections::HashSet; +use std::time::Duration; use std::{ collections::HashMap, pin::Pin, @@ -15,6 +16,7 @@ use mango_v4::{ use mango_v4_client::{chain_data, swap, MangoClient, TransactionBuilder}; use anyhow::Context as AnyhowContext; +use mango_v4::accounts_ix::HealthCheckKind::MaintRatio; use solana_sdk::signature::Signature; use tracing::*; use {fixed::types::I80F48, solana_sdk::pubkey::Pubkey}; @@ -55,6 +57,7 @@ pub enum Mode { #[derive(Clone)] pub struct Config { + pub refresh_timeout: Duration, pub min_health_ratio: f64, pub max_trigger_quote_amount: u64, pub compute_limit_for_trigger: u32, @@ -999,7 +1002,7 @@ impl Context { pub async fn execute_tcs( &self, tcs: &mut [(Pubkey, u64, u64)], - error_tracking: &mut ErrorTracking, + error_tracking: Arc>>, ) -> anyhow::Result<(Vec, Vec)> { use rand::distributions::{Distribution, WeightedError, WeightedIndex}; @@ -1048,7 +1051,7 @@ impl Context { } Err(e) => { trace!(%result.pubkey, "preparation error {:?}", e); - error_tracking.record( + error_tracking.write().unwrap().record( LiqErrorType::TcsExecution, &result.pubkey, e.to_string(), @@ -1092,7 +1095,7 @@ impl Context { }; // start the new one - if let Some(job) = self.prepare_job(&pubkey, tcs_id, volume, error_tracking) { + if let Some(job) = self.prepare_job(&pubkey, tcs_id, volume, error_tracking.clone()) { pending_volume += volume; pending.push(job); } @@ -1129,7 +1132,11 @@ impl Context { Ok(v) => Some((pubkey, v)), Err(err) => { trace!(%pubkey, "execution error {:?}", err); - error_tracking.record(LiqErrorType::TcsExecution, &pubkey, err.to_string()); + error_tracking.write().unwrap().record( + LiqErrorType::TcsExecution, + &pubkey, + err.to_string(), + ); None } }); @@ -1144,12 +1151,14 @@ impl Context { pubkey: &Pubkey, tcs_id: u64, volume: u64, - error_tracking: &ErrorTracking, + error_tracking: Arc>>, ) -> Option + Send>>> { // Skip a pubkey if there've been too many errors recently - if let Some(error_entry) = - error_tracking.had_too_many_errors(LiqErrorType::TcsExecution, pubkey, Instant::now()) - { + if let Some(error_entry) = error_tracking.read().unwrap().had_too_many_errors( + LiqErrorType::TcsExecution, + pubkey, + Instant::now(), + ) { trace!( "skip checking for tcs on account {pubkey}, had {} errors recently", error_entry.count @@ -1224,6 +1233,27 @@ impl Context { .instructions .append(&mut trigger_ixs.instructions); + let (_, tcs) = liqee.token_conditional_swap_by_id(pending.tcs_id)?; + let affected_tokens = allowed_tokens + .iter() + .chain(&[tcs.buy_token_index, tcs.sell_token_index]) + .copied() + .collect_vec(); + let liqor = &self.mango_client.mango_account().await?; + tx_builder.instructions.append( + &mut self + .mango_client + .health_check_instruction( + liqor, + self.config.min_health_ratio, + affected_tokens, + vec![], + MaintRatio, + ) + .await? + .instructions, + ); + let txsig = tx_builder .send_and_confirm(&self.mango_client.client) .await?; diff --git a/bin/liquidator/src/tx_sender.rs b/bin/liquidator/src/tx_sender.rs new file mode 100644 index 0000000000..05027f6784 --- /dev/null +++ b/bin/liquidator/src/tx_sender.rs @@ -0,0 +1,241 @@ +use crate::liquidation_state::LiquidationState; +use crate::tcs_state::TcsState; +use crate::SharedState; +use anchor_lang::prelude::Pubkey; +use async_channel::{Receiver, Sender}; +use mango_v4_client::AsyncChannelSendUnlessFull; +use std::sync::{Arc, RwLock}; +use tokio::task::JoinHandle; +use tracing::{debug, error, trace}; + +enum WorkerTask { + Liquidation(Pubkey), + Tcs(Vec<(Pubkey, u64, u64)>), + + // Given two workers: #0=LIQ_only, #1=LIQ+TCS + // If they are both busy, and the scanning jobs find a new TCS and a new LIQ candidates and enqueue them in the channel + // Then if #1 wake up first, it will consume the LIQ candidate (LIQ always have priority) + // Then when #0 wake up, it will not find any LIQ candidate, and would not do anything (it won't take a TCS) + // But if we do nothing, #1 would never wake up again (no new task in channel) + // So we use this `GiveUpTcs` that will be handled by #0 by queuing a new signal the channel and will wake up #1 again + GiveUpTcs, + + // Can happen if TCS is batched (2 TCS enqueued, 2 workers waken, but first one take both tasks) + NoWork, +} + +pub fn spawn_tx_senders_job( + max_parallel_operations: u64, + enable_liquidation: bool, + tx_liq_trigger_receiver: Receiver<()>, + tx_tcs_trigger_receiver: Receiver<()>, + tx_tcs_trigger_sender: Sender<()>, + rebalance_trigger_sender: Sender<()>, + shared_state: Arc>, + liquidation: Box, + tcs: Box, +) -> Vec> { + if max_parallel_operations < 1 { + error!("max_parallel_operations must be >= 1"); + std::process::exit(1) + } + + let reserve_one_worker_for_liquidation = max_parallel_operations > 1 && enable_liquidation; + + let workers: Vec> = (0..max_parallel_operations) + .map(|worker_id| { + tokio::spawn({ + let shared_state = shared_state.clone(); + let receiver_liq = tx_liq_trigger_receiver.clone(); + let receiver_tcs = tx_tcs_trigger_receiver.clone(); + let sender_tcs = tx_tcs_trigger_sender.clone(); + let rebalance_trigger_sender = rebalance_trigger_sender.clone(); + let liquidation = liquidation.clone(); + let tcs = tcs.clone(); + async move { + worker_loop( + shared_state, + receiver_liq, + receiver_tcs, + sender_tcs, + rebalance_trigger_sender, + liquidation, + tcs, + worker_id, + reserve_one_worker_for_liquidation && worker_id == 0, + ) + .await; + } + }) + }) + .collect(); + + workers +} + +async fn worker_loop( + shared_state: Arc>, + liq_receiver: Receiver<()>, + tcs_receiver: Receiver<()>, + tcs_sender: Sender<()>, + rebalance_trigger_sender: Sender<()>, + mut liquidation: Box, + mut tcs: Box, + id: u64, + only_liquidation: bool, +) { + loop { + debug!( + "Worker #{} waiting for task (only_liq={})", + id, only_liquidation + ); + + let _ = if only_liquidation { + liq_receiver.recv().await.expect("receive failed") + } else { + tokio::select!( + _ = liq_receiver.recv() => {}, + _ = tcs_receiver.recv() => {}, + ) + }; + + // a task must be available to process + // find it in global shared state, and mark it as processing + let task = worker_pull_task(&shared_state, id, only_liquidation) + .expect("Worker woke up but has nothing to do"); + + // execute the task + let need_rebalancing = match &task { + WorkerTask::Liquidation(l) => worker_execute_liquidation(&mut liquidation, *l).await, + WorkerTask::Tcs(t) => worker_execute_tcs(&mut tcs, t.clone()).await, + WorkerTask::GiveUpTcs => worker_give_up_tcs(&tcs_sender).await, + WorkerTask::NoWork => false, + }; + + if need_rebalancing { + rebalance_trigger_sender.send_unless_full(()).unwrap(); + } + + // remove from shared state + worker_finalize_task(&shared_state, id, task, need_rebalancing); + } +} + +async fn worker_give_up_tcs(sender: &Sender<()>) -> bool { + sender.send(()).await.expect("sending task failed"); + false +} + +async fn worker_execute_tcs(tcs: &mut Box, candidates: Vec<(Pubkey, u64, u64)>) -> bool { + tcs.maybe_take_token_conditional_swap(candidates) + .await + .unwrap_or(false) +} + +async fn worker_execute_liquidation( + liquidation: &mut Box, + candidate: Pubkey, +) -> bool { + liquidation + .maybe_liquidate_and_log_error(&candidate) + .await + .unwrap_or(false) +} + +fn worker_pull_task( + shared_state: &Arc>, + id: u64, + only_liquidation: bool, +) -> anyhow::Result { + let mut writer = shared_state.write().unwrap(); + + // print out list of all task for debugging + for x in &writer.liquidation_candidates_accounts { + if !writer.processing_liquidation.contains(x) { + trace!(" - LIQ {:?}", x); + } + } + + // next liq task to execute + if let Some(liq_candidate) = writer + .liquidation_candidates_accounts + .iter() + .find(|x| !writer.processing_liquidation.contains(x)) + .copied() + { + debug!("worker #{} got a liq candidate -> {}", id, liq_candidate); + writer.processing_liquidation.insert(liq_candidate); + return Ok(WorkerTask::Liquidation(liq_candidate)); + } + + let tcs_todo = writer.interesting_tcs.len() - writer.processing_tcs.len(); + + if only_liquidation { + debug!("worker #{} giving up TCS (todo count: {})", id, tcs_todo); + return Ok(WorkerTask::GiveUpTcs); + } + + for x in &writer.interesting_tcs { + if !writer.processing_tcs.contains(x) { + trace!(" - TCS {:?}", x); + } + } + + // next tcs task to execute + let max_tcs_batch_size = 20; + let tcs_candidates: Vec<(Pubkey, u64, u64)> = writer + .interesting_tcs + .iter() + .filter(|x| !writer.processing_tcs.contains(x)) + .take(max_tcs_batch_size) + .copied() + .collect(); + + for tcs_candidate in &tcs_candidates { + debug!( + "worker #{} got a tcs candidate -> {:?} (out of {})", + id, + tcs_candidate, + writer.interesting_tcs.len() + ); + writer.processing_tcs.insert(tcs_candidate.clone()); + } + + if tcs_candidates.len() > 0 { + Ok(WorkerTask::Tcs(tcs_candidates)) + } else { + debug!("worker #{} got nothing", id); + Ok(WorkerTask::NoWork) + } +} + +fn worker_finalize_task( + shared_state: &Arc>, + id: u64, + task: WorkerTask, + done: bool, +) { + let mut writer = shared_state.write().unwrap(); + match task { + WorkerTask::Liquidation(liq) => { + debug!( + "worker #{} - checked liq {:?} with success ? {}", + id, liq, done + ); + writer.liquidation_candidates_accounts.shift_remove(&liq); + writer.processing_liquidation.remove(&liq); + } + WorkerTask::Tcs(tcs_list) => { + for tcs in tcs_list { + debug!( + "worker #{} - checked tcs {:?} with success ? {}", + id, tcs, done + ); + writer.interesting_tcs.shift_remove(&tcs); + writer.processing_tcs.remove(&tcs); + } + } + WorkerTask::GiveUpTcs => {} + WorkerTask::NoWork => {} + } +} diff --git a/lib/client/src/client.rs b/lib/client/src/client.rs index 52e84b5913..9f451fe2a8 100644 --- a/lib/client/src/client.rs +++ b/lib/client/src/client.rs @@ -24,17 +24,17 @@ use mango_v4::accounts_zerocopy::KeyedAccountSharedData; use mango_v4::health::HealthCache; use mango_v4::state::{ Bank, Group, MangoAccountValue, OracleAccountInfos, PerpMarket, PerpMarketIndex, - PlaceOrderType, SelfTradeBehavior, Serum3MarketIndex, Side, TokenIndex, INSURANCE_TOKEN_INDEX, + PlaceOrderType, SelfTradeBehavior, Serum3MarketIndex, Side, TokenIndex, }; use crate::confirm_transaction::{wait_for_transaction_confirmation, RpcConfirmTransactionConfig}; use crate::context::MangoGroupContext; use crate::gpa::{fetch_anchor_account, fetch_mango_accounts}; -use crate::health_cache; use crate::priority_fees::{FixedPriorityFeeProvider, PriorityFeeProvider}; use crate::util; use crate::util::PreparedInstructions; use crate::{account_fetcher::*, swap}; +use crate::{health_cache, Serum3MarketContext, TokenContext}; use solana_address_lookup_table_program::state::AddressLookupTable; use solana_client::nonblocking::rpc_client::RpcClient as RpcClientAsync; use solana_client::rpc_client::SerializableTransaction; @@ -623,6 +623,34 @@ impl MangoClient { Ok(ixs) } + /// Avoid executing same instruction multiple time + pub async fn sequence_check_instruction( + &self, + mango_account_address: &Pubkey, + mango_account: &MangoAccountValue, + ) -> anyhow::Result { + let ixs = PreparedInstructions::from_vec( + vec![Instruction { + program_id: mango_v4::id(), + accounts: { + anchor_lang::ToAccountMetas::to_account_metas( + &mango_v4::accounts::SequenceCheck { + group: self.group(), + account: *mango_account_address, + owner: mango_account.fixed.owner, + }, + None, + ) + }, + data: anchor_lang::InstructionData::data(&mango_v4::instruction::SequenceCheck { + expected_sequence_number: mango_account.fixed.sequence_number, + }), + }], + self.context.compute_estimates.cu_for_sequence_check, + ); + Ok(ixs) + } + /// Creates token withdraw instructions for the MangoClient's account/owner. /// The `account` state is passed in separately so changes during the tx can be /// accounted for when deriving health accounts. @@ -1150,6 +1178,18 @@ impl MangoClient { let account = self.mango_account().await?; let open_orders = account.serum3_orders(market_index).unwrap().open_orders; + let ix = self.serum3_settle_funds_instruction(s3, base, quote, open_orders); + self.send_and_confirm_authority_tx(ix.to_instructions()) + .await + } + + pub fn serum3_settle_funds_instruction( + &self, + s3: &Serum3MarketContext, + base: &TokenContext, + quote: &TokenContext, + open_orders: Pubkey, + ) -> PreparedInstructions { let ix = Instruction { program_id: mango_v4::id(), accounts: anchor_lang::ToAccountMetas::to_account_metas( @@ -1182,7 +1222,11 @@ impl MangoClient { fees_to_dao: true, }), }; - self.send_and_confirm_authority_tx(vec![ix]).await + + PreparedInstructions::from_single( + ix, + self.context.compute_estimates.cu_per_mango_instruction, + ) } pub fn serum3_cancel_all_orders_instruction( @@ -1763,13 +1807,13 @@ impl MangoClient { let mango_account = &self.mango_account().await?; let perp = self.context.perp(market_index); let settle_token_info = self.context.token(perp.settle_token_index); - let insurance_token_info = self.context.token(INSURANCE_TOKEN_INDEX); + let insurance_token_info = self.context.token_by_mint(&group.insurance_mint)?; let (health_remaining_ams, health_cu) = self .derive_health_check_remaining_account_metas_two_accounts( mango_account, liqee.1, - &[INSURANCE_TOKEN_INDEX], + &[insurance_token_info.token_index], &[], ) .await @@ -1917,10 +1961,15 @@ impl MangoClient { liab_token_index: TokenIndex, max_liab_transfer: I80F48, ) -> anyhow::Result { + let group = account_fetcher_fetch_anchor_account::( + &*self.account_fetcher, + &self.context.group, + ) + .await?; + let mango_account = &self.mango_account().await?; - let quote_token_index = 0; - let quote_info = self.context.token(quote_token_index); + let insurance_info = self.context.token_by_mint(&group.insurance_mint)?; let liab_info = self.context.token(liab_token_index); let bank_remaining_ams = liab_info @@ -1933,8 +1982,8 @@ impl MangoClient { .derive_health_check_remaining_account_metas_two_accounts( mango_account, liqee.1, - &[INSURANCE_TOKEN_INDEX], - &[quote_token_index, liab_token_index], + &[insurance_info.token_index], + &[insurance_info.token_index, liab_token_index], ) .await .unwrap(); @@ -1955,7 +2004,7 @@ impl MangoClient { liqor: self.mango_account_address, liqor_owner: self.authority(), liab_mint_info: liab_info.mint_info_address, - quote_vault: quote_info.first_vault(), + quote_vault: insurance_info.first_vault(), insurance_vault: group.insurance_vault, token_program: Token::id(), }, @@ -2165,7 +2214,10 @@ impl MangoClient { } pub fn jupiter_v6(&self) -> swap::jupiter_v6::JupiterV6 { - swap::jupiter_v6::JupiterV6 { mango_client: self } + swap::jupiter_v6::JupiterV6 { + mango_client: self, + timeout_duration: self.client.config.jupiter_timeout, + } } pub fn sanctum(&self) -> swap::sanctum::Sanctum { diff --git a/lib/client/src/context.rs b/lib/client/src/context.rs index a141ea7e85..3071bd0e96 100644 --- a/lib/client/src/context.rs +++ b/lib/client/src/context.rs @@ -128,6 +128,7 @@ pub struct ComputeEstimates { pub cu_perp_consume_events_base: u32, pub cu_perp_consume_events_per_event: u32, pub cu_token_update_index_and_rates: u32, + pub cu_for_sequence_check: u32, } impl Default for ComputeEstimates { @@ -156,6 +157,8 @@ impl Default for ComputeEstimates { cu_perp_consume_events_base: 10_000, cu_perp_consume_events_per_event: 18_000, cu_token_update_index_and_rates: 90_000, + // measured around 8k, see test_basics + cu_for_sequence_check: 10_000, } } } diff --git a/lib/client/src/swap/jupiter_v6.rs b/lib/client/src/swap/jupiter_v6.rs index 864c049c44..62ee8ade1a 100644 --- a/lib/client/src/swap/jupiter_v6.rs +++ b/lib/client/src/swap/jupiter_v6.rs @@ -1,4 +1,5 @@ use std::str::FromStr; +use std::time::Duration; use anchor_lang::prelude::Pubkey; use serde::{Deserialize, Serialize}; @@ -133,6 +134,7 @@ impl TryFrom<&AccountMeta> for solana_sdk::instruction::AccountMeta { pub struct JupiterV6<'a> { pub mango_client: &'a MangoClient, + pub timeout_duration: Duration, } impl<'a> JupiterV6<'a> { @@ -198,6 +200,7 @@ impl<'a> JupiterV6<'a> { .http_client .get(format!("{}/quote", config.jupiter_v6_url)) .query(&query_args) + .timeout(self.timeout_duration) .send() .await .context("quote request to jupiter")?; @@ -284,6 +287,7 @@ impl<'a> JupiterV6<'a> { destination_token_account: None, // default to user ata quote_response: quote.clone(), }) + .timeout(self.timeout_duration) .send() .await .context("swap transaction request to jupiter")?; diff --git a/lib/client/src/util.rs b/lib/client/src/util.rs index b8e6cbb461..25a07435db 100644 --- a/lib/client/src/util.rs +++ b/lib/client/src/util.rs @@ -21,19 +21,29 @@ impl AnyhowWrap for Result { /// Push to an async_channel::Sender and ignore if the channel is full pub trait AsyncChannelSendUnlessFull { /// Send a message if the channel isn't full - fn send_unless_full(&self, msg: T) -> Result<(), async_channel::SendError>; + fn send_unless_full(&self, msg: T) -> anyhow::Result<()>; } impl AsyncChannelSendUnlessFull for async_channel::Sender { - fn send_unless_full(&self, msg: T) -> Result<(), async_channel::SendError> { + fn send_unless_full(&self, msg: T) -> anyhow::Result<()> { use async_channel::*; match self.try_send(msg) { Ok(()) => Ok(()), - Err(TrySendError::Closed(msg)) => Err(async_channel::SendError(msg)), + Err(TrySendError::Closed(_)) => Err(anyhow::format_err!("channel is closed")), Err(TrySendError::Full(_)) => Ok(()), } } } +impl AsyncChannelSendUnlessFull for tokio::sync::mpsc::Sender { + fn send_unless_full(&self, msg: T) -> anyhow::Result<()> { + use tokio::sync::mpsc::*; + match self.try_send(msg) { + Ok(()) => Ok(()), + Err(error::TrySendError::Closed(_)) => Err(anyhow::format_err!("channel is closed")), + Err(error::TrySendError::Full(_)) => Ok(()), + } + } +} /// Like tokio::time::interval(), but with Delay as default MissedTickBehavior /// diff --git a/mango_v4.json b/mango_v4.json index 9acc3ee052..65f638f232 100644 --- a/mango_v4.json +++ b/mango_v4.json @@ -1,5 +1,5 @@ { - "version": "0.24.2", + "version": "0.24.3", "name": "mango_v4", "instructions": [ { @@ -326,6 +326,86 @@ } ] }, + { + "name": "groupChangeInsuranceFund", + "accounts": [ + { + "name": "group", + "isMut": true, + "isSigner": false, + "relations": [ + "insurance_vault", + "admin" + ] + }, + { + "name": "admin", + "isMut": false, + "isSigner": true + }, + { + "name": "insuranceVault", + "isMut": true, + "isSigner": false + }, + { + "name": "withdrawDestination", + "isMut": true, + "isSigner": false + }, + { + "name": "newInsuranceMint", + "isMut": false, + "isSigner": false + }, + { + "name": "newInsuranceVault", + "isMut": true, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "InsuranceVault" + }, + { + "kind": "account", + "type": "publicKey", + "path": "group" + }, + { + "kind": "account", + "type": "publicKey", + "account": "Mint", + "path": "new_insurance_mint" + } + ] + } + }, + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, { "name": "ixGateSet", "accounts": [ @@ -645,6 +725,10 @@ { "name": "collateralFeePerDay", "type": "f32" + }, + { + "name": "tier", + "type": "string" } ] }, @@ -1073,6 +1157,12 @@ "type": { "option": "bool" } + }, + { + "name": "tierOpt", + "type": { + "option": "string" + } } ] }, @@ -7584,7 +7674,7 @@ "type": "u8" }, { - "name": "padding", + "name": "tier", "type": { "array": [ "u8", @@ -10063,9 +10153,13 @@ "type": { "array": [ "u8", - 119 + 111 ] } + }, + { + "name": "forceAlign", + "type": "u64" } ] } @@ -11120,6 +11214,9 @@ }, { "name": "HealthCheck" + }, + { + "name": "GroupChangeInsuranceFund" } ] } diff --git a/programs/mango-v4/Cargo.toml b/programs/mango-v4/Cargo.toml index bd5fd56d93..a42367c087 100644 --- a/programs/mango-v4/Cargo.toml +++ b/programs/mango-v4/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mango-v4" -version = "0.24.2" +version = "0.24.3" description = "Created with Anchor" edition = "2021" diff --git a/programs/mango-v4/src/accounts_ix/group_change_insurance_fund.rs b/programs/mango-v4/src/accounts_ix/group_change_insurance_fund.rs new file mode 100644 index 0000000000..3f3e3f29be --- /dev/null +++ b/programs/mango-v4/src/accounts_ix/group_change_insurance_fund.rs @@ -0,0 +1,63 @@ +use crate::{error::MangoError, state::*}; +use anchor_lang::prelude::*; +use anchor_spl::token::{self, Mint, Token, TokenAccount}; + +#[derive(Accounts)] +pub struct GroupChangeInsuranceFund<'info> { + #[account( + mut, + has_one = insurance_vault, + has_one = admin, + constraint = group.load()?.is_ix_enabled(IxGate::GroupChangeInsuranceFund) @ MangoError::IxIsDisabled, + )] + pub group: AccountLoader<'info, Group>, + pub admin: Signer<'info>, + + #[account(mut)] + pub insurance_vault: Account<'info, TokenAccount>, + + #[account(mut)] + pub withdraw_destination: Account<'info, TokenAccount>, + + pub new_insurance_mint: Account<'info, Mint>, + + #[account( + init, + seeds = [b"InsuranceVault".as_ref(), group.key().as_ref(), new_insurance_mint.key().as_ref()], + bump, + token::authority = group, + token::mint = new_insurance_mint, + payer = payer + )] + pub new_insurance_vault: Account<'info, TokenAccount>, + + #[account(mut)] + pub payer: Signer<'info>, + + pub token_program: Program<'info, Token>, + pub system_program: Program<'info, System>, + pub rent: Sysvar<'info, Rent>, +} + +impl<'info> GroupChangeInsuranceFund<'info> { + pub fn transfer_ctx(&self) -> CpiContext<'_, '_, '_, 'info, token::Transfer<'info>> { + let program = self.token_program.to_account_info(); + let accounts = token::Transfer { + from: self.insurance_vault.to_account_info(), + to: self.withdraw_destination.to_account_info(), + authority: self.group.to_account_info(), + }; + CpiContext::new(program, accounts) + } + + pub fn close_ctx(&self) -> CpiContext<'_, '_, '_, 'info, token::CloseAccount<'info>> { + CpiContext::new( + self.token_program.to_account_info(), + token::CloseAccount { + account: self.insurance_vault.to_account_info(), + destination: self.payer.to_account_info(), + authority: self.group.to_account_info(), + }, + ) + } +} diff --git a/programs/mango-v4/src/accounts_ix/mod.rs b/programs/mango-v4/src/accounts_ix/mod.rs index 4256824a8e..9da1598756 100644 --- a/programs/mango-v4/src/accounts_ix/mod.rs +++ b/programs/mango-v4/src/accounts_ix/mod.rs @@ -12,6 +12,7 @@ pub use alt_set::*; pub use benchmark::*; pub use compute_account_data::*; pub use flash_loan::*; +pub use group_change_insurance_fund::*; pub use group_close::*; pub use group_create::*; pub use group_edit::*; @@ -92,6 +93,7 @@ mod alt_set; mod benchmark; mod compute_account_data; mod flash_loan; +mod group_change_insurance_fund; mod group_close; mod group_create; mod group_edit; diff --git a/programs/mango-v4/src/accounts_ix/perp_liq_negative_pnl_or_bankruptcy.rs b/programs/mango-v4/src/accounts_ix/perp_liq_negative_pnl_or_bankruptcy.rs index f389934948..e78363fbcd 100644 --- a/programs/mango-v4/src/accounts_ix/perp_liq_negative_pnl_or_bankruptcy.rs +++ b/programs/mango-v4/src/accounts_ix/perp_liq_negative_pnl_or_bankruptcy.rs @@ -115,7 +115,7 @@ pub struct PerpLiqNegativePnlOrBankruptcyV2<'info> { #[account( mut, has_one = group, - constraint = insurance_bank.load()?.token_index == INSURANCE_TOKEN_INDEX + constraint = insurance_bank.load()?.mint == insurance_vault.mint, )] pub insurance_bank: AccountLoader<'info, Bank>, diff --git a/programs/mango-v4/src/accounts_ix/token_liq_bankruptcy.rs b/programs/mango-v4/src/accounts_ix/token_liq_bankruptcy.rs index b5a3515263..f31555262e 100644 --- a/programs/mango-v4/src/accounts_ix/token_liq_bankruptcy.rs +++ b/programs/mango-v4/src/accounts_ix/token_liq_bankruptcy.rs @@ -8,7 +8,7 @@ use crate::state::*; // Remaining accounts: // - all banks for liab_mint_info (writable) -// - merged health accounts for liqor+liqee +// - merged health accounts for liqor + liqee, including the bank for the insurance token #[derive(Accounts)] pub struct TokenLiqBankruptcy<'info> { #[account( diff --git a/programs/mango-v4/src/instructions/group_change_insurance_fund.rs b/programs/mango-v4/src/instructions/group_change_insurance_fund.rs new file mode 100644 index 0000000000..c3c11190f0 --- /dev/null +++ b/programs/mango-v4/src/instructions/group_change_insurance_fund.rs @@ -0,0 +1,24 @@ +use anchor_lang::prelude::*; +use anchor_spl::token; + +use crate::{accounts_ix::GroupChangeInsuranceFund, group_seeds}; + +pub fn group_change_insurance_fund(ctx: Context) -> Result<()> { + { + let group = ctx.accounts.group.load()?; + let group_seeds = group_seeds!(group); + token::transfer( + ctx.accounts.transfer_ctx().with_signer(&[group_seeds]), + ctx.accounts.insurance_vault.amount, + )?; + token::close_account(ctx.accounts.close_ctx().with_signer(&[group_seeds]))?; + } + + { + let mut group = ctx.accounts.group.load_mut()?; + group.insurance_vault = ctx.accounts.new_insurance_vault.key(); + group.insurance_mint = ctx.accounts.new_insurance_mint.key(); + } + + Ok(()) +} diff --git a/programs/mango-v4/src/instructions/ix_gate_set.rs b/programs/mango-v4/src/instructions/ix_gate_set.rs index 8fdd0b8531..f23baf0f40 100644 --- a/programs/mango-v4/src/instructions/ix_gate_set.rs +++ b/programs/mango-v4/src/instructions/ix_gate_set.rs @@ -98,6 +98,7 @@ pub fn ix_gate_set(ctx: Context, ix_gate: u128) -> Result<()> { log_if_changed(&group, ix_gate, IxGate::TokenForceWithdraw); log_if_changed(&group, ix_gate, IxGate::SequenceCheck); log_if_changed(&group, ix_gate, IxGate::HealthCheck); + log_if_changed(&group, ix_gate, IxGate::GroupChangeInsuranceFund); group.ix_gate = ix_gate; diff --git a/programs/mango-v4/src/instructions/mod.rs b/programs/mango-v4/src/instructions/mod.rs index 1f91a7b53a..68b8e9b4c0 100644 --- a/programs/mango-v4/src/instructions/mod.rs +++ b/programs/mango-v4/src/instructions/mod.rs @@ -12,6 +12,7 @@ pub use alt_set::*; pub use benchmark::*; pub use compute_account_data::*; pub use flash_loan::*; +pub use group_change_insurance_fund::*; pub use group_close::*; pub use group_create::*; pub use group_edit::*; @@ -83,6 +84,7 @@ mod alt_set; mod benchmark; mod compute_account_data; mod flash_loan; +mod group_change_insurance_fund; mod group_close; mod group_create; mod group_edit; diff --git a/programs/mango-v4/src/instructions/serum3_close_open_orders.rs b/programs/mango-v4/src/instructions/serum3_close_open_orders.rs index d463c5d582..101ae2f956 100644 --- a/programs/mango-v4/src/instructions/serum3_close_open_orders.rs +++ b/programs/mango-v4/src/instructions/serum3_close_open_orders.rs @@ -5,18 +5,19 @@ use crate::error::MangoError; use crate::state::*; pub fn serum3_close_open_orders(ctx: Context) -> Result<()> { + let serum_market = ctx.accounts.serum_market.load()?; + // // Validation // let mut account = ctx.accounts.account.load_full_mut()?; // account constraint #1 require!( - account.fixed.is_owner_or_delegate(ctx.accounts.owner.key()), + account.fixed.is_owner_or_delegate(ctx.accounts.owner.key()) + || serum_market.is_force_close(), MangoError::SomeError ); - let serum_market = ctx.accounts.serum_market.load()?; - // Validate open_orders #2 require!( account diff --git a/programs/mango-v4/src/instructions/token_edit.rs b/programs/mango-v4/src/instructions/token_edit.rs index 87afd6622f..a493d60375 100644 --- a/programs/mango-v4/src/instructions/token_edit.rs +++ b/programs/mango-v4/src/instructions/token_edit.rs @@ -56,6 +56,7 @@ pub fn token_edit( disable_asset_liquidation_opt: Option, collateral_fee_per_day: Option, force_withdraw_opt: Option, + tier_opt: Option, ) -> Result<()> { let group = ctx.accounts.group.load()?; @@ -323,6 +324,12 @@ pub fn token_edit( require_group_admin = true; }; + if let Some(tier) = tier_opt.as_ref() { + msg!("Tier: old - {:?}, new - {:?}", bank.tier, tier); + bank.tier = fill_from_str(&tier)?; + require_group_admin = true; + }; + if let Some(force_close) = force_close_opt { if force_close { require!(bank.reduce_only > 0, MangoError::SomeError); diff --git a/programs/mango-v4/src/instructions/token_liq_bankruptcy.rs b/programs/mango-v4/src/instructions/token_liq_bankruptcy.rs index 8cc5e86388..430b3b0d44 100644 --- a/programs/mango-v4/src/instructions/token_liq_bankruptcy.rs +++ b/programs/mango-v4/src/instructions/token_liq_bankruptcy.rs @@ -26,6 +26,24 @@ pub fn token_liq_bankruptcy( let (bank_ais, health_ais) = &ctx.remaining_accounts.split_at(liab_mint_info.num_banks()); liab_mint_info.verify_banks_ais(bank_ais)?; + // find the insurance bank token index + let insurance_mint = ctx.accounts.insurance_vault.mint; + require_keys_eq!(insurance_mint, group.insurance_mint); + let insurance_token_index = health_ais + .iter() + .find_map(|ai| { + ai.load::() + .and_then(|b| { + if b.mint == insurance_mint { + Ok(b.token_index) + } else { + Err(MangoError::InvalidBank.into()) + } + }) + .ok() + }) + .ok_or_else(|| error_msg!("could not find bank for insurance mint in health accounts"))?; + require_keys_neq!(ctx.accounts.liqor.key(), ctx.accounts.liqee.key()); let mut liqor = ctx.accounts.liqor.load_full_mut()?; @@ -51,10 +69,10 @@ pub fn token_liq_bankruptcy( liqee_health_cache.require_after_phase2_liquidation()?; liqee.fixed.set_being_liquidated(true); - let liab_is_insurance_token = liab_token_index == INSURANCE_TOKEN_INDEX; - let (liab_bank, liab_oracle_price, opt_quote_bank_and_price) = - account_retriever.banks_mut_and_oracles(liab_token_index, INSURANCE_TOKEN_INDEX)?; - assert!(liab_is_insurance_token == opt_quote_bank_and_price.is_none()); + let liab_is_insurance_token = liab_token_index == insurance_token_index; + let (liab_bank, liab_oracle_price, opt_insurance_bank_and_price) = + account_retriever.banks_mut_and_oracles(liab_token_index, insurance_token_index)?; + assert!(liab_is_insurance_token == opt_insurance_bank_and_price.is_none()); let mut liab_deposit_index = liab_bank.deposit_index; let liab_borrow_index = liab_bank.borrow_index; @@ -76,11 +94,12 @@ pub fn token_liq_bankruptcy( // guaranteed positive let mut remaining_liab_loss = (-initial_liab_native).min(-liqee_liab_health_balance); - // We pay for the liab token in quote. Example: SOL is at $20 and USDC is at $2, then for a liab + // We pay for the liab token in insurance token. + // Example: SOL is at $20 and USDC is at $2, then for a liab // of 3 SOL, we'd pay 3 * 20 / 2 * (1+fee) = 30 * (1+fee) USDC. - let liab_to_quote_with_fee = - if let Some((_quote_bank, quote_price)) = opt_quote_bank_and_price.as_ref() { - liab_oracle_price * (I80F48::ONE + liab_bank.liquidation_fee) / quote_price + let liab_to_insurance_with_fee = + if let Some((_insurance_bank, insurance_price)) = opt_insurance_bank_and_price.as_ref() { + liab_oracle_price * (I80F48::ONE + liab_bank.liquidation_fee) / insurance_price } else { I80F48::ONE }; @@ -93,7 +112,7 @@ pub fn token_liq_bankruptcy( 0 }; - let insurance_transfer = (liab_transfer_unrounded * liab_to_quote_with_fee) + let insurance_transfer = (liab_transfer_unrounded * liab_to_insurance_with_fee) .ceil() .to_num::() .min(insurance_vault_amount); @@ -105,7 +124,7 @@ pub fn token_liq_bankruptcy( // AUDIT: v3 does this, but it seems bad, because it can make liab_transfer // exceed max_liab_transfer due to the ceil() above! Otoh, not doing it would allow // liquidators to exploit the insurance fund for 1 native token each call. - let liab_transfer = insurance_transfer_i80f48 / liab_to_quote_with_fee; + let liab_transfer = insurance_transfer_i80f48 / liab_to_insurance_with_fee; let mut liqee_liab_active = true; if insurance_transfer > 0 { @@ -115,36 +134,36 @@ pub fn token_liq_bankruptcy( // update correctly even if dusting happened remaining_liab_loss -= liqee_liab.native(liab_bank) - initial_liab_native; - // move insurance assets into quote bank + // move insurance assets into insurance bank let group_seeds = group_seeds!(group); token::transfer( ctx.accounts.transfer_ctx().with_signer(&[group_seeds]), insurance_transfer, )?; - // move quote assets into liqor and withdraw liab assets - if let Some((quote_bank, _)) = opt_quote_bank_and_price { + // move insurance assets into liqor and withdraw liab assets + if let Some((insurance_bank, _)) = opt_insurance_bank_and_price { // account constraint #2 a) - require_keys_eq!(quote_bank.vault, ctx.accounts.quote_vault.key()); - require_keys_eq!(quote_bank.mint, ctx.accounts.insurance_vault.mint); + require_keys_eq!(insurance_bank.vault, ctx.accounts.quote_vault.key()); + require_keys_eq!(insurance_bank.mint, ctx.accounts.insurance_vault.mint); - let quote_deposit_index = quote_bank.deposit_index; - let quote_borrow_index = quote_bank.borrow_index; + let insurance_deposit_index = insurance_bank.deposit_index; + let insurance_borrow_index = insurance_bank.borrow_index; // credit the liqor - let (liqor_quote, liqor_quote_raw_token_index, _) = - liqor.ensure_token_position(INSURANCE_TOKEN_INDEX)?; - let liqor_quote_active = - quote_bank.deposit(liqor_quote, insurance_transfer_i80f48, now_ts)?; + let (liqor_insurance, liqor_insurance_raw_token_index, _) = + liqor.ensure_token_position(insurance_token_index)?; + let liqor_insurance_active = + insurance_bank.deposit(liqor_insurance, insurance_transfer_i80f48, now_ts)?; - // liqor quote + // liqor insurance emit_stack(TokenBalanceLog { mango_group: ctx.accounts.group.key(), mango_account: ctx.accounts.liqor.key(), - token_index: INSURANCE_TOKEN_INDEX, - indexed_position: liqor_quote.indexed_position.to_bits(), - deposit_index: quote_deposit_index.to_bits(), - borrow_index: quote_borrow_index.to_bits(), + token_index: insurance_token_index, + indexed_position: liqor_insurance.indexed_position.to_bits(), + deposit_index: insurance_deposit_index.to_bits(), + borrow_index: insurance_borrow_index.to_bits(), }); // transfer liab from liqee to liqor @@ -189,9 +208,9 @@ pub fn token_liq_bankruptcy( }); } - if !liqor_quote_active { + if !liqor_insurance_active { liqor.deactivate_token_position_and_log( - liqor_quote_raw_token_index, + liqor_insurance_raw_token_index, ctx.accounts.liqor.key(), ); } @@ -202,12 +221,12 @@ pub fn token_liq_bankruptcy( ); } } else { - // For liab_token_index == INSURANCE_TOKEN_INDEX: the insurance fund deposits directly into liqee, + // For liab_token_index == insurance_token_index: the insurance fund deposits directly into liqee, // without a fee or the liqor being involved // account constraint #2 b) require_keys_eq!(liab_bank.vault, ctx.accounts.quote_vault.key()); - require_eq!(liab_token_index, INSURANCE_TOKEN_INDEX); - require_eq!(liab_to_quote_with_fee, I80F48::ONE); + require_eq!(liab_token_index, insurance_token_index); + require_eq!(liab_to_insurance_with_fee, I80F48::ONE); require_eq!(insurance_transfer_i80f48, liab_transfer); } } @@ -287,7 +306,7 @@ pub fn token_liq_bankruptcy( liab_token_index, initial_liab_native: initial_liab_native.to_bits(), liab_price: liab_oracle_price.to_bits(), - insurance_token_index: INSURANCE_TOKEN_INDEX, + insurance_token_index, insurance_transfer: insurance_transfer_i80f48.to_bits(), socialized_loss: socialized_loss.to_bits(), starting_liab_deposit_index: starting_deposit_index.to_bits(), diff --git a/programs/mango-v4/src/instructions/token_register.rs b/programs/mango-v4/src/instructions/token_register.rs index 7d545ad97e..8ce21eb479 100644 --- a/programs/mango-v4/src/instructions/token_register.rs +++ b/programs/mango-v4/src/instructions/token_register.rs @@ -46,14 +46,8 @@ pub fn token_register( platform_liquidation_fee: f32, disable_asset_liquidation: bool, collateral_fee_per_day: f32, + tier: String, ) -> Result<()> { - // Require token 0 to be in the insurance token - if token_index == INSURANCE_TOKEN_INDEX { - require_keys_eq!( - ctx.accounts.group.load()?.insurance_mint, - ctx.accounts.mint.key() - ); - } require_neq!(token_index, TokenIndex::MAX); let now_ts: u64 = Clock::get()?.unix_timestamp.try_into().unwrap(); @@ -113,7 +107,6 @@ pub fn token_register( force_close: 0, disable_asset_liquidation: u8::from(disable_asset_liquidation), force_withdraw: 0, - padding: Default::default(), fees_withdrawn: 0, token_conditional_swap_taker_fee_rate, token_conditional_swap_maker_fee_rate, @@ -133,6 +126,7 @@ pub fn token_register( collected_liquidation_fees: I80F48::ZERO, collected_collateral_fees: I80F48::ZERO, collateral_fee_per_day, + tier: fill_from_str(&tier)?, reserved: [0; 1900], }; diff --git a/programs/mango-v4/src/instructions/token_register_trustless.rs b/programs/mango-v4/src/instructions/token_register_trustless.rs index 6b62842286..913d664868 100644 --- a/programs/mango-v4/src/instructions/token_register_trustless.rs +++ b/programs/mango-v4/src/instructions/token_register_trustless.rs @@ -92,7 +92,6 @@ pub fn token_register_trustless( force_close: 0, disable_asset_liquidation: 1, force_withdraw: 0, - padding: Default::default(), fees_withdrawn: 0, token_conditional_swap_taker_fee_rate: 0.0, token_conditional_swap_maker_fee_rate: 0.0, @@ -111,6 +110,7 @@ pub fn token_register_trustless( collected_liquidation_fees: I80F48::ZERO, collected_collateral_fees: I80F48::ZERO, collateral_fee_per_day: 0.0, // TODO + tier: fill_from_str("C")?, reserved: [0; 1900], }; let oracle_ref = &AccountInfoRef::borrow(ctx.accounts.oracle.as_ref())?; diff --git a/programs/mango-v4/src/instructions/token_withdraw.rs b/programs/mango-v4/src/instructions/token_withdraw.rs index eb3161c9e8..e6e334dae8 100644 --- a/programs/mango-v4/src/instructions/token_withdraw.rs +++ b/programs/mango-v4/src/instructions/token_withdraw.rs @@ -13,8 +13,6 @@ use crate::logs::{ emit_stack, LoanOriginationFeeInstruction, TokenBalanceLog, WithdrawLoanLog, WithdrawLog, }; -const DELEGATE_WITHDRAW_MAX: i64 = 100_000; // $0.1 - pub fn token_withdraw(ctx: Context, amount: u64, allow_borrow: bool) -> Result<()> { require_msg!(amount > 0, "withdraw amount must be positive"); @@ -143,13 +141,6 @@ pub fn token_withdraw(ctx: Context, amount: u64, allow_borrow: bo !withdraw_result.position_is_active, MangoError::DelegateWithdrawMustClosePosition ); - - // Delegates can't withdraw too much - require_gte!( - DELEGATE_WITHDRAW_MAX, - amount_usd, - MangoError::DelegateWithdrawSmall - ); } // diff --git a/programs/mango-v4/src/lib.rs b/programs/mango-v4/src/lib.rs index ac27e52a7e..32fec2077d 100644 --- a/programs/mango-v4/src/lib.rs +++ b/programs/mango-v4/src/lib.rs @@ -115,6 +115,12 @@ pub mod mango_v4 { Ok(()) } + pub fn group_change_insurance_fund(ctx: Context) -> Result<()> { + #[cfg(feature = "enable-gpl")] + instructions::group_change_insurance_fund(ctx)?; + Ok(()) + } + pub fn ix_gate_set(ctx: Context, ix_gate: u128) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::ix_gate_set(ctx, ix_gate)?; @@ -161,6 +167,7 @@ pub mod mango_v4 { platform_liquidation_fee: f32, disable_asset_liquidation: bool, collateral_fee_per_day: f32, + tier: String, ) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::token_register( @@ -196,6 +203,7 @@ pub mod mango_v4 { platform_liquidation_fee, disable_asset_liquidation, collateral_fee_per_day, + tier, )?; Ok(()) } @@ -254,6 +262,7 @@ pub mod mango_v4 { disable_asset_liquidation_opt: Option, collateral_fee_per_day_opt: Option, force_withdraw_opt: Option, + tier_opt: Option, ) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::token_edit( @@ -299,6 +308,7 @@ pub mod mango_v4 { disable_asset_liquidation_opt, collateral_fee_per_day_opt, force_withdraw_opt, + tier_opt, )?; Ok(()) } diff --git a/programs/mango-v4/src/state/bank.rs b/programs/mango-v4/src/state/bank.rs index 40d889bbb3..827f8ca01e 100644 --- a/programs/mango-v4/src/state/bank.rs +++ b/programs/mango-v4/src/state/bank.rs @@ -164,8 +164,8 @@ pub struct Bank { pub force_withdraw: u8, - #[derivative(Debug = "ignore")] - pub padding: [u8; 4], + #[derivative(Debug(format_with = "util::format_zero_terminated_utf8_bytes"))] + pub tier: [u8; 4], // Do separate bookkeping for how many tokens were withdrawn // This ensures that collected_fees_native is strictly increasing for stats gathering purposes @@ -364,7 +364,7 @@ impl Bank { force_close: existing_bank.force_close, disable_asset_liquidation: existing_bank.disable_asset_liquidation, force_withdraw: existing_bank.force_withdraw, - padding: [0; 4], + tier: existing_bank.tier, token_conditional_swap_taker_fee_rate: existing_bank .token_conditional_swap_taker_fee_rate, token_conditional_swap_maker_fee_rate: existing_bank @@ -440,6 +440,12 @@ impl Bank { .trim_matches(char::from(0)) } + pub fn tier(&self) -> &str { + std::str::from_utf8(&self.tier) + .unwrap() + .trim_matches(char::from(0)) + } + pub fn are_deposits_reduce_only(&self) -> bool { self.reduce_only == 1 } diff --git a/programs/mango-v4/src/state/group.rs b/programs/mango-v4/src/state/group.rs index 61f61cde2e..f4b4c8bed1 100644 --- a/programs/mango-v4/src/state/group.rs +++ b/programs/mango-v4/src/state/group.rs @@ -12,11 +12,6 @@ pub type TokenIndex = u16; /// incorrect assumption. pub const QUOTE_TOKEN_INDEX: TokenIndex = 0; -/// The token index used for the insurance fund. -/// -/// We should eventually generalize insurance funds. -pub const INSURANCE_TOKEN_INDEX: TokenIndex = 0; - /// The token index used for settling perp markets. /// /// We should eventually generalize to make the whole perp quote (and settle) token @@ -248,6 +243,7 @@ pub enum IxGate { TokenForceWithdraw = 72, SequenceCheck = 73, HealthCheck = 74, + GroupChangeInsuranceFund = 76, // NOTE: Adding new variants requires matching changes in ts and the ix_gate_set instruction. } diff --git a/programs/mango-v4/src/state/orderbook/bookside.rs b/programs/mango-v4/src/state/orderbook/bookside.rs index 692e71be24..b4d98d1c73 100644 --- a/programs/mango-v4/src/state/orderbook/bookside.rs +++ b/programs/mango-v4/src/state/orderbook/bookside.rs @@ -238,6 +238,7 @@ impl BookSide { mod tests { use super::*; use bytemuck::Zeroable; + use std::collections::HashSet; fn new_order_tree(order_tree_type: OrderTreeType) -> OrderTreeNodes { let mut ot = OrderTreeNodes::zeroed(); @@ -281,8 +282,11 @@ mod tests { .insert_leaf(&mut root_pegged, &new_leaf(key)) .unwrap(); + let mut pegged_prices = vec![]; + while root_pegged.leaf_count < 100 { - let price_data: u64 = oracle_pegged_price_data(rng.gen_range(-20..20)); + let price = rng.gen_range(-20..20); + let price_data: u64 = oracle_pegged_price_data(price); let seq_num: u64 = rng.gen_range(0..1000); let key = new_node_key(side, price_data, seq_num); if keys.contains(&key) { @@ -292,6 +296,7 @@ mod tests { order_tree .insert_leaf(&mut root_pegged, &new_leaf(key)) .unwrap(); + pegged_prices.push(price); } while root_fixed.leaf_count < 100 { @@ -332,6 +337,12 @@ mod tests { total += 1; } assert!(total >= 101); // some oracle peg orders could be skipped + + let skipped_pegged_orders = pegged_prices + .iter() + .filter(|x| oracle_price_lots + **x <= 0) + .count(); + assert_eq!(total, 200 - skipped_pegged_orders); if oracle_price_lots > 20 { assert_eq!(total, 200); } @@ -340,15 +351,31 @@ mod tests { #[test] fn bookside_iteration_random() { - bookside_iteration_random_helper(Side::Bid); - bookside_iteration_random_helper(Side::Ask); + for i in 0..10 { + bookside_iteration_random_helper(Side::Bid); + bookside_iteration_random_helper(Side::Ask); + } } fn bookside_setup() -> BookSide { + bookside_setup_advanced( + &[(100, 0), (120, 5)], + &[(-10, 0, 100), (-15, 0, -1), (-20, 7, 95)], + Side::Bid, + ) + } + + fn bookside_setup_advanced( + fixed: &[(i64, u16)], + pegged: &[(i64, u16, i64)], + side: Side, + ) -> BookSide { use std::cell::RefCell; - let side = Side::Bid; - let order_tree_type = OrderTreeType::Bids; + let order_tree_type = match side { + Side::Bid => OrderTreeType::Bids, + Side::Ask => OrderTreeType::Asks, + }; let order_tree = RefCell::new(new_order_tree(order_tree_type)); let mut root_fixed = OrderTreeRoot::zeroed(); @@ -381,11 +408,12 @@ mod tests { .unwrap(); }; - add_fixed(100, 0); - add_fixed(120, 5); - add_pegged(-10, 0, 100); - add_pegged(-15, 0, -1); - add_pegged(-20, 7, 95); + for (price, tif) in fixed { + add_fixed(*price, *tif); + } + for (price_offset, tif, limit) in pegged { + add_pegged(*price_offset, *tif, *limit); + } BookSide { roots: [root_fixed, root_pegged], @@ -458,4 +486,26 @@ mod tests { assert_eq!(p, 120); assert_eq!(order_prices(0, 100), Vec::::new()); } + + #[test] + fn bookside_iterate_when_first_peg_is_skipped() { + use std::cell::RefCell; + + let bookside = RefCell::new(bookside_setup_advanced( + &[], + &[(-100, 0, 50), (-20, 0, 50), (-30, 0, 50)], + Side::Ask, + )); + + let order_prices = |now_ts: u64, oracle: i64| -> Vec { + bookside + .borrow() + .iter_valid(now_ts, oracle) + .map(|it| it.price_lots) + .collect() + }; + + assert_eq!(order_prices(0, 200), vec![100, 170, 180]); + assert_eq!(order_prices(0, 100), vec![70, 80]); + } } diff --git a/programs/mango-v4/src/state/orderbook/bookside_iterator.rs b/programs/mango-v4/src/state/orderbook/bookside_iterator.rs index da89ddf689..3ac3eed873 100644 --- a/programs/mango-v4/src/state/orderbook/bookside_iterator.rs +++ b/programs/mango-v4/src/state/orderbook/bookside_iterator.rs @@ -170,7 +170,8 @@ impl<'a> Iterator for BookSideIter<'a> { if oracle_pegged_price(self.oracle_price_lots, o_node, side).0 != OrderState::Skipped { break; } - o_peek = self.oracle_pegged_iter.next() + self.oracle_pegged_iter.next(); + o_peek = self.oracle_pegged_iter.peek(); } let f_peek = self.fixed_iter.peek(); diff --git a/programs/mango-v4/src/state/orderbook/nodes.rs b/programs/mango-v4/src/state/orderbook/nodes.rs index 8b5454d893..96dbb810d3 100644 --- a/programs/mango-v4/src/state/orderbook/nodes.rs +++ b/programs/mango-v4/src/state/orderbook/nodes.rs @@ -1,4 +1,4 @@ -use std::mem::size_of; +use std::mem::{align_of, size_of}; use anchor_lang::prelude::*; use bytemuck::{cast_mut, cast_ref}; @@ -252,7 +252,9 @@ pub struct FreeNode { pub(crate) tag: u8, // NodeTag pub(crate) padding: [u8; 3], pub(crate) next: NodeHandle, - pub(crate) reserved: [u8; NODE_SIZE - 8], + pub(crate) reserved: [u8; NODE_SIZE - 16], + // ensure that FreeNode has the same 8-byte alignment as other nodes + pub(crate) force_align: u64, } const_assert_eq!(size_of::(), NODE_SIZE); const_assert_eq!(size_of::() % 8, 0); @@ -261,13 +263,19 @@ const_assert_eq!(size_of::() % 8, 0); #[derive(bytemuck::Pod, bytemuck::Zeroable)] pub struct AnyNode { pub tag: u8, - pub data: [u8; 119], + pub data: [u8; 111], + // ensure that AnyNode has the same 8-byte alignment as other nodes + pub(crate) force_align: u64, } const_assert_eq!(size_of::(), NODE_SIZE); const_assert_eq!(size_of::() % 8, 0); const_assert_eq!(size_of::(), size_of::()); const_assert_eq!(size_of::(), size_of::()); const_assert_eq!(size_of::(), size_of::()); +const_assert_eq!(align_of::(), 8); +const_assert_eq!(align_of::(), align_of::()); +const_assert_eq!(align_of::(), align_of::()); +const_assert_eq!(align_of::(), align_of::()); pub(crate) enum NodeRef<'a> { Inner(&'a InnerNode), diff --git a/programs/mango-v4/src/state/orderbook/ordertree.rs b/programs/mango-v4/src/state/orderbook/ordertree.rs index 0e9748b310..f32a9112af 100644 --- a/programs/mango-v4/src/state/orderbook/ordertree.rs +++ b/programs/mango-v4/src/state/orderbook/ordertree.rs @@ -262,7 +262,8 @@ impl OrderTreeNodes { }, padding: Default::default(), next: self.free_list_head, - reserved: [0; 112], + reserved: [0; 104], + force_align: 0, }); self.free_list_len += 1; diff --git a/programs/mango-v4/tests/cases/test_bankrupt_tokens.rs b/programs/mango-v4/tests/cases/test_bankrupt_tokens.rs index c9ba251bbb..e21ed6d0b9 100644 --- a/programs/mango-v4/tests/cases/test_bankrupt_tokens.rs +++ b/programs/mango-v4/tests/cases/test_bankrupt_tokens.rs @@ -320,36 +320,18 @@ async fn test_bankrupt_tokens_insurance_fund() -> Result<(), TransportError> { } // deposit some funds, to the vaults aren't empty - let vault_account = send_tx( - solana, - AccountCreateInstruction { - account_num: 2, - group, - owner, - payer, - ..Default::default() - }, - ) - .await - .unwrap() - .account; let vault_amount = 100000; - for &token_account in payer_mint_accounts { - send_tx( - solana, - TokenDepositInstruction { - amount: vault_amount, - reduce_only: false, - account: vault_account, - owner, - token_account, - token_authority: payer.clone(), - bank_index: 1, - }, - ) - .await - .unwrap(); - } + let vault_account = create_funded_account( + &solana, + group, + owner, + 2, + &context.users[1], + mints, + vault_amount, + 1, + ) + .await; // Also add a tiny amount to bank0 for borrow_token1, so we can test multi-bank socialized loss. // It must be enough to not trip the borrow limits on the bank. @@ -610,3 +592,299 @@ async fn test_bankrupt_tokens_insurance_fund() -> Result<(), TransportError> { Ok(()) } + +#[tokio::test] +async fn test_bankrupt_tokens_other_insurance_fund() -> Result<(), TransportError> { + let mut test_builder = TestContextBuilder::new(); + test_builder.test().set_compute_max_units(85_000); // TokenLiqWithToken needs 84k + let context = test_builder.start_default().await; + let solana = &context.solana.clone(); + + let admin = TestKeypair::new(); + let owner = context.users[0].key; + let payer = context.users[1].key; + let mints = &context.mints[0..4]; + let payer_mint_accounts = &context.users[1].token_accounts[0..4]; + + // + // SETUP: Create a group and an account to fill the vaults + // + + let mango_setup::GroupWithTokens { + group, + tokens, + insurance_vault, + .. + } = mango_setup::GroupWithTokensConfig { + admin, + payer, + mints: mints.to_vec(), + ..GroupWithTokensConfig::default() + } + .create(solana) + .await; + let borrow_token1 = &tokens[0]; // USDC + let borrow_token2 = &tokens[1]; + let collateral_token1 = &tokens[2]; + let collateral_token2 = &tokens[3]; + let insurance_token = collateral_token2; + + // fund the insurance vault + { + let mut tx = ClientTransaction::new(solana); + tx.add_instruction_direct( + spl_token::instruction::transfer( + &spl_token::ID, + &payer_mint_accounts[0], + &insurance_vault, + &payer.pubkey(), + &[&payer.pubkey()], + 1051, + ) + .unwrap(), + ); + tx.add_signer(payer); + tx.send().await.unwrap(); + } + + // + // TEST: switch the insurance vault mint, reclaiming the deposited tokens + // + let before_withdraw_dest = solana.token_account_balance(payer_mint_accounts[0]).await; + let insurance_vault = send_tx( + solana, + GroupChangeInsuranceFund { + group, + admin, + payer, + insurance_mint: insurance_token.mint.pubkey, + withdraw_destination: payer_mint_accounts[0], + }, + ) + .await + .unwrap() + .new_insurance_vault; + let after_withdraw_dest = solana.token_account_balance(payer_mint_accounts[0]).await; + assert_eq!(after_withdraw_dest - before_withdraw_dest, 1051); + + // SETUP: Fund the new insurance vault + { + let mut tx = ClientTransaction::new(solana); + tx.add_instruction_direct( + spl_token::instruction::transfer( + &spl_token::ID, + &payer_mint_accounts[3], + &insurance_vault, + &payer.pubkey(), + &[&payer.pubkey()], + 2000, + ) + .unwrap(), + ); + tx.add_signer(payer); + tx.send().await.unwrap(); + } + + // deposit some funds, to the vaults aren't empty + let vault_amount = 100000; + let vault_account = create_funded_account( + &solana, + group, + owner, + 2, + &context.users[1], + mints, + vault_amount, + 0, + ) + .await; + + // + // SETUP: Make an account with some collateral and some borrows + // + let account = send_tx( + solana, + AccountCreateInstruction { + account_num: 0, + group, + owner, + payer, + ..Default::default() + }, + ) + .await + .unwrap() + .account; + + let deposit1_amount = 20; + let deposit2_amount = 1000; + send_tx( + solana, + TokenDepositInstruction { + amount: deposit1_amount, + reduce_only: false, + account, + owner, + token_account: payer_mint_accounts[2], + token_authority: payer.clone(), + bank_index: 0, + }, + ) + .await + .unwrap(); + send_tx( + solana, + TokenDepositInstruction { + amount: deposit2_amount, + reduce_only: false, + account, + owner, + token_account: payer_mint_accounts[3], + token_authority: payer.clone(), + bank_index: 0, + }, + ) + .await + .unwrap(); + + let borrow1_amount = 50; + let borrow1_amount_bank0 = 10; + let borrow1_amount_bank1 = borrow1_amount - borrow1_amount_bank0; + let borrow2_amount = 350; + send_tx( + solana, + TokenWithdrawInstruction { + amount: borrow1_amount_bank1, + allow_borrow: true, + account, + owner, + token_account: payer_mint_accounts[0], + bank_index: 0, + }, + ) + .await + .unwrap(); + send_tx( + solana, + TokenWithdrawInstruction { + amount: borrow1_amount_bank0, + allow_borrow: true, + account, + owner, + token_account: payer_mint_accounts[0], + bank_index: 0, + }, + ) + .await + .unwrap(); + send_tx( + solana, + TokenWithdrawInstruction { + amount: borrow2_amount, + allow_borrow: true, + account, + owner, + token_account: payer_mint_accounts[1], + bank_index: 0, + }, + ) + .await + .unwrap(); + + // + // SETUP: Change the oracle to make health go very negative + // and change the insurance token price to verify it has an effect + // + set_bank_stub_oracle_price(solana, group, borrow_token2, admin, 20.0).await; + set_bank_stub_oracle_price(solana, group, insurance_token, admin, 1.5).await; + + // + // SETUP: liquidate all the collateral against borrow2 + // + + // eat collateral1 + send_tx( + solana, + TokenLiqWithTokenInstruction { + liqee: account, + liqor: vault_account, + liqor_owner: owner, + asset_token_index: collateral_token1.index, + asset_bank_index: 1, + liab_token_index: borrow_token2.index, + liab_bank_index: 1, + max_liab_transfer: I80F48::from_num(100000.0), + }, + ) + .await + .unwrap(); + assert!(account_position_closed(solana, account, collateral_token1.bank).await); + let liqee = get_mango_account(solana, account).await; + assert!(liqee.being_liquidated()); + + // eat collateral2, leaving the account bankrupt + send_tx( + solana, + TokenLiqWithTokenInstruction { + liqee: account, + liqor: vault_account, + liqor_owner: owner, + asset_token_index: collateral_token2.index, + asset_bank_index: 1, + liab_token_index: borrow_token2.index, + liab_bank_index: 1, + max_liab_transfer: I80F48::from_num(100000.0), + }, + ) + .await + .unwrap(); + assert!(account_position_closed(solana, account, collateral_token2.bank).await,); + let liqee = get_mango_account(solana, account).await; + assert!(liqee.being_liquidated()); + + // + // TEST: use the insurance fund to liquidate borrow1 and borrow2 + // + + // Change value of token that the insurance fund is in, to check that bankruptcy amounts + // are correct if it depegs + set_bank_stub_oracle_price(solana, group, borrow_token1, admin, 2.0).await; + + // bankruptcy: insurance token to liqor, liability to liqee + // liquidating only a partial amount + let liab_before = account_position_f64(solana, account, borrow_token2.bank).await; + let insurance_vault_before = solana.token_account_balance(insurance_vault).await; + let liqor_before = account_position(solana, vault_account, insurance_token.bank).await; + let insurance_to_liab = 1.5 / 20.0; + let liab_transfer: f64 = 500.0 * insurance_to_liab; + send_tx( + solana, + TokenLiqBankruptcyInstruction { + liqee: account, + liqor: vault_account, + liqor_owner: owner, + liab_mint_info: borrow_token2.mint_info, + max_liab_transfer: I80F48::from_num(liab_transfer), + }, + ) + .await + .unwrap(); + let liqee = get_mango_account(solana, account).await; + assert!(liqee.being_liquidated()); + assert!(account_position_closed(solana, account, insurance_token.bank).await); + assert_eq!( + account_position(solana, account, borrow_token2.bank).await, + (liab_before + liab_transfer).floor() as i64 + ); + let usdc_amount = (liab_transfer / insurance_to_liab * 1.02).ceil() as u64; + assert_eq!( + solana.token_account_balance(insurance_vault).await, + insurance_vault_before - usdc_amount + ); + assert_eq!( + account_position(solana, vault_account, insurance_token.bank).await, + liqor_before + usdc_amount as i64 + ); + + Ok(()) +} diff --git a/programs/mango-v4/tests/cases/test_health_check.rs b/programs/mango-v4/tests/cases/test_health_check.rs index b4624218b6..bb0c792a3a 100644 --- a/programs/mango-v4/tests/cases/test_health_check.rs +++ b/programs/mango-v4/tests/cases/test_health_check.rs @@ -7,8 +7,6 @@ use mango_v4::accounts_ix::{HealthCheck, HealthCheckKind}; use mango_v4::error::MangoError; use solana_sdk::transport::TransportError; -// TODO FAS - #[tokio::test] async fn test_health_check() -> Result<(), TransportError> { let context = TestContext::new().await; diff --git a/programs/mango-v4/tests/cases/test_liq_perps_bankruptcy.rs b/programs/mango-v4/tests/cases/test_liq_perps_bankruptcy.rs index d551cb4eda..7ed6e40127 100644 --- a/programs/mango-v4/tests/cases/test_liq_perps_bankruptcy.rs +++ b/programs/mango-v4/tests/cases/test_liq_perps_bankruptcy.rs @@ -450,3 +450,303 @@ async fn test_liq_perps_bankruptcy() -> Result<(), TransportError> { Ok(()) } + +#[tokio::test] +async fn test_liq_perps_bankruptcy_other_insurance_fund() -> Result<(), TransportError> { + let mut test_builder = TestContextBuilder::new(); + test_builder.test().set_compute_max_units(200_000); // PerpLiqNegativePnlOrBankruptcy takes a lot of CU + let context = test_builder.start_default().await; + let solana = &context.solana.clone(); + + let admin = TestKeypair::new(); + let owner = context.users[0].key; + let payer = context.users[1].key; + let mints = &context.mints[0..4]; + let payer_mint_accounts = &context.users[1].token_accounts[0..4]; + + // + // SETUP: Create a group and an account to fill the vaults + // + + let GroupWithTokens { group, tokens, .. } = GroupWithTokensConfig { + admin, + payer, + mints: mints.to_vec(), + zero_token_is_quote: true, + ..GroupWithTokensConfig::default() + } + .create(solana) + .await; + + let _quote_token = &tokens[0]; // USDC, 1/1 weights, price 1, never changed + let base_token = &tokens[1]; // used for perp market + let collateral_token = &tokens[2]; // used for adjusting account health + let insurance_token = &tokens[3]; + + let insurance_vault = send_tx( + solana, + GroupChangeInsuranceFund { + group, + admin, + payer, + insurance_mint: insurance_token.mint.pubkey, + withdraw_destination: payer_mint_accounts[0], + }, + ) + .await + .unwrap() + .new_insurance_vault; + + // An unusual price to verify the oracle is used + set_bank_stub_oracle_price(solana, group, &insurance_token, admin, 1.6).await; + + send_tx( + solana, + TokenEditWeights { + group, + admin, + mint: mints[2].pubkey, + maint_liab_weight: 1.0, + maint_asset_weight: 1.0, + init_liab_weight: 1.0, + init_asset_weight: 1.0, + }, + ) + .await + .unwrap(); + + let fund_insurance = |amount: u64| async move { + let mut tx = ClientTransaction::new(solana); + tx.add_instruction_direct( + spl_token::instruction::transfer( + &spl_token::ID, + &payer_mint_accounts[3], + &insurance_vault, + &payer.pubkey(), + &[&payer.pubkey()], + amount, + ) + .unwrap(), + ); + tx.add_signer(payer); + tx.send().await.unwrap(); + }; + + // all perp markets used here default to price = 1.0, base_lot_size = 100 + let price_lots = 100; + + let context_ref = &context; + let mut perp_market_index: PerpMarketIndex = 0; + let setup_perp_inner = |perp_market_index: PerpMarketIndex, + health: i64, + pnl: i64, + settle_limit: i64| async move { + // price used later to produce negative pnl with a short: + // doubling the price leads to -100 pnl + let adj_price = 1.0 + pnl as f64 / -100.0; + let adj_price_lots = (price_lots as f64 * adj_price) as i64; + + let fresh_liqor = create_funded_account( + &solana, + group, + owner, + 200 + perp_market_index as u32, + &context_ref.users[1], + mints, + 10000, + 0, + ) + .await; + + let mango_v4::accounts::PerpCreateMarket { perp_market, .. } = send_tx( + solana, + PerpCreateMarketInstruction { + group, + admin, + payer, + perp_market_index, + quote_lot_size: 1, + base_lot_size: 100, + maint_base_asset_weight: 0.8, + init_base_asset_weight: 0.6, + maint_base_liab_weight: 1.2, + init_base_liab_weight: 1.4, + base_liquidation_fee: 0.05, + maker_fee: 0.0, + taker_fee: 0.0, + group_insurance_fund: true, + // adjust this factur such that we get the desired settle limit in the end + settle_pnl_limit_factor: (settle_limit as f32 - 0.1).max(0.0) + / (1.0 * 100.0 * adj_price) as f32, + settle_pnl_limit_window_size_ts: 24 * 60 * 60, + ..PerpCreateMarketInstruction::with_new_book_and_queue(&solana, base_token).await + }, + ) + .await + .unwrap(); + set_perp_stub_oracle_price(solana, group, perp_market, &base_token, admin, 1.0).await; + set_bank_stub_oracle_price(solana, group, &collateral_token, admin, 1.0).await; + + // + // SETUP: accounts + // + let deposit_amount = 1000; + let helper_account = create_funded_account( + &solana, + group, + owner, + perp_market_index as u32 * 2, + &context_ref.users[1], + &mints[2..3], + deposit_amount, + 0, + ) + .await; + let account = create_funded_account( + &solana, + group, + owner, + perp_market_index as u32 * 2 + 1, + &context_ref.users[1], + &mints[2..3], + deposit_amount, + 0, + ) + .await; + + // + // SETUP: Trade perps between accounts twice to generate pnl, settle_limit + // + let mut tx = ClientTransaction::new(solana); + tx.add_instruction(PerpPlaceOrderInstruction { + account: helper_account, + perp_market, + owner, + side: Side::Bid, + price_lots, + max_base_lots: 1, + ..PerpPlaceOrderInstruction::default() + }) + .await; + tx.add_instruction(PerpPlaceOrderInstruction { + account: account, + perp_market, + owner, + side: Side::Ask, + price_lots, + max_base_lots: 1, + ..PerpPlaceOrderInstruction::default() + }) + .await; + tx.add_instruction(PerpConsumeEventsInstruction { + perp_market, + mango_accounts: vec![account, helper_account], + }) + .await; + tx.send().await.unwrap(); + + set_perp_stub_oracle_price(solana, group, perp_market, &base_token, admin, adj_price).await; + let mut tx = ClientTransaction::new(solana); + tx.add_instruction(PerpPlaceOrderInstruction { + account: helper_account, + perp_market, + owner, + side: Side::Ask, + price_lots: adj_price_lots, + max_base_lots: 1, + ..PerpPlaceOrderInstruction::default() + }) + .await; + tx.add_instruction(PerpPlaceOrderInstruction { + account: account, + perp_market, + owner, + side: Side::Bid, + price_lots: adj_price_lots, + max_base_lots: 1, + ..PerpPlaceOrderInstruction::default() + }) + .await; + tx.add_instruction(PerpConsumeEventsInstruction { + perp_market, + mango_accounts: vec![account, helper_account], + }) + .await; + tx.send().await.unwrap(); + + set_perp_stub_oracle_price(solana, group, perp_market, &base_token, admin, 1.0).await; + + // Adjust target health: + // full health = 1000 * collat price * 1.0 + pnl + set_bank_stub_oracle_price( + solana, + group, + &collateral_token, + admin, + (health - pnl) as f64 / 1000.0, + ) + .await; + + // Verify we got it right + let account_data = solana.get_account::(account).await; + assert_eq!(account_data.perps[0].quote_position_native(), pnl); + assert_eq!( + account_data.perps[0].recurring_settle_pnl_allowance, + settle_limit + ); + assert_eq!( + account_init_health(solana, account).await.round(), + health as f64 + ); + + (perp_market, account, fresh_liqor) + }; + let mut setup_perp = |health: i64, pnl: i64, settle_limit: i64| { + let out = setup_perp_inner(perp_market_index, health, pnl, settle_limit); + perp_market_index += 1; + out + }; + + let limit_prec = |f: f64| (f * 1000.0).round() / 1000.0; + + let liq_event_amounts = || { + let settlement = solana + .program_log_events::() + .pop() + .map(|v| limit_prec(I80F48::from_bits(v.settlement).to_num::())) + .unwrap_or(0.0); + let (insur, loss) = solana + .program_log_events::() + .pop() + .map(|v| { + ( + I80F48::from_bits(v.insurance_transfer).to_num::(), + limit_prec(I80F48::from_bits(v.socialized_loss).to_num::()), + ) + }) + .unwrap_or((0, 0.0)); + (settlement, insur, loss) + }; + + { + let (perp_market, account, liqor) = setup_perp(-40, -50, 5).await; + fund_insurance(42).await; + + send_tx( + solana, + PerpLiqNegativePnlOrBankruptcyInstruction { + liqor, + liqor_owner: owner, + liqee: account, + perp_market, + max_liab_transfer: u64::MAX, + }, + ) + .await + .unwrap(); + // 27 insurance cover 27*1.6 = 43.2, where the needs is for 40 * 1.05 = 42 + assert_eq!(liq_event_amounts(), (5.0, 27, 0.0)); + } + + Ok(()) +} diff --git a/programs/mango-v4/tests/program_test/mango_client.rs b/programs/mango-v4/tests/program_test/mango_client.rs index 649084e43f..4ccd1119d0 100644 --- a/programs/mango-v4/tests/program_test/mango_client.rs +++ b/programs/mango-v4/tests/program_test/mango_client.rs @@ -1074,6 +1074,7 @@ impl ClientInstruction for TokenRegisterInstruction { platform_liquidation_fee: self.platform_liquidation_fee, disable_asset_liquidation: false, collateral_fee_per_day: 0.0, + tier: "A".to_string(), }; let bank = Pubkey::find_program_address( @@ -1324,6 +1325,7 @@ pub fn token_edit_instruction_default() -> mango_v4::instruction::TokenEdit { disable_asset_liquidation_opt: None, collateral_fee_per_day_opt: None, force_withdraw_opt: None, + tier_opt: None, } } @@ -1914,6 +1916,58 @@ impl ClientInstruction for GroupEdit { } } +pub struct GroupChangeInsuranceFund { + pub group: Pubkey, + pub admin: TestKeypair, + pub payer: TestKeypair, + pub insurance_mint: Pubkey, + pub withdraw_destination: Pubkey, +} +#[async_trait::async_trait(?Send)] +impl ClientInstruction for GroupChangeInsuranceFund { + type Accounts = mango_v4::accounts::GroupChangeInsuranceFund; + type Instruction = mango_v4::instruction::GroupChangeInsuranceFund; + async fn to_instruction( + &self, + account_loader: &(impl ClientAccountLoader + 'async_trait), + ) -> (Self::Accounts, instruction::Instruction) { + let program_id = mango_v4::id(); + let instruction = Self::Instruction {}; + + let group = account_loader.load::(&self.group).await.unwrap(); + + let new_insurance_vault = Pubkey::find_program_address( + &[ + b"InsuranceVault".as_ref(), + self.group.as_ref(), + self.insurance_mint.as_ref(), + ], + &program_id, + ) + .0; + + let accounts = Self::Accounts { + group: self.group, + admin: self.admin.pubkey(), + insurance_vault: group.insurance_vault, + withdraw_destination: self.withdraw_destination, + new_insurance_mint: self.insurance_mint, + new_insurance_vault, + payer: self.payer.pubkey(), + token_program: Token::id(), + system_program: System::id(), + rent: sysvar::rent::Rent::id(), + }; + + let instruction = make_instruction(program_id, &accounts, &instruction); + (accounts, instruction) + } + + fn signers(&self) -> Vec { + vec![self.admin, self.payer] + } +} + pub struct IxGateSetInstruction { pub group: Pubkey, pub admin: TestKeypair, @@ -1957,21 +2011,17 @@ impl ClientInstruction for GroupCloseInstruction { type Instruction = mango_v4::instruction::GroupClose; async fn to_instruction( &self, - _account_loader: &(impl ClientAccountLoader + 'async_trait), + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction {}; - let insurance_vault = Pubkey::find_program_address( - &[b"InsuranceVault".as_ref(), self.group.as_ref()], - &program_id, - ) - .0; + let group = account_loader.load::(&self.group).await.unwrap(); let accounts = Self::Accounts { group: self.group, admin: self.admin.pubkey(), - insurance_vault, + insurance_vault: group.insurance_vault, sol_destination: self.sol_destination, token_program: Token::id(), }; @@ -3253,21 +3303,11 @@ impl ClientInstruction for TokenLiqBankruptcyInstruction { .load_mango_account(&self.liqor) .await .unwrap(); - let health_check_metas = derive_liquidation_remaining_account_metas( - account_loader, - &liqee, - &liqor, - QUOTE_TOKEN_INDEX, - 0, - liab_mint_info.token_index, - 0, - ) - .await; let group_key = liqee.fixed.group; let group: Group = account_loader.load(&group_key).await.unwrap(); - let quote_mint_info = Pubkey::find_program_address( + let insurance_mint_info = Pubkey::find_program_address( &[ b"MintInfo".as_ref(), liqee.fixed.group.as_ref(), @@ -3276,13 +3316,19 @@ impl ClientInstruction for TokenLiqBankruptcyInstruction { &program_id, ) .0; - let quote_mint_info: MintInfo = account_loader.load("e_mint_info).await.unwrap(); + let insurance_mint_info: MintInfo = + account_loader.load(&insurance_mint_info).await.unwrap(); - let insurance_vault = Pubkey::find_program_address( - &[b"InsuranceVault".as_ref(), group_key.as_ref()], - &program_id, + let health_check_metas = derive_liquidation_remaining_account_metas( + account_loader, + &liqee, + &liqor, + insurance_mint_info.token_index, + 0, + liab_mint_info.token_index, + 0, ) - .0; + .await; let accounts = Self::Accounts { group: group_key, @@ -3290,8 +3336,8 @@ impl ClientInstruction for TokenLiqBankruptcyInstruction { liqor: self.liqor, liqor_owner: self.liqor_owner.pubkey(), liab_mint_info: self.liab_mint_info, - quote_vault: quote_mint_info.first_vault(), - insurance_vault, + quote_vault: insurance_mint_info.first_vault(), + insurance_vault: group.insurance_vault, token_program: Token::id(), }; @@ -4344,7 +4390,6 @@ impl ClientInstruction for PerpLiqNegativePnlOrBankruptcyInstruction { }; let perp_market: PerpMarket = account_loader.load(&self.perp_market).await.unwrap(); - let group_key = perp_market.group; let liqor = account_loader .load_mango_account(&self.liqor) .await @@ -4353,23 +4398,36 @@ impl ClientInstruction for PerpLiqNegativePnlOrBankruptcyInstruction { .load_mango_account(&self.liqee) .await .unwrap(); + + let group_key = liqee.fixed.group; + let group: Group = account_loader.load(&group_key).await.unwrap(); + + let insurance_mint_info = Pubkey::find_program_address( + &[ + b"MintInfo".as_ref(), + liqee.fixed.group.as_ref(), + group.insurance_mint.as_ref(), + ], + &program_id, + ) + .0; + let insurance_mint_info: MintInfo = + account_loader.load(&insurance_mint_info).await.unwrap(); + let health_check_metas = derive_liquidation_remaining_account_metas( account_loader, &liqee, &liqor, - TokenIndex::MAX, + insurance_mint_info.token_index, 0, TokenIndex::MAX, 0, ) .await; - let group = account_loader.load::(&group_key).await.unwrap(); let settle_mint_info = get_mint_info_by_token_index(account_loader, &liqee, perp_market.settle_token_index) .await; - let insurance_mint_info = - get_mint_info_by_token_index(account_loader, &liqee, QUOTE_TOKEN_INDEX).await; let accounts = Self::Accounts { group: group_key, diff --git a/ts/client/src/accounts/bank.ts b/ts/client/src/accounts/bank.ts index d4c8c44371..13da794e2e 100644 --- a/ts/client/src/accounts/bank.ts +++ b/ts/client/src/accounts/bank.ts @@ -83,6 +83,7 @@ export class Bank implements BankForHealth { public platformLiquidationFee: I80F48; public collectedLiquidationFees: I80F48; public collectedCollateralFees: I80F48; + public tier: string; static from( publicKey: PublicKey, @@ -151,6 +152,7 @@ export class Bank implements BankForHealth { collectedLiquidationFees: I80F48Dto; collectedCollateralFees: I80F48Dto; collateralFeePerDay: number; + tier: number[]; }, ): Bank { return new Bank( @@ -218,6 +220,7 @@ export class Bank implements BankForHealth { obj.disableAssetLiquidation == 0, obj.collectedCollateralFees, obj.collateralFeePerDay, + obj.tier, obj.forceWithdraw == 1, ); } @@ -287,6 +290,7 @@ export class Bank implements BankForHealth { public allowAssetLiquidation: boolean, collectedCollateralFees: I80F48Dto, public collateralFeePerDay: number, + tier: number[], public forceWithdraw: boolean, ) { this.name = utf8.decode(new Uint8Array(name)).split('\x00')[0]; @@ -325,6 +329,7 @@ export class Bank implements BankForHealth { this._uiPrice = undefined; this._oracleLastUpdatedSlot = undefined; this._oracleProvider = undefined; + this.tier = utf8.decode(new Uint8Array(tier)).split('\x00')[0]; } toString(): string { diff --git a/ts/client/src/client.ts b/ts/client/src/client.ts index 8d60a6e68b..db09101ea4 100644 --- a/ts/client/src/client.ts +++ b/ts/client/src/client.ts @@ -361,6 +361,24 @@ export class MangoClient { return await this.sendAndConfirmTransactionForGroup(group, [ix]); } + public async groupChangeInsuranceFund( + group: Group, + withdrawDestination: PublicKey, + newInsuranceMint: PublicKey, + ): Promise { + const ix = await this.program.methods + .groupChangeInsuranceFund() + .accounts({ + group: group.publicKey, + admin: (this.program.provider as AnchorProvider).wallet.publicKey, + insuranceVault: group.insuranceVault, + withdrawDestination, + newInsuranceMint, + }) + .instruction(); + return await this.sendAndConfirmTransactionForGroup(group, [ix]); + } + public async ixGateSet( group: Group, ixGateParams: IxGateParams, @@ -502,6 +520,7 @@ export class MangoClient { params.platformLiquidationFee, params.disableAssetLiquidation, params.collateralFeePerDay, + params.tier, ) .accounts({ group: group.publicKey, @@ -592,6 +611,7 @@ export class MangoClient { params.disableAssetLiquidation, params.collateralFeePerDay, params.forceWithdraw, + params.tier, ) .accounts({ group: group.publicKey, diff --git a/ts/client/src/clientIxParamBuilder.ts b/ts/client/src/clientIxParamBuilder.ts index ddfb1dd51f..17c7aff057 100644 --- a/ts/client/src/clientIxParamBuilder.ts +++ b/ts/client/src/clientIxParamBuilder.ts @@ -32,11 +32,12 @@ export interface TokenRegisterParams { platformLiquidationFee: number; disableAssetLiquidation: boolean; collateralFeePerDay: number; + tier: string; } export const DefaultTokenRegisterParams: TokenRegisterParams = { oracleConfig: { - confFilter: 0, + confFilter: 0.3, maxStalenessSlots: null, }, groupInsuranceFund: false, @@ -74,6 +75,7 @@ export const DefaultTokenRegisterParams: TokenRegisterParams = { platformLiquidationFee: 0.0, disableAssetLiquidation: false, collateralFeePerDay: 0.0, + tier: '', }; export interface TokenEditParams { @@ -117,6 +119,7 @@ export interface TokenEditParams { platformLiquidationFee: number | null; disableAssetLiquidation: boolean | null; collateralFeePerDay: number | null; + tier: string | null; forceWithdraw: boolean | null; } @@ -161,6 +164,7 @@ export const NullTokenEditParams: TokenEditParams = { platformLiquidationFee: null, disableAssetLiquidation: null, collateralFeePerDay: null, + tier: null, forceWithdraw: null, }; @@ -312,6 +316,7 @@ export interface IxGateParams { TokenForceWithdraw: boolean; SequenceCheck: boolean; HealthCheck: boolean; + GroupChangeInsuranceFund: boolean; } // Default with all ixs enabled, use with buildIxGate @@ -394,6 +399,7 @@ export const TrueIxGateParams: IxGateParams = { TokenForceWithdraw: true, SequenceCheck: true, HealthCheck: true, + GroupChangeInsuranceFund: true, }; // build ix gate e.g. buildIxGate(Builder(TrueIxGateParams).TokenDeposit(false).build()).toNumber(), @@ -486,6 +492,7 @@ export function buildIxGate(p: IxGateParams): BN { toggleIx(ixGate, p, 'TokenForceWithdraw', 72); toggleIx(ixGate, p, 'SequenceCheck', 73); toggleIx(ixGate, p, 'HealthCheck', 74); + toggleIx(ixGate, p, 'GroupChangeInsuranceFund', 76); return ixGate; } diff --git a/ts/client/src/mango_v4.ts b/ts/client/src/mango_v4.ts index 31233e3d93..bae89ac153 100644 --- a/ts/client/src/mango_v4.ts +++ b/ts/client/src/mango_v4.ts @@ -1,5 +1,5 @@ export type MangoV4 = { - "version": "0.24.2", + "version": "0.24.3", "name": "mango_v4", "instructions": [ { @@ -326,6 +326,86 @@ export type MangoV4 = { } ] }, + { + "name": "groupChangeInsuranceFund", + "accounts": [ + { + "name": "group", + "isMut": true, + "isSigner": false, + "relations": [ + "insurance_vault", + "admin" + ] + }, + { + "name": "admin", + "isMut": false, + "isSigner": true + }, + { + "name": "insuranceVault", + "isMut": true, + "isSigner": false + }, + { + "name": "withdrawDestination", + "isMut": true, + "isSigner": false + }, + { + "name": "newInsuranceMint", + "isMut": false, + "isSigner": false + }, + { + "name": "newInsuranceVault", + "isMut": true, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "InsuranceVault" + }, + { + "kind": "account", + "type": "publicKey", + "path": "group" + }, + { + "kind": "account", + "type": "publicKey", + "account": "Mint", + "path": "new_insurance_mint" + } + ] + } + }, + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, { "name": "ixGateSet", "accounts": [ @@ -645,6 +725,10 @@ export type MangoV4 = { { "name": "collateralFeePerDay", "type": "f32" + }, + { + "name": "tier", + "type": "string" } ] }, @@ -1073,6 +1157,12 @@ export type MangoV4 = { "type": { "option": "bool" } + }, + { + "name": "tierOpt", + "type": { + "option": "string" + } } ] }, @@ -7584,7 +7674,7 @@ export type MangoV4 = { "type": "u8" }, { - "name": "padding", + "name": "tier", "type": { "array": [ "u8", @@ -10063,9 +10153,13 @@ export type MangoV4 = { "type": { "array": [ "u8", - 119 + 111 ] } + }, + { + "name": "forceAlign", + "type": "u64" } ] } @@ -11120,6 +11214,9 @@ export type MangoV4 = { }, { "name": "HealthCheck" + }, + { + "name": "GroupChangeInsuranceFund" } ] } @@ -14488,7 +14585,7 @@ export type MangoV4 = { }; export const IDL: MangoV4 = { - "version": "0.24.2", + "version": "0.24.3", "name": "mango_v4", "instructions": [ { @@ -14815,6 +14912,86 @@ export const IDL: MangoV4 = { } ] }, + { + "name": "groupChangeInsuranceFund", + "accounts": [ + { + "name": "group", + "isMut": true, + "isSigner": false, + "relations": [ + "insurance_vault", + "admin" + ] + }, + { + "name": "admin", + "isMut": false, + "isSigner": true + }, + { + "name": "insuranceVault", + "isMut": true, + "isSigner": false + }, + { + "name": "withdrawDestination", + "isMut": true, + "isSigner": false + }, + { + "name": "newInsuranceMint", + "isMut": false, + "isSigner": false + }, + { + "name": "newInsuranceVault", + "isMut": true, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "InsuranceVault" + }, + { + "kind": "account", + "type": "publicKey", + "path": "group" + }, + { + "kind": "account", + "type": "publicKey", + "account": "Mint", + "path": "new_insurance_mint" + } + ] + } + }, + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, { "name": "ixGateSet", "accounts": [ @@ -15134,6 +15311,10 @@ export const IDL: MangoV4 = { { "name": "collateralFeePerDay", "type": "f32" + }, + { + "name": "tier", + "type": "string" } ] }, @@ -15562,6 +15743,12 @@ export const IDL: MangoV4 = { "type": { "option": "bool" } + }, + { + "name": "tierOpt", + "type": { + "option": "string" + } } ] }, @@ -22073,7 +22260,7 @@ export const IDL: MangoV4 = { "type": "u8" }, { - "name": "padding", + "name": "tier", "type": { "array": [ "u8", @@ -24552,9 +24739,13 @@ export const IDL: MangoV4 = { "type": { "array": [ "u8", - 119 + 111 ] } + }, + { + "name": "forceAlign", + "type": "u64" } ] } @@ -25609,6 +25800,9 @@ export const IDL: MangoV4 = { }, { "name": "HealthCheck" + }, + { + "name": "GroupChangeInsuranceFund" } ] }