diff --git a/Cargo.lock b/Cargo.lock index 0d665bd12..6e30190d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1276,6 +1276,7 @@ dependencies = [ "solana-account-decoder", "solana-client", "solana-sdk", + "spl-associated-token-account", "thiserror", "tokio", ] diff --git a/cli/src/main.rs b/cli/src/main.rs index 6a46a7378..f214caa61 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -176,7 +176,13 @@ fn main() -> Result<(), anyhow::Error> { let input_mint = client::pubkey_from_cli(&cmd.input_mint); let output_mint = client::pubkey_from_cli(&cmd.output_mint); let client = MangoClient::new_for_existing_account(client, account, owner)?; - let txsig = client.jupiter_swap(input_mint, output_mint, cmd.amount, cmd.slippage)?; + let txsig = client.jupiter_swap( + input_mint, + output_mint, + cmd.amount, + cmd.slippage, + client::JupiterSwapMode::ExactIn, + )?; println!("{}", txsig); } Command::GroupAddress { creator, num } => { diff --git a/client/Cargo.toml b/client/Cargo.toml index e8f333ef9..252018a6e 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -21,6 +21,7 @@ shellexpand = "2.1.0" solana-account-decoder = "~1.10.29" solana-client = "~1.10.29" solana-sdk = "~1.10.29" +spl-associated-token-account = "1.0.3" thiserror = "1.0.31" log = "0.4" reqwest = "0.11.11" diff --git a/client/src/chain_data_fetcher.rs b/client/src/chain_data_fetcher.rs index 68ccec7ec..1def80c58 100644 --- a/client/src/chain_data_fetcher.rs +++ b/client/src/chain_data_fetcher.rs @@ -1,4 +1,6 @@ use std::sync::{Arc, RwLock}; +use std::thread; +use std::time::{Duration, Instant}; use crate::chain_data::*; @@ -11,7 +13,9 @@ use anyhow::Context; use solana_client::rpc_client::RpcClient; use solana_sdk::account::{AccountSharedData, ReadableAccount}; +use solana_sdk::clock::Slot; use solana_sdk::pubkey::Pubkey; +use solana_sdk::signature::Signature; pub struct AccountFetcher { pub chain_data: Arc>, @@ -66,11 +70,12 @@ impl AccountFetcher { .clone()) } - pub fn refresh_account_via_rpc(&self, address: &Pubkey) -> anyhow::Result<()> { + pub fn refresh_account_via_rpc(&self, address: &Pubkey) -> anyhow::Result { let response = self .rpc .get_account_with_commitment(&address, self.rpc.commitment()) .with_context(|| format!("refresh account {} via rpc", address))?; + let slot = response.context.slot; let account = response .value .ok_or(anchor_client::ClientError::AccountNotFound) @@ -85,6 +90,43 @@ impl AccountFetcher { }, ); + Ok(slot) + } + + /// Return the maximum slot reported for the processing of the signatures + pub fn transaction_max_slot(&self, signatures: &[Signature]) -> anyhow::Result { + let statuses = self.rpc.get_signature_statuses(signatures)?.value; + Ok(statuses + .iter() + .map(|status_opt| status_opt.as_ref().map(|status| status.slot).unwrap_or(0)) + .max() + .unwrap_or(0)) + } + + /// Return success once all addresses have data >= min_slot + pub fn refresh_accounts_via_rpc_until_slot( + &self, + addresses: &[Pubkey], + min_slot: Slot, + timeout: Duration, + ) -> anyhow::Result<()> { + let start = Instant::now(); + for address in addresses { + loop { + if start.elapsed() > timeout { + anyhow::bail!( + "timeout while waiting for data for {} that's newer than slot {}", + address, + min_slot + ); + } + let data_slot = self.refresh_account_via_rpc(address)?; + if data_slot >= min_slot { + break; + } + thread::sleep(Duration::from_millis(500)); + } + } Ok(()) } } diff --git a/client/src/client.rs b/client/src/client.rs index 8d069d2c8..e286d34f1 100644 --- a/client/src/client.rs +++ b/client/src/client.rs @@ -395,6 +395,57 @@ impl MangoClient { .map_err(prettify_client_error) } + pub fn token_withdraw( + &self, + mint: Pubkey, + amount: u64, + allow_borrow: bool, + ) -> anyhow::Result { + let token = self.context.token_by_mint(&mint)?; + let token_index = token.token_index; + let mint_info = token.mint_info; + + let health_check_metas = + self.derive_health_check_remaining_account_metas(vec![token_index], false)?; + + self.program() + .request() + .instruction(create_associated_token_account_idempotent( + &self.owner(), + &self.owner(), + &mint, + )) + .instruction(Instruction { + program_id: mango_v4::id(), + accounts: { + let mut ams = anchor_lang::ToAccountMetas::to_account_metas( + &mango_v4::accounts::TokenWithdraw { + group: self.group(), + account: self.mango_account_address, + owner: self.owner(), + bank: mint_info.first_bank(), + vault: mint_info.first_vault(), + token_account: get_associated_token_address( + &self.owner(), + &mint_info.mint, + ), + token_program: Token::id(), + }, + None, + ); + ams.extend(health_check_metas.into_iter()); + ams + }, + data: anchor_lang::InstructionData::data(&mango_v4::instruction::TokenWithdraw { + amount, + allow_borrow, + }), + }) + .signer(&self.owner) + .send() + .map_err(prettify_client_error) + } + pub fn get_oracle_price( &self, token_name: &str, @@ -837,33 +888,50 @@ impl MangoClient { &self, input_mint: Pubkey, output_mint: Pubkey, - source_amount: u64, + amount: u64, slippage: f64, + swap_mode: JupiterSwapMode, ) -> anyhow::Result { - self.invoke(self.jupiter_swap_async(input_mint, output_mint, source_amount, slippage)) + self.invoke(self.jupiter_swap_async(input_mint, output_mint, amount, slippage, swap_mode)) } - // Not actually fully async, since it uses the blocking RPC client to send the actual tx - pub async fn jupiter_swap_async( + pub fn jupiter_route( &self, input_mint: Pubkey, output_mint: Pubkey, - source_amount: u64, + amount: u64, slippage: f64, - ) -> anyhow::Result { - let source_token = self.context.token_by_mint(&input_mint)?; - let target_token = self.context.token_by_mint(&output_mint)?; + swap_mode: JupiterSwapMode, + ) -> anyhow::Result { + self.invoke(self.jupiter_route_async(input_mint, output_mint, amount, slippage, swap_mode)) + } + pub async fn jupiter_route_async( + &self, + input_mint: Pubkey, + output_mint: Pubkey, + amount: u64, + slippage: f64, + swap_mode: JupiterSwapMode, + ) -> anyhow::Result { let quote = self .http_client .get("https://quote-api.jup.ag/v1/quote") .query(&[ ("inputMint", input_mint.to_string()), ("outputMint", output_mint.to_string()), - ("amount", format!("{}", source_amount)), + ("amount", format!("{}", amount)), ("onlyDirectRoutes", "true".into()), ("filterTopNResult", "10".into()), ("slippage", format!("{}", slippage)), + ( + "swapMode", + match swap_mode { + JupiterSwapMode::ExactIn => "ExactIn", + JupiterSwapMode::ExactOut => "ExactOut", + } + .into(), + ), ]) .send() .await @@ -889,6 +957,23 @@ impl MangoClient { ) })?; + Ok(route.clone()) + } + + pub async fn jupiter_swap_async( + &self, + input_mint: Pubkey, + output_mint: Pubkey, + amount: u64, + slippage: f64, + swap_mode: JupiterSwapMode, + ) -> anyhow::Result { + let source_token = self.context.token_by_mint(&input_mint)?; + let target_token = self.context.token_by_mint(&output_mint)?; + let route = self + .jupiter_route_async(input_mint, output_mint, amount, slippage, swap_mode) + .await?; + let swap = self .http_client .post("https://quote-api.jup.ag/v1/swap") @@ -919,19 +1004,12 @@ impl MangoClient { .context("base64 decoding jupiter transaction")?, ) .context("parsing jupiter transaction")?; + let ata_program = anchor_spl::associated_token::ID; + let token_program = anchor_spl::token::ID; + let is_setup_ix = |k: Pubkey| -> bool { k == ata_program || k == token_program }; let jup_ixs = deserialize_instructions(&jup_tx.message) .into_iter() - // TODO: possibly creating associated token accounts if they don't exist yet is good?! - // we could squeeze the FlashLoan instructions in the middle: - // - beginning AToken... - // - FlashLoanBegin - // - other JUP ix - // - FlashLoanEnd - // - ending AToken - .filter(|ix| { - ix.program_id - != Pubkey::from_str("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL").unwrap() - }) + .filter(|ix| !is_setup_ix(ix.program_id)) .collect::>(); let bank_ams = [ @@ -962,7 +1040,14 @@ impl MangoClient { }) .collect::>(); - let loan_amounts = vec![source_amount, 0u64]; + let loan_amounts = vec![ + match swap_mode { + JupiterSwapMode::ExactIn => amount, + // in amount + slippage + JupiterSwapMode::ExactOut => route.other_amount_threshold, + }, + 0u64, + ]; // This relies on the fact that health account banks will be identical to the first_bank above! let health_ams = self @@ -973,7 +1058,19 @@ impl MangoClient { .context("building health accounts")?; let program = self.program(); - let mut builder = program.request().instruction(Instruction { + let mut builder = program.request(); + + builder = builder.instruction(create_associated_token_account_idempotent( + &self.owner.pubkey(), + &self.owner.pubkey(), + &source_token.mint_info.mint, + )); + builder = builder.instruction(create_associated_token_account_idempotent( + &self.owner.pubkey(), + &self.owner.pubkey(), + &target_token.mint_info.mint, + )); + builder = builder.instruction(Instruction { program_id: mango_v4::id(), accounts: { let mut ams = anchor_lang::ToAccountMetas::to_account_metas( @@ -994,7 +1091,7 @@ impl MangoClient { }), }); for ix in jup_ixs { - builder = builder.instruction(ix); + builder = builder.instruction(ix.clone()); } builder = builder.instruction(Instruction { program_id: mango_v4::id(), @@ -1094,6 +1191,12 @@ pub fn prettify_client_error(err: anchor_client::ClientError) -> anyhow::Error { err.into() } +#[derive(Clone, Copy)] +pub enum JupiterSwapMode { + ExactIn, + ExactOut, +} + pub fn keypair_from_cli(keypair: &str) -> Keypair { let maybe_keypair = keypair::read_keypair(&mut keypair.as_bytes()); match maybe_keypair { @@ -1128,3 +1231,17 @@ fn to_writable_account_meta(pubkey: Pubkey) -> AccountMeta { is_signer: false, } } + +// FUTURE: use spl_associated_token_account::instruction::create_associated_token_account_idempotent +// Right now anchor depends on an earlier version of this package... +fn create_associated_token_account_idempotent( + funder: &Pubkey, + owner: &Pubkey, + mint: &Pubkey, +) -> Instruction { + let mut instr = spl_associated_token_account::instruction::create_associated_token_account( + funder, owner, mint, + ); + instr.data = vec![0x1]; // CreateIdempotent + instr +} diff --git a/client/src/jupiter.rs b/client/src/jupiter.rs index d9e70af56..56c2e9633 100644 --- a/client/src/jupiter.rs +++ b/client/src/jupiter.rs @@ -15,7 +15,7 @@ pub struct QueryRoute { pub out_amount: u64, pub amount: u64, pub other_amount_threshold: u64, - pub out_amount_with_slippage: u64, + pub out_amount_with_slippage: Option, pub swap_mode: String, pub price_impact_pct: f64, pub market_infos: Vec, @@ -33,7 +33,7 @@ pub struct QueryMarketInfo { pub lp_fee: QueryFee, pub platform_fee: QueryFee, pub not_enough_liquidity: bool, - pub price_impact_pct: f64, + pub price_impact_pct: Option, } #[derive(Deserialize, Serialize, Debug, Clone)] @@ -41,7 +41,7 @@ pub struct QueryMarketInfo { pub struct QueryFee { pub amount: u64, pub mint: String, - pub pct: f64, + pub pct: Option, } #[derive(Deserialize, Serialize, Debug, Clone)] @@ -49,6 +49,7 @@ pub struct QueryFee { pub struct SwapRequest { pub route: QueryRoute, pub user_public_key: String, + #[serde(rename = "wrapUnwrapSOL")] pub wrap_unwrap_sol: bool, } diff --git a/client/src/util.rs b/client/src/util.rs index 86a3d69c7..728d5eb0e 100644 --- a/client/src/util.rs +++ b/client/src/util.rs @@ -1,4 +1,15 @@ -use solana_sdk::signature::Keypair; +use solana_client::{ + client_error::Result as ClientResult, rpc_client::RpcClient, rpc_request::RpcError, +}; +use solana_sdk::transaction::Transaction; +use solana_sdk::{ + clock::Slot, + commitment_config::CommitmentConfig, + signature::{Keypair, Signature}, + transaction::uses_durable_nonce, +}; + +use std::{thread, time}; // #[allow(dead_code)] // pub fn retry(request: impl Fn() -> Result) -> anyhow::Result { @@ -24,3 +35,64 @@ impl MyClone for Keypair { Self::from_bytes(&self.to_bytes()).unwrap() } } + +/// A copy of RpcClient::send_and_confirm_transaction that returns the slot the +/// transaction confirmed in. +pub fn send_and_confirm_transaction( + rpc_client: &RpcClient, + transaction: &Transaction, +) -> ClientResult<(Signature, Slot)> { + const SEND_RETRIES: usize = 1; + const GET_STATUS_RETRIES: usize = usize::MAX; + + 'sending: for _ in 0..SEND_RETRIES { + let signature = rpc_client.send_transaction(transaction)?; + + let recent_blockhash = if uses_durable_nonce(transaction).is_some() { + let (recent_blockhash, ..) = + rpc_client.get_latest_blockhash_with_commitment(CommitmentConfig::processed())?; + recent_blockhash + } else { + transaction.message.recent_blockhash + }; + + for status_retry in 0..GET_STATUS_RETRIES { + let response = rpc_client.get_signature_statuses(&[signature])?.value; + match response[0] + .clone() + .filter(|result| result.satisfies_commitment(rpc_client.commitment())) + { + Some(tx_status) => { + return if let Some(e) = tx_status.err { + Err(e.into()) + } else { + Ok((signature, tx_status.slot)) + }; + } + None => { + if !rpc_client + .is_blockhash_valid(&recent_blockhash, CommitmentConfig::processed())? + { + // Block hash is not found by some reason + break 'sending; + } else if cfg!(not(test)) + // Ignore sleep at last step. + && status_retry < GET_STATUS_RETRIES + { + // Retry twice a second + thread::sleep(time::Duration::from_millis(500)); + continue; + } + } + } + } + } + + Err(RpcError::ForUser( + "unable to confirm transaction. \ + This can happen in situations such as transaction expiration \ + and insufficient fee-payer funds" + .to_string(), + ) + .into()) +} diff --git a/liquidator/src/liquidate.rs b/liquidator/src/liquidate.rs index f15d810ad..1b9aca720 100644 --- a/liquidator/src/liquidate.rs +++ b/liquidator/src/liquidate.rs @@ -1,13 +1,20 @@ +use std::time::Duration; + use crate::account_shared_data::KeyedAccountSharedData; use client::{chain_data, AccountFetcher, MangoClient, MangoClientError, MangoGroupContext}; use mango_v4::state::{ new_health_cache, oracle_price, Bank, FixedOrderAccountRetriever, HealthCache, HealthType, - MangoAccountValue, TokenIndex, + MangoAccountValue, TokenIndex, QUOTE_TOKEN_INDEX, }; use {anyhow::Context, fixed::types::I80F48, solana_sdk::pubkey::Pubkey}; +pub struct Config { + pub min_health_ratio: f64, + pub refresh_timeout: Duration, +} + pub fn new_health_cache_( context: &MangoGroupContext, account_fetcher: &chain_data::AccountFetcher, @@ -36,14 +43,68 @@ pub fn new_health_cache_( new_health_cache(&account.borrow(), &retriever).context("make health cache") } +pub fn jupiter_market_can_buy( + mango_client: &MangoClient, + token: TokenIndex, + quote_token: TokenIndex, +) -> bool { + if token == quote_token { + return true; + } + let token_mint = mango_client.context.token(token).mint_info.mint; + let quote_token_mint = mango_client.context.token(quote_token).mint_info.mint; + + // Consider a market alive if we can swap $10 worth at 1% slippage + // TODO: configurable + // TODO: cache this, no need to recheck often + let quote_amount = 10_000_000u64; + let slippage = 1.0; + mango_client + .jupiter_route( + quote_token_mint, + token_mint, + quote_amount, + slippage, + client::JupiterSwapMode::ExactIn, + ) + .is_ok() +} + +pub fn jupiter_market_can_sell( + mango_client: &MangoClient, + token: TokenIndex, + quote_token: TokenIndex, +) -> bool { + if token == quote_token { + return true; + } + let token_mint = mango_client.context.token(token).mint_info.mint; + let quote_token_mint = mango_client.context.token(quote_token).mint_info.mint; + + // Consider a market alive if we can swap $10 worth at 1% slippage + // TODO: configurable + // TODO: cache this, no need to recheck often + let quote_amount = 10_000_000u64; + let slippage = 1.0; + mango_client + .jupiter_route( + token_mint, + quote_token_mint, + quote_amount, + slippage, + client::JupiterSwapMode::ExactOut, + ) + .is_ok() +} + #[allow(clippy::too_many_arguments)] -pub fn process_account( +pub fn maybe_liquidate_account( mango_client: &MangoClient, account_fetcher: &chain_data::AccountFetcher, pubkey: &Pubkey, -) -> anyhow::Result<()> { - // TODO: configurable - let min_health_ratio = I80F48::from_num(50.0f64); + config: &Config, +) -> anyhow::Result { + let min_health_ratio = I80F48::from_num(config.min_health_ratio); let quote_token_index = 0; let account = account_fetcher.fetch_mango_account(pubkey)?; @@ -52,7 +113,7 @@ pub fn process_account( .health(HealthType::Maint); if maint_health >= 0 && !account.is_bankrupt() { - return Ok(()); + return Ok(false); } log::trace!( @@ -85,11 +146,11 @@ pub fn process_account( )?; Ok(( token_position.token_index, - bank, + price, token_position.native(&bank) * price, )) }) - .collect::>>()?; + .collect::>>()?; tokens.sort_by(|a, b| a.2.cmp(&b.2)); let get_max_liab_transfer = |source, target| -> anyhow::Result { @@ -111,31 +172,69 @@ pub fn process_account( }; // try liquidating - if account.is_bankrupt() { + let txsig = if account.is_bankrupt() { if tokens.is_empty() { anyhow::bail!("mango account {}, is bankrupt has no active tokens", pubkey); } - let (liab_token_index, _liab_bank, _liab_price) = tokens.first().unwrap(); + let liab_token_index = tokens + .iter() + .find(|(liab_token_index, _liab_price, liab_usdc_equivalent)| { + liab_usdc_equivalent.is_negative() + && jupiter_market_can_buy(mango_client, *liab_token_index, QUOTE_TOKEN_INDEX) + }) + .ok_or_else(|| { + anyhow::anyhow!( + "mango account {}, has no liab tokens that are purchasable for USDC: {:?}", + pubkey, + tokens + ) + })? + .0; - let max_liab_transfer = get_max_liab_transfer(*liab_token_index, quote_token_index)?; + let max_liab_transfer = get_max_liab_transfer(liab_token_index, quote_token_index)?; let sig = mango_client - .liq_token_bankruptcy((pubkey, &account), *liab_token_index, max_liab_transfer) + .liq_token_bankruptcy((pubkey, &account), liab_token_index, max_liab_transfer) .context("sending liq_token_bankruptcy")?; log::info!( - "Liquidated bankruptcy for {}..., maint_health was {}, tx sig {:?}", - &pubkey.to_string()[..3], + "Liquidated bankruptcy for {}, maint_health was {}, tx sig {:?}", + pubkey, maint_health, sig ); + sig } else if maint_health.is_negative() { - if tokens.len() < 2 { - anyhow::bail!("mango account {}, has less than 2 active tokens", pubkey); - } - let (asset_token_index, _asset_bank, _asset_price) = tokens.last().unwrap(); - let (liab_token_index, _liab_bank, _liab_price) = tokens.first().unwrap(); + let asset_token_index = tokens + .iter() + .rev() + .find(|(asset_token_index, _asset_price, asset_usdc_equivalent)| { + asset_usdc_equivalent.is_positive() + && jupiter_market_can_sell(mango_client, *asset_token_index, QUOTE_TOKEN_INDEX) + }) + .ok_or_else(|| { + anyhow::anyhow!( + "mango account {}, has no asset tokens that are sellable for USDC: {:?}", + pubkey, + tokens + ) + })? + .0; + let liab_token_index = tokens + .iter() + .find(|(liab_token_index, _liab_price, liab_usdc_equivalent)| { + liab_usdc_equivalent.is_negative() + && jupiter_market_can_buy(mango_client, *liab_token_index, QUOTE_TOKEN_INDEX) + }) + .ok_or_else(|| { + anyhow::anyhow!( + "mango account {}, has no liab tokens that are purchasable for USDC: {:?}", + pubkey, + tokens + ) + })? + .0; - let max_liab_transfer = get_max_liab_transfer(*liab_token_index, *asset_token_index) + let max_liab_transfer = get_max_liab_transfer(liab_token_index, asset_token_index) .context("getting max_liab_transfer")?; // @@ -146,29 +245,43 @@ pub fn process_account( let sig = mango_client .liq_token_with_token( (pubkey, &account), - *asset_token_index, - *liab_token_index, + asset_token_index, + liab_token_index, max_liab_transfer, ) .context("sending liq_token_with_token")?; log::info!( - "Liquidated token with token for {}..., maint_health was {}, tx sig {:?}", - &pubkey.to_string()[..3], + "Liquidated token with token for {}, maint_health was {}, tx sig {:?}", + pubkey, maint_health, sig ); + sig + } else { + return Ok(false); + }; + + let slot = account_fetcher.transaction_max_slot(&[txsig])?; + if let Err(e) = account_fetcher.refresh_accounts_via_rpc_until_slot( + &[*pubkey, mango_client.mango_account_address], + slot, + config.refresh_timeout, + ) { + log::info!("could not refresh after liquidation: {}", e); } - Ok(()) + + Ok(true) } #[allow(clippy::too_many_arguments)] -pub fn process_accounts<'a>( +pub fn maybe_liquidate_one<'a>( mango_client: &MangoClient, account_fetcher: &chain_data::AccountFetcher, accounts: impl Iterator, -) -> anyhow::Result<()> { + config: &Config, +) -> bool { for pubkey in accounts { - match process_account(mango_client, account_fetcher, pubkey) { + match maybe_liquidate_account(mango_client, account_fetcher, pubkey, config) { Err(err) => { // Not all errors need to be raised to the user's attention. let mut log_level = log::Level::Error; @@ -185,9 +298,10 @@ pub fn process_accounts<'a>( }; log::log!(log_level, "liquidating account {}: {:?}", pubkey, err); } + Ok(true) => return true, _ => {} }; } - Ok(()) + false } diff --git a/liquidator/src/main.rs b/liquidator/src/main.rs index 11a6b9320..7c1e5741a 100644 --- a/liquidator/src/main.rs +++ b/liquidator/src/main.rs @@ -15,6 +15,7 @@ use std::collections::HashSet; pub mod account_shared_data; pub mod liquidate; pub mod metrics; +pub mod rebalance; pub mod snapshot_source; pub mod util; pub mod websocket_source; @@ -67,13 +68,20 @@ struct Cli { #[clap(long, env, default_value = "300")] snapshot_interval_secs: u64, - // how many getMultipleAccounts requests to send in parallel + /// how many getMultipleAccounts requests to send in parallel #[clap(long, env, default_value = "10")] parallel_rpc_requests: usize, - // typically 100 is the max number for getMultipleAccounts + /// typically 100 is the max number of accounts getMultipleAccounts will retrieve at once #[clap(long, env, default_value = "100")] get_multiple_accounts_count: usize, + + /// liquidator health ratio should not fall below this value + #[clap(long, env, default_value = "50")] + min_health_ratio: f64, + + #[clap(long, env, default_value = "1")] + rebalance_slippage: f64, } pub fn encode_address(addr: &Pubkey) -> String { @@ -114,8 +122,7 @@ async fn main() -> anyhow::Result<()> { let group_context = MangoGroupContext::new_from_rpc(mango_group, cluster.clone(), commitment)?; - // TODO: this is all oracles, not just pyth! - let mango_pyth_oracles = group_context + let mango_oracles = group_context .tokens .values() .map(|value| value.mint_info.oracle) @@ -145,7 +152,7 @@ async fn main() -> anyhow::Result<()> { serum_program: cli.serum_program, open_orders_authority: mango_group, }, - mango_pyth_oracles.clone(), + mango_oracles.clone(), websocket_sender, ); @@ -169,7 +176,7 @@ async fn main() -> anyhow::Result<()> { snapshot_interval: std::time::Duration::from_secs(cli.snapshot_interval_secs), min_slot: first_websocket_slot + 10, }, - mango_pyth_oracles, + mango_oracles, snapshot_sender, ); @@ -183,14 +190,6 @@ async fn main() -> anyhow::Result<()> { let mut oracles = HashSet::::new(); let mut perp_markets = HashMap::::new(); - // List of accounts that are potentially liquidatable. - // - // Used to send a different message for newly liqudatable accounts and - // accounts that are still liquidatable but not fresh anymore. - // - // This should actually be done per connected websocket client, and not globally. - let _current_candidates = HashSet::::new(); - // Is the first snapshot done? Only start checking account health when it is. let mut one_snapshot_done = false; @@ -211,6 +210,19 @@ async fn main() -> anyhow::Result<()> { )?) }; + let liq_config = liquidate::Config { + min_health_ratio: cli.min_health_ratio, + // TODO: config + refresh_timeout: Duration::from_secs(30), + }; + + let mut rebalance_interval = tokio::time::interval(Duration::from_secs(5)); + let rebalance_config = rebalance::Config { + slippage: cli.rebalance_slippage, + // TODO: config + refresh_timeout: Duration::from_secs(30), + }; + info!("main loop"); loop { tokio::select! { @@ -238,14 +250,13 @@ async fn main() -> anyhow::Result<()> { continue; } - if let Err(err) = liquidate::process_accounts( - &mango_client, - &account_fetcher, - std::iter::once(&account_write.pubkey), - - ) { - warn!("could not process account {}: {:?}", account_write.pubkey, err); - } + liquidate( + &mango_client, + &account_fetcher, + std::iter::once(&account_write.pubkey), + &liq_config, + &rebalance_config, + )?; } if is_mango_bank(&account_write.account, &mango_program, &mango_group).is_some() || oracles.contains(&account_write.pubkey) { @@ -261,21 +272,13 @@ async fn main() -> anyhow::Result<()> { log::debug!("change to oracle {}", &account_write.pubkey); } - // check health of all accounts - // - // TODO: This could be done asynchronously by calling - // let accounts = chain_data.accounts_snapshot(); - // and then working with the snapshot of the data - // - // However, this currently takes like 50ms for me in release builds, - // so optimizing much seems unnecessary. - if let Err(err) = liquidate::process_accounts( - &mango_client, - &account_fetcher, - mango_accounts.iter(), - ) { - warn!("could not process accounts: {:?}", err); - } + liquidate( + &mango_client, + &account_fetcher, + mango_accounts.iter(), + &liq_config, + &rebalance_config, + )?; } } }, @@ -302,19 +305,46 @@ async fn main() -> anyhow::Result<()> { snapshot_source::update_chain_data(&mut chain_data.write().unwrap(), message); one_snapshot_done = true; - // trigger a full health check - if let Err(err) = liquidate::process_accounts( - &mango_client, - &account_fetcher, - mango_accounts.iter(), - ) { - warn!("could not process accounts: {:?}", err); - } + liquidate( + &mango_client, + &account_fetcher, + mango_accounts.iter(), + &liq_config, + &rebalance_config, + )?; }, + + _ = rebalance_interval.tick() => { + if one_snapshot_done { + if let Err(err) = rebalance::zero_all_non_quote(&mango_client, &account_fetcher, &cli.liqor_mango_account, &rebalance_config) { + log::error!("failed to rebalance liqor: {:?}", err); + } + } + } } } } +fn liquidate<'a>( + mango_client: &MangoClient, + account_fetcher: &chain_data::AccountFetcher, + accounts: impl Iterator, + config: &liquidate::Config, + rebalance_config: &rebalance::Config, +) -> anyhow::Result<()> { + if !liquidate::maybe_liquidate_one(&mango_client, &account_fetcher, accounts, &config) { + return Ok(()); + } + + let liqor = &mango_client.mango_account_address; + if let Err(err) = + rebalance::zero_all_non_quote(mango_client, account_fetcher, liqor, &rebalance_config) + { + log::error!("failed to rebalance liqor: {:?}", err); + } + Ok(()) +} + fn start_chain_data_metrics(chain: Arc>, metrics: &metrics::Metrics) { let mut interval = tokio::time::interval(std::time::Duration::from_secs(5)); diff --git a/liquidator/src/rebalance.rs b/liquidator/src/rebalance.rs new file mode 100644 index 000000000..95955217a --- /dev/null +++ b/liquidator/src/rebalance.rs @@ -0,0 +1,201 @@ +use crate::{account_shared_data::KeyedAccountSharedData, AnyhowWrap}; + +use client::{chain_data, AccountFetcher, MangoClient, TokenContext}; +use mango_v4::state::{oracle_price, Bank, TokenIndex, TokenPosition, QUOTE_TOKEN_INDEX}; + +use {fixed::types::I80F48, solana_sdk::pubkey::Pubkey}; + +use std::{collections::HashMap, time::Duration}; + +pub struct Config { + pub slippage: f64, + pub refresh_timeout: Duration, +} + +#[derive(Debug)] +struct TokenState { + price: I80F48, + native_position: I80F48, +} + +impl TokenState { + fn new_position( + token: &TokenContext, + position: &TokenPosition, + account_fetcher: &chain_data::AccountFetcher, + ) -> anyhow::Result { + let bank = Self::bank(token, account_fetcher)?; + Ok(Self { + price: Self::fetch_price(token, &bank, account_fetcher)?, + native_position: position.native(&bank), + }) + } + + fn bank( + token: &TokenContext, + account_fetcher: &chain_data::AccountFetcher, + ) -> anyhow::Result { + account_fetcher.fetch::(&token.mint_info.first_bank()) + } + + fn fetch_price( + token: &TokenContext, + bank: &Bank, + account_fetcher: &chain_data::AccountFetcher, + ) -> anyhow::Result { + let oracle = account_fetcher.fetch_raw_account(token.mint_info.oracle)?; + oracle_price( + &KeyedAccountSharedData::new(token.mint_info.oracle, oracle.into()), + bank.oracle_config.conf_filter, + bank.mint_decimals, + ) + .map_err_anyhow() + } +} + +#[allow(clippy::too_many_arguments)] +pub fn zero_all_non_quote( + mango_client: &MangoClient, + account_fetcher: &chain_data::AccountFetcher, + mango_account_address: &Pubkey, + config: &Config, +) -> anyhow::Result<()> { + log::trace!("checking for rebalance: {}", mango_account_address); + + // TODO: configurable? + let quote_token = mango_client.context.token(QUOTE_TOKEN_INDEX); + + let account = account_fetcher.fetch_mango_account(mango_account_address)?; + + let tokens = account + .token_iter_active() + .map(|token_position| { + let token = mango_client.context.token(token_position.token_index); + Ok(( + token.token_index, + TokenState::new_position(token, token_position, account_fetcher)?, + )) + }) + .collect::>>()?; + log::trace!("account tokens: {:?}", tokens); + + // Function to refresh the mango account after the txsig confirmed. Returns false on timeout. + let refresh_mango_account = + |account_fetcher: &chain_data::AccountFetcher, txsig| -> anyhow::Result { + let max_slot = account_fetcher.transaction_max_slot(&[txsig])?; + if let Err(e) = account_fetcher.refresh_accounts_via_rpc_until_slot( + &[*mango_account_address], + max_slot, + config.refresh_timeout, + ) { + // If we don't get fresh data, maybe the tx landed on a fork? + // Rebalance is technically still ok. + log::info!("could not refresh account data: {}", e); + return Ok(false); + } + Ok(true) + }; + + for (token_index, token_state) in tokens { + let token = mango_client.context.token(token_index); + if token_index == quote_token.token_index { + continue; + } + let token_mint = token.mint_info.mint; + let quote_mint = quote_token.mint_info.mint; + + // It's not always possible to bring the native balance to 0 through swaps: + // Consider a price <1. You need to sell a bunch of tokens to get 1 USDC native and + // similarly will get multiple tokens when buying. + // 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 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. + let dust_threshold = I80F48::from(2) / token_state.price; + + let mut amount = token_state.native_position; + + if amount > dust_threshold { + // Sell + let txsig = mango_client.jupiter_swap( + token_mint, + quote_mint, + amount.to_num::(), + config.slippage, + client::JupiterSwapMode::ExactIn, + )?; + log::info!( + "sold {} {} for {} in tx {}", + token.native_to_ui(amount), + token.name, + quote_token.name, + txsig + ); + if !refresh_mango_account(account_fetcher, txsig)? { + return Ok(()); + } + let bank = TokenState::bank(token, account_fetcher)?; + amount = mango_client + .mango_account()? + .token_get(token_index) + .map(|(position, _)| position.native(&bank)) + .unwrap_or(I80F48::ZERO); + } else if token_state.native_position < 0 { + // Buy + let buy_amount = (-token_state.native_position).ceil() + + (dust_threshold - I80F48::ONE).max(I80F48::ZERO); + let txsig = mango_client.jupiter_swap( + quote_mint, + token_mint, + buy_amount.to_num::(), + config.slippage, + client::JupiterSwapMode::ExactOut, + )?; + log::info!( + "bought {} {} for {} in tx {}", + token.native_to_ui(buy_amount), + token.name, + quote_token.name, + txsig + ); + if !refresh_mango_account(account_fetcher, txsig)? { + return Ok(()); + } + let bank = TokenState::bank(token, account_fetcher)?; + amount = mango_client + .mango_account()? + .token_get(token_index) + .map(|(position, _)| position.native(&bank)) + .unwrap_or(I80F48::ZERO); + } + + // Any remainder that could not be sold just gets withdrawn to ensure the + // TokenPosition is freed up + if amount > 0 && amount <= dust_threshold { + // TODO: fix to false once program updated to fix allow_borrow bug + let allow_borrow = true; + let txsig = + mango_client.token_withdraw(token_mint, amount.to_num::(), allow_borrow)?; + log::info!( + "withdrew {} {} to liqor wallet in {}", + token.native_to_ui(amount), + token.name, + txsig + ); + if !refresh_mango_account(account_fetcher, txsig)? { + return Ok(()); + } + } else { + anyhow::bail!( + "unexpected {} position after rebalance swap: {} native", + token.name, + amount + ); + } + } + + Ok(()) +} diff --git a/liquidator/src/snapshot_source.rs b/liquidator/src/snapshot_source.rs index 6d59f5bb7..71624d5f6 100644 --- a/liquidator/src/snapshot_source.rs +++ b/liquidator/src/snapshot_source.rs @@ -86,7 +86,7 @@ pub struct Config { async fn feed_snapshots( config: &Config, - mango_pyth_oracles: Vec, + mango_oracles: Vec, sender: &async_channel::Sender, ) -> anyhow::Result<()> { let rpc_client = http::connect_with_options::(&config.rpc_http_url, true) @@ -128,7 +128,7 @@ async fn feed_snapshots( let results: Vec<( Vec, Result>>, jsonrpc_core_client::RpcError>, - )> = stream::iter(mango_pyth_oracles) + )> = stream::iter(mango_oracles) .chunks(config.get_multiple_accounts_count) .map(|keys| { let rpc_client = &rpc_client; @@ -207,7 +207,7 @@ async fn feed_snapshots( pub fn start( config: Config, - mango_pyth_oracles: Vec, + mango_oracles: Vec, sender: async_channel::Sender, ) { let mut poll_wait_first_snapshot = time::interval(time::Duration::from_secs(2)); @@ -239,7 +239,7 @@ pub fn start( loop { interval_between_snapshots.tick().await; - if let Err(err) = feed_snapshots(&config, mango_pyth_oracles.clone(), &sender).await { + if let Err(err) = feed_snapshots(&config, mango_oracles.clone(), &sender).await { warn!("snapshot error: {:?}", err); } else { info!("snapshot success"); diff --git a/liquidator/src/websocket_source.rs b/liquidator/src/websocket_source.rs index 9f2462e79..d86f6a30b 100644 --- a/liquidator/src/websocket_source.rs +++ b/liquidator/src/websocket_source.rs @@ -57,7 +57,7 @@ pub struct Config { async fn feed_data( config: &Config, - mango_pyth_oracles: Vec, + mango_oracles: Vec, sender: async_channel::Sender, ) -> anyhow::Result<()> { let connect = ws::try_connect::(&config.rpc_ws_url).map_err_anyhow()?; @@ -99,10 +99,9 @@ async fn feed_data( Some(all_accounts_config.clone()), ) .map_err_anyhow()?; - // TODO: mango_pyth_oracles should not contain stub mango_pyth_oracles, since they already sub'ed with mango_sub - let mut mango_pyth_oracles_sub_map = StreamMap::new(); - for oracle in mango_pyth_oracles.into_iter() { - mango_pyth_oracles_sub_map.insert( + let mut mango_oracles_sub_map = StreamMap::new(); + for oracle in mango_oracles.into_iter() { + mango_oracles_sub_map.insert( oracle, client .account_subscribe( @@ -136,13 +135,13 @@ async fn feed_data( return Ok(()); } }, - message = mango_pyth_oracles_sub_map.next() => { + message = mango_oracles_sub_map.next() => { if let Some(data) = message { let response = data.1.map_err_anyhow()?; let response = solana_client::rpc_response::Response{ context: RpcResponseContext{ slot: response.context.slot, api_version: None }, value: RpcKeyedAccount{ pubkey: data.0.to_string(), account: response.value} } ; sender.send(Message::Account(AccountUpdate::from_rpc(response)?)).await.expect("sending must succeed"); } else { - warn!("pyth stream closed"); + warn!("oracle stream closed"); return Ok(()); } }, @@ -171,16 +170,12 @@ async fn feed_data( } } -pub fn start( - config: Config, - mango_pyth_oracles: Vec, - sender: async_channel::Sender, -) { +pub fn start(config: Config, mango_oracles: Vec, sender: async_channel::Sender) { tokio::spawn(async move { // if the websocket disconnects, we get no data in a while etc, reconnect and try again loop { info!("connecting to solana websocket streams"); - let out = feed_data(&config, mango_pyth_oracles.clone(), sender.clone()); + let out = feed_data(&config, mango_oracles.clone(), sender.clone()); let _ = out.await; } }); diff --git a/programs/mango-v4/src/instructions/liq_token_with_token.rs b/programs/mango-v4/src/instructions/liq_token_with_token.rs index 9454b7ef8..6ea399685 100644 --- a/programs/mango-v4/src/instructions/liq_token_with_token.rs +++ b/programs/mango-v4/src/instructions/liq_token_with_token.rs @@ -85,8 +85,8 @@ pub fn liq_token_with_token( // The main complication here is that we can't keep the liqee_asset_position and liqee_liab_position // borrows alive at the same time. Possibly adding get_mut_pair() would be helpful. let (liqee_asset_position, liqee_asset_raw_index) = liqee.token_get(asset_token_index)?; - let liqee_assets_native = liqee_asset_position.native(asset_bank); - require!(liqee_assets_native.is_positive(), MangoError::SomeError); + let liqee_asset_native = liqee_asset_position.native(asset_bank); + require!(liqee_asset_native.is_positive(), MangoError::SomeError); let (liqee_liab_position, liqee_liab_raw_index) = liqee.token_get(liab_token_index)?; let liqee_liab_native = liqee_liab_position.native(liab_bank); @@ -115,7 +115,7 @@ pub fn liq_token_with_token( / (liab_price * init_liab_weight - init_asset_weight * liab_price_adjusted)); // How much liab can we get at most for the asset balance? - let liab_possible = cm!(liqee_assets_native * asset_price / liab_price_adjusted); + let liab_possible = cm!(liqee_asset_native * asset_price / liab_price_adjusted); // The amount of liab native tokens we will transfer let liab_transfer = min( @@ -135,6 +135,7 @@ pub fn liq_token_with_token( liqor.token_get_mut_or_create(liab_token_index)?; let liqor_liab_active = liab_bank.withdraw_with_fee(liqor_liab_position, liab_transfer)?; let liqor_liab_position_indexed = liqor_liab_position.indexed_position; + let liqee_liab_native_after = liqee_liab_position.native(&liab_bank); let (liqor_asset_position, liqor_asset_raw_index, _) = liqor.token_get_mut_or_create(asset_token_index)?; @@ -145,10 +146,17 @@ pub fn liq_token_with_token( let liqee_asset_active = asset_bank.withdraw_without_fee(liqee_asset_position, asset_transfer)?; let liqee_asset_position_indexed = liqee_asset_position.indexed_position; + let liqee_assets_native_after = liqee_asset_position.native(&asset_bank); // Update the health cache - liqee_health_cache.adjust_token_balance(liab_token_index, liab_transfer)?; - liqee_health_cache.adjust_token_balance(asset_token_index, -asset_transfer)?; + liqee_health_cache.adjust_token_balance( + liab_token_index, + cm!(liqee_liab_native_after - liqee_liab_native), + )?; + liqee_health_cache.adjust_token_balance( + asset_token_index, + cm!(liqee_assets_native_after - liqee_asset_native), + )?; msg!( "liquidated {} liab for {} asset", diff --git a/programs/mango-v4/tests/test_liq_tokens.rs b/programs/mango-v4/tests/test_liq_tokens.rs index 0c2d845a7..eed928b14 100644 --- a/programs/mango-v4/tests/test_liq_tokens.rs +++ b/programs/mango-v4/tests/test_liq_tokens.rs @@ -505,5 +505,73 @@ async fn test_liq_tokens_with_token() -> Result<(), TransportError> { assert!(!liqee.being_liquidated()); assert!(!liqee.is_bankrupt()); + // + // TEST: bankruptcy when collateral is dusted + // + + // Setup: make collateral really valueable, remove nearly all of it + send_tx( + solana, + StubOracleSetInstruction { + group, + admin, + mint: collateral_token1.mint.pubkey, + payer, + price: "100000.0", + }, + ) + .await + .unwrap(); + send_tx( + solana, + TokenWithdrawInstruction { + amount: (account_position(solana, account, collateral_token1.bank).await) as u64 - 1, + allow_borrow: false, + account, + owner, + token_account: payer_mint_accounts[2], + bank_index: 0, + }, + ) + .await + .unwrap(); + // Setup: reduce collateral value to trigger liquidatability + // We have -93 borrows, so -93*2*1.4 = -260.4 health from that + // And 1-2 collateral, so max 2*0.6*X health; say X=150 for max 180 health + send_tx( + solana, + StubOracleSetInstruction { + group, + admin, + mint: collateral_token1.mint.pubkey, + payer, + price: "150.0", + }, + ) + .await + .unwrap(); + + send_tx( + solana, + LiqTokenWithTokenInstruction { + liqee: account, + liqor: vault_account, + liqor_owner: owner, + asset_token_index: collateral_token1.index, + liab_token_index: borrow_token1.index, + max_liab_transfer: I80F48::from_num(10001.0), + asset_bank_index: 0, + liab_bank_index: 0, + }, + ) + .await + .unwrap(); + + // Liqee's remaining collateral got dusted, only borrows remain: bankrupt + let liqee = get_mango_account(solana, account).await; + assert_eq!(liqee.token_iter_active().count(), 1); + assert!(liqee.is_bankrupt()); + assert!(liqee.being_liquidated()); + Ok(()) } diff --git a/ts/client/src/client.ts b/ts/client/src/client.ts index 497db5640..ba3288c27 100644 --- a/ts/client/src/client.ts +++ b/ts/client/src/client.ts @@ -128,10 +128,7 @@ export class MangoClient { return group; } - public async getGroupForCreator( - creatorPk: PublicKey, - groupNum?: number, - ): Promise { + public async getGroupsForCreator(creatorPk: PublicKey): Promise { const filters: MemcmpFilter[] = [ { memcmp: { @@ -141,20 +138,25 @@ export class MangoClient { }, ]; - if (groupNum !== undefined) { - const bbuf = Buffer.alloc(4); - bbuf.writeUInt32LE(groupNum); - filters.push({ - memcmp: { - bytes: bs58.encode(bbuf), - offset: 40, - }, - }); - } - - const groups = (await this.program.account.group.all(filters)).map( - (tuple) => Group.from(tuple.publicKey, tuple.account), + return (await this.program.account.group.all(filters)).map((tuple) => + Group.from(tuple.publicKey, tuple.account), ); + } + + public async getGroupForCreator( + creatorPk: PublicKey, + groupNum?: number, + ): Promise { + const groups = (await this.getGroupsForCreator(creatorPk)).filter( + (group) => { + if (groupNum !== undefined) { + return group.groupNum == groupNum; + } else { + return true; + } + }, + ); + await groups[0].reloadAll(this); return groups[0]; } @@ -472,12 +474,35 @@ export class MangoClient { accountNumber?: number, name?: string, ): Promise { - let mangoAccounts = await this.getMangoAccountsForOwner(group, ownerPk); - if (mangoAccounts.length === 0) { - await this.createMangoAccount(group, accountNumber, name); - mangoAccounts = await this.getMangoAccountsForOwner(group, ownerPk); + // TODO: this function discards accountSize and name when the account exists already! + // TODO: this function always creates accounts for this.program.owner, and not + // ownerPk! It needs to get passed a keypair, and we need to add + // createMangoAccountForOwner + if (accountNumber === undefined) { + // Get any MangoAccount + // TODO: should probably sort by accountNum for deterministic output! + let mangoAccounts = await this.getMangoAccountsForOwner(group, ownerPk); + if (mangoAccounts.length === 0) { + await this.createMangoAccount(group, accountNumber, name); + mangoAccounts = await this.getMangoAccountsForOwner(group, ownerPk); + } + return mangoAccounts[0]; + } else { + let account = await this.getMangoAccountForOwner( + group, + ownerPk, + accountNumber, + ); + if (account === undefined) { + await this.createMangoAccount(group, accountNumber, name); + account = await this.getMangoAccountForOwner( + group, + ownerPk, + accountNumber, + ); + } + return account; } - return mangoAccounts[0]; } public async createMangoAccount( @@ -537,6 +562,16 @@ export class MangoClient { ); } + public async getMangoAccountForOwner( + group: Group, + ownerPk: PublicKey, + accountNumber: number, + ): Promise { + return (await this.getMangoAccountsForOwner(group, ownerPk)).find( + (a) => a.accountNum == accountNumber, + ); + } + public async getMangoAccountsForOwner( group: Group, ownerPk: PublicKey, @@ -623,6 +658,22 @@ export class MangoClient { amount: number, ): Promise { const bank = group.banksMap.get(tokenName)!; + const nativeAmount = toNativeDecimals(amount, bank.mintDecimals).toNumber(); + return await this.tokenDepositNative( + group, + mangoAccount, + tokenName, + nativeAmount, + ); + } + + public async tokenDepositNative( + group: Group, + mangoAccount: MangoAccount, + tokenName: string, + nativeAmount: number, + ) { + const bank = group.banksMap.get(tokenName)!; const tokenAccountPk = await getAssociatedTokenAddress( bank.mint, @@ -635,7 +686,7 @@ export class MangoClient { const additionalSigners: Signer[] = []; if (bank.mint.equals(WRAPPED_SOL_MINT)) { wrappedSolAccount = new Keypair(); - const lamports = Math.round(amount * LAMPORTS_PER_SOL) + 1e7; + const lamports = nativeAmount + 1e7; preInstructions = [ SystemProgram.createAccount({ @@ -670,7 +721,7 @@ export class MangoClient { ); return await this.program.methods - .tokenDeposit(toNativeDecimals(amount, bank.mintDecimals)) + .tokenDeposit(new BN(nativeAmount)) .accounts({ group: group.publicKey, account: mangoAccount.publicKey, @@ -699,45 +750,14 @@ export class MangoClient { allowBorrow: boolean, ): Promise { const bank = group.banksMap.get(tokenName)!; - - const tokenAccountPk = await getAssociatedTokenAddress( - bank.mint, - mangoAccount.owner, + const nativeAmount = toNativeDecimals(amount, bank.mintDecimals).toNumber(); + return await this.tokenWithdrawNative( + group, + mangoAccount, + tokenName, + nativeAmount, + allowBorrow, ); - - const healthRemainingAccounts: PublicKey[] = - this.buildHealthRemainingAccounts( - AccountRetriever.Fixed, - group, - [mangoAccount], - [bank], - ); - - return await this.program.methods - .tokenWithdraw(toNativeDecimals(amount, bank.mintDecimals), allowBorrow) - .accounts({ - group: group.publicKey, - account: mangoAccount.publicKey, - bank: bank.publicKey, - vault: bank.vault, - tokenAccount: tokenAccountPk, - owner: mangoAccount.owner, - }) - .remainingAccounts( - healthRemainingAccounts.map( - (pk) => - ({ pubkey: pk, isWritable: false, isSigner: false } as AccountMeta), - ), - ) - .preInstructions([ - // ensure withdraws don't fail with missing ATAs - await createAssociatedTokenAccountIdempotentInstruction( - mangoAccount.owner, - mangoAccount.owner, - bank.mint, - ), - ]) - .rpc({ skipPreflight: true }); } public async tokenWithdrawNative( @@ -778,6 +798,14 @@ export class MangoClient { ({ pubkey: pk, isWritable: false, isSigner: false } as AccountMeta), ), ) + .preInstructions([ + // ensure withdraws don't fail with missing ATAs + await createAssociatedTokenAccountIdempotentInstruction( + mangoAccount.owner, + mangoAccount.owner, + bank.mint, + ), + ]) .rpc({ skipPreflight: true }); } diff --git a/ts/client/src/scripts/decode-event.ts b/ts/client/src/scripts/decode-event.ts new file mode 100644 index 000000000..72c1a8608 --- /dev/null +++ b/ts/client/src/scripts/decode-event.ts @@ -0,0 +1,29 @@ +import { AnchorProvider, Wallet } from '@project-serum/anchor'; +import { Connection, Keypair, PublicKey } from '@solana/web3.js'; +import { BN, BorshCoder } from '@project-serum/anchor'; +import { MangoClient } from '../client'; +import { MANGO_V4_ID } from '../constants'; +import { IDL } from '../mango_v4'; + +async function main() { + const coder = new BorshCoder(IDL); + + const event = coder.events.decode(process.argv[2]); + console.log( + JSON.stringify( + event, + function (key, value) { + const orig_value = this[key]; // value is already processed + if (orig_value instanceof BN) { + return orig_value.toString(); + } + return value; + }, + ' ', + ), + ); + + process.exit(); +} + +main(); diff --git a/ts/client/src/scripts/mb-example1-admin-close.ts b/ts/client/src/scripts/mb-example1-admin-close.ts index cf7bff764..fa4e0db2f 100644 --- a/ts/client/src/scripts/mb-example1-admin-close.ts +++ b/ts/client/src/scripts/mb-example1-admin-close.ts @@ -8,11 +8,8 @@ import { MANGO_V4_ID } from '../constants'; // example script to close accounts - banks, markets, group etc. which require admin to be the signer // -const MAINNET_MINTS = new Map([ - ['USDC', 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'], - ['BTC', '9n4nbM75f5Ui33ZbPYXn59EwSgE8CGsHtAeTH5YFeJ9E'], - ['SOL', 'So11111111111111111111111111111111111111112'], -]); +// Use to close only a specific group by number. Use "all" to close all groups. +const GROUP_NUM = process.env.GROUP_NUM; async function main() { const options = AnchorProvider.defaultOptions(); @@ -32,49 +29,58 @@ async function main() { MANGO_V4_ID['mainnet-beta'], ); - const group = await client.getGroupForCreator(admin.publicKey); - console.log(`Group ${group.publicKey}`); + const groups = await (async () => { + if (GROUP_NUM === 'all') { + return await client.getGroupsForCreator(admin.publicKey); + } else { + return [ + await client.getGroupForCreator(admin.publicKey, Number(GROUP_NUM)), + ]; + } + })(); + for (const group of groups) { + console.log(`Group ${group.publicKey}`); - let sig; + let sig; - // close stub oracle - const usdcMainnetBetaMint = new PublicKey(MAINNET_MINTS.get('USDC')!); - const usdcMainnetBetaOracle = ( - await client.getStubOracle(group, usdcMainnetBetaMint) - )[0]; - sig = await client.stubOracleClose(group, usdcMainnetBetaOracle.publicKey); - console.log( - `Closed USDC stub oracle, sig https://explorer.solana.com/tx/${sig}`, - ); + // close stub oracles + const stubOracles = await client.getStubOracle(group); + for (const stubOracle of stubOracles) { + sig = await client.stubOracleClose(group, stubOracle.publicKey); + console.log( + `Closed stub oracle ${stubOracle.publicKey}, sig https://explorer.solana.com/tx/${sig}`, + ); + } - // close all bank - for (const bank of group.banksMap.values()) { - sig = await client.tokenDeregister(group, bank.name); - console.log( - `Removed token ${bank.name}, sig https://explorer.solana.com/tx/${sig}`, - ); + // close all banks + for (const bank of group.banksMap.values()) { + sig = await client.tokenDeregister(group, bank.name); + console.log( + `Removed token ${bank.name}, sig https://explorer.solana.com/tx/${sig}`, + ); + } + + // deregister all serum markets + for (const market of group.serum3MarketsMap.values()) { + sig = await client.serum3deregisterMarket(group, market.name); + console.log( + `Deregistered serum market ${market.name}, sig https://explorer.solana.com/tx/${sig}`, + ); + } + + // close all perp markets + for (const market of group.perpMarketsMap.values()) { + sig = await client.perpCloseMarket(group, market.name); + console.log( + `Closed perp market ${market.name}, sig https://explorer.solana.com/tx/${sig}`, + ); + } + + // finally, close the group + sig = await client.groupClose(group); + console.log(`Closed group, sig https://explorer.solana.com/tx/${sig}`); } - // deregister all serum markets - for (const market of group.serum3MarketsMap.values()) { - sig = await client.serum3deregisterMarket(group, market.name); - console.log( - `Deregistered serum market ${market.name}, sig https://explorer.solana.com/tx/${sig}`, - ); - } - - // close all perp markets - for (const market of group.perpMarketsMap.values()) { - sig = await client.perpCloseMarket(group, market.name); - console.log( - `Closed perp market ${market.name}, sig https://explorer.solana.com/tx/${sig}`, - ); - } - - // finally, close the group - sig = await client.groupClose(group); - console.log(`Closed group, sig https://explorer.solana.com/tx/${sig}`); - process.exit(); } diff --git a/ts/client/src/scripts/mb-liqtest-create-group.ts b/ts/client/src/scripts/mb-liqtest-create-group.ts new file mode 100644 index 000000000..e37ad7a42 --- /dev/null +++ b/ts/client/src/scripts/mb-liqtest-create-group.ts @@ -0,0 +1,175 @@ +import { AnchorProvider, Wallet } from '@project-serum/anchor'; +import { Connection, Keypair, PublicKey } from '@solana/web3.js'; +import fs from 'fs'; +import { MangoClient } from '../client'; +import { MANGO_V4_ID } from '../constants'; + +// +// Script which depoys a new mango group, and registers 3 tokens +// with stub oracles +// + +// default to group 1, to not conflict with the normal group +const GROUP_NUM = Number(process.env.GROUP_NUM || 1); + +const MAINNET_MINTS = new Map([ + ['USDC', 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'], + ['BTC', '9n4nbM75f5Ui33ZbPYXn59EwSgE8CGsHtAeTH5YFeJ9E'], + ['SOL', 'So11111111111111111111111111111111111111112'], +]); + +const STUB_PRICES = new Map([ + ['USDC', 1.0], + ['BTC', 20000.0], // btc and usdc both have 6 decimals + ['SOL', 0.04], // sol has 9 decimals, equivalent to $40 per SOL +]); + +async function main() { + const options = AnchorProvider.defaultOptions(); + const connection = new Connection(process.env.CLUSTER_URL, options); + + const admin = Keypair.fromSecretKey( + Buffer.from( + JSON.parse( + fs.readFileSync(process.env.MANGO_MAINNET_PAYER_KEYPAIR!, 'utf-8'), + ), + ), + ); + const adminWallet = new Wallet(admin); + console.log(`Admin ${adminWallet.publicKey.toBase58()}`); + const adminProvider = new AnchorProvider(connection, adminWallet, options); + const client = await MangoClient.connect( + adminProvider, + 'mainnet-beta', + MANGO_V4_ID['mainnet-beta'], + ); + + // group + console.log(`Creating Group...`); + try { + const insuranceMint = new PublicKey(MAINNET_MINTS.get('USDC')!); + await client.groupCreate(GROUP_NUM, true, 0, insuranceMint); + } catch (error) { + console.log(error); + } + const group = await client.getGroupForCreator(admin.publicKey, GROUP_NUM); + console.log(`...registered group ${group.publicKey}`); + + // stub oracles + let oracles = new Map(); + for (let [name, mint] of MAINNET_MINTS) { + console.log(`Creating stub oracle for ${name}...`); + const mintPk = new PublicKey(mint); + try { + const price = STUB_PRICES.get(name); + await client.stubOracleCreate(group, mintPk, price); + } catch (error) { + console.log(error); + } + const oracle = (await client.getStubOracle(group, mintPk))[0]; + console.log(`...created stub oracle ${oracle.publicKey}`); + oracles.set(name, oracle.publicKey); + } + + // register token 1 + console.log(`Registering BTC...`); + const btcMainnetMint = new PublicKey(MAINNET_MINTS.get('BTC')!); + const btcMainnetOracle = oracles.get('BTC'); + try { + await client.tokenRegister( + group, + btcMainnetMint, + btcMainnetOracle, + 0.1, + 1, + 'BTC', + 0.01, + 0.4, + 0.07, + 0.7, + 0.88, + 1.5, + 0.0, + 0.0001, + 0.9, + 0.8, + 1.1, + 1.2, + 0.05, + ); + await group.reloadAll(client); + } catch (error) { + console.log(error); + } + + // register token 0 + console.log(`Registering USDC...`); + const usdcMainnetMint = new PublicKey(MAINNET_MINTS.get('USDC')!); + const usdcMainnetOracle = oracles.get('USDC'); + try { + await client.tokenRegister( + group, + usdcMainnetMint, + usdcMainnetOracle, + 0.1, + 0, + 'USDC', + 0.01, + 0.4, + 0.07, + 0.8, + 0.9, + 1.5, + 0.0, + 0.0001, + 1, + 1, + 1, + 1, + 0, + ); + await group.reloadAll(client); + } catch (error) { + console.log(error); + } + + // register token 2 + console.log(`Registering SOL...`); + const solMainnetMint = new PublicKey(MAINNET_MINTS.get('SOL')!); + const solMainnetOracle = oracles.get('SOL'); + try { + await client.tokenRegister( + group, + solMainnetMint, + solMainnetOracle, + 0.1, + 2, // tokenIndex + 'SOL', + 0.01, + 0.4, + 0.07, + 0.8, + 0.9, + 1.5, + 0.0, + 0.0001, + 0.9, + 0.8, + 1.1, + 1.2, + 0.05, + ); + await group.reloadAll(client); + } catch (error) { + console.log(error); + } + + // log tokens/banks + for (const bank of await group.banksMap.values()) { + console.log(`${bank.toString()}`); + } + + process.exit(); +} + +main(); diff --git a/ts/client/src/scripts/mb-liqtest-deposit-tokens.ts b/ts/client/src/scripts/mb-liqtest-deposit-tokens.ts new file mode 100644 index 000000000..9a647af04 --- /dev/null +++ b/ts/client/src/scripts/mb-liqtest-deposit-tokens.ts @@ -0,0 +1,68 @@ +import { AnchorProvider, Wallet } from '@project-serum/anchor'; +import { Connection, Keypair, PublicKey } from '@solana/web3.js'; +import fs from 'fs'; +import { MangoClient } from '../client'; +import { MANGO_V4_ID } from '../constants'; + +// +// This script deposits some tokens, so other liquidation scripts can borrow. +// + +const GROUP_NUM = Number(process.env.GROUP_NUM || 1); +const ACCOUNT_NUM = Number(process.env.ACCOUNT_NUM || 0); + +async function main() { + const options = AnchorProvider.defaultOptions(); + const connection = new Connection(process.env.CLUSTER_URL, options); + + const admin = Keypair.fromSecretKey( + Buffer.from( + JSON.parse( + fs.readFileSync(process.env.MANGO_MAINNET_PAYER_KEYPAIR!, 'utf-8'), + ), + ), + ); + const userWallet = new Wallet(admin); + const userProvider = new AnchorProvider(connection, userWallet, options); + const client = await MangoClient.connect( + userProvider, + 'mainnet-beta', + MANGO_V4_ID['mainnet-beta'], + ); + console.log(`User ${userWallet.publicKey.toBase58()}`); + + // fetch group + const group = await client.getGroupForCreator(admin.publicKey, GROUP_NUM); + console.log(group.toString()); + + // create + fetch account + console.log(`Creating mangoaccount...`); + const mangoAccount = await client.getOrCreateMangoAccount( + group, + admin.publicKey, + ACCOUNT_NUM, + ); + console.log(`...created/found mangoAccount ${mangoAccount.publicKey}`); + console.log(mangoAccount.toString()); + + // deposit + try { + console.log(`...depositing 10 USDC`); + await client.tokenDeposit(group, mangoAccount, 'USDC', 10); + await mangoAccount.reload(client, group); + + console.log(`...depositing 0.0004 BTC`); + await client.tokenDeposit(group, mangoAccount, 'BTC', 0.0004); + await mangoAccount.reload(client, group); + + console.log(`...depositing 0.25 SOL`); + await client.tokenDeposit(group, mangoAccount, 'SOL', 0.25); + await mangoAccount.reload(client, group); + } catch (error) { + console.log(error); + } + + process.exit(); +} + +main(); diff --git a/ts/client/src/scripts/mb-liqtest-make-candidates.ts b/ts/client/src/scripts/mb-liqtest-make-candidates.ts new file mode 100644 index 000000000..8e51758cd --- /dev/null +++ b/ts/client/src/scripts/mb-liqtest-make-candidates.ts @@ -0,0 +1,110 @@ +import { AnchorProvider, Wallet } from '@project-serum/anchor'; +import { Connection, Keypair, PublicKey } from '@solana/web3.js'; +import fs from 'fs'; +import { MangoClient } from '../client'; +import { MANGO_V4_ID } from '../constants'; + +// +// This script creates liquidation candidates +// + +const GROUP_NUM = Number(process.env.GROUP_NUM || 1); + +// native prices +const PRICES = { + BTC: 20000.0, + SOL: 0.04, + USDC: 1, +}; + +const MAINNET_MINTS = new Map([ + ['USDC', 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'], + ['BTC', '9n4nbM75f5Ui33ZbPYXn59EwSgE8CGsHtAeTH5YFeJ9E'], + ['SOL', 'So11111111111111111111111111111111111111112'], +]); + +const SCENARIOS: [string, string, number, string, number][] = [ + ['LIQTEST, LIQOR', 'USDC', 1000000, 'USDC', 0], + ['LIQTEST, A: USDC, L: SOL', 'USDC', 1000 * PRICES.SOL, 'SOL', 920], + ['LIQTEST, A: SOL, L: USDC', 'SOL', 1000, 'USDC', 920 * PRICES.SOL], + // TODO: needs the fix on liq+dust to go live + //['LIQTEST, A: BTC, L: SOL', 'BTC', 20, 'SOL', 18 * PRICES.BTC / PRICES.SOL], +]; + +async function main() { + const options = AnchorProvider.defaultOptions(); + const connection = new Connection(process.env.CLUSTER_URL, options); + + const admin = Keypair.fromSecretKey( + Buffer.from( + JSON.parse( + fs.readFileSync(process.env.MANGO_MAINNET_PAYER_KEYPAIR!, 'utf-8'), + ), + ), + ); + const userWallet = new Wallet(admin); + const userProvider = new AnchorProvider(connection, userWallet, options); + const client = await MangoClient.connect( + userProvider, + 'mainnet-beta', + MANGO_V4_ID['mainnet-beta'], + ); + console.log(`User ${userWallet.publicKey.toBase58()}`); + + // fetch group + const group = await client.getGroupForCreator(admin.publicKey, GROUP_NUM); + console.log(group.toString()); + + const accounts = await client.getMangoAccountsForOwner( + group, + admin.publicKey, + ); + let maxAccountNum = Math.max(...accounts.map((a) => a.accountNum)); + + for (const scenario of SCENARIOS) { + const [name, assetName, assetAmount, liabName, liabAmount] = scenario; + + // create account + console.log(`Creating mangoaccount...`); + let mangoAccount = await client.getOrCreateMangoAccount( + group, + admin.publicKey, + maxAccountNum + 1, + ); + maxAccountNum = maxAccountNum + 1; + console.log( + `...created mangoAccount ${mangoAccount.publicKey} for ${name}`, + ); + + await client.tokenDepositNative( + group, + mangoAccount, + assetName, + assetAmount, + ); + await mangoAccount.reload(client, group); + + if (liabAmount > 0) { + // temporarily drop the borrowed token value, so the borrow goes through + const oracle = group.banksMap.get(liabName).oracle; + try { + await client.stubOracleSet(group, oracle, PRICES[liabName] / 2); + + await client.tokenWithdrawNative( + group, + mangoAccount, + liabName, + liabAmount, + true, + ); + } finally { + // restore the oracle + await client.stubOracleSet(group, oracle, PRICES[liabName]); + } + } + } + + process.exit(); +} + +main(); diff --git a/ts/client/src/scripts/mb-liqtest-settle-and-close-all.ts b/ts/client/src/scripts/mb-liqtest-settle-and-close-all.ts new file mode 100644 index 000000000..12e3f3d53 --- /dev/null +++ b/ts/client/src/scripts/mb-liqtest-settle-and-close-all.ts @@ -0,0 +1,102 @@ +import { AnchorProvider, Wallet } from '@project-serum/anchor'; +import { Connection, Keypair, PublicKey } from '@solana/web3.js'; +import fs from 'fs'; +import { MangoClient } from '../client'; +import { MANGO_V4_ID } from '../constants'; + +// +// This script tries to withdraw all positive balances for all accounts +// by MANGO_MAINNET_PAYER_KEYPAIR in the group. +// + +const GROUP_NUM = Number(process.env.GROUP_NUM || 1); + +async function main() { + const options = AnchorProvider.defaultOptions(); + const connection = new Connection(process.env.CLUSTER_URL, options); + + const admin = Keypair.fromSecretKey( + Buffer.from( + JSON.parse( + fs.readFileSync(process.env.MANGO_MAINNET_PAYER_KEYPAIR!, 'utf-8'), + ), + ), + ); + const userWallet = new Wallet(admin); + const userProvider = new AnchorProvider(connection, userWallet, options); + const client = await MangoClient.connect( + userProvider, + 'mainnet-beta', + MANGO_V4_ID['mainnet-beta'], + ); + console.log(`User ${userWallet.publicKey.toBase58()}`); + + const group = await client.getGroupForCreator(admin.publicKey, GROUP_NUM); + console.log(group.toString()); + + let accounts = await client.getMangoAccountsForOwner(group, admin.publicKey); + for (let account of accounts) { + console.log(`settling borrows on account: ${account}`); + + // first, settle all borrows + for (let token of account.tokensActive()) { + const bank = group.findBank(token.tokenIndex); + const amount = token.native(bank).toNumber(); + if (amount < 0) { + try { + await client.tokenDepositNative( + group, + account, + bank.name, + Math.ceil(-amount), + ); + await account.reload(client, group); + } catch (error) { + console.log( + `failed to deposit ${bank.name} into ${account.publicKey}: ${error}`, + ); + } + } + } + } + + accounts = await client.getMangoAccountsForOwner(group, admin.publicKey); + for (let account of accounts) { + console.log(`withdrawing deposits of account: ${account}`); + + // withdraw all funds + for (let token of account.tokensActive()) { + const bank = group.findBank(token.tokenIndex); + const amount = token.native(bank).toNumber(); + if (amount > 0) { + try { + const allowBorrow = true; // TODO: set this to false once the withdraw amount ___<___ nativePosition bug is fixed + await client.tokenWithdrawNative( + group, + account, + bank.name, + amount, + allowBorrow, + ); + await account.reload(client, group); + } catch (error) { + console.log( + `failed to withdraw ${bank.name} from ${account.publicKey}: ${error}`, + ); + } + } + } + + // close account + try { + console.log(`closing account: ${account}`); + await client.closeMangoAccount(group, account); + } catch (error) { + console.log(`failed to close ${account.publicKey}: ${error}`); + } + } + + process.exit(); +} + +main();