diff --git a/Cargo.lock b/Cargo.lock index a54abdb97..7b423022e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3462,6 +3462,7 @@ dependencies = [ "atty", "base64 0.13.1", "bincode", + "borsh 0.10.3", "clap 3.2.25", "derive_builder", "fixed 1.11.0 (git+https://github.com/blockworks-foundation/fixed.git?branch=v1.11.0-borsh0_10-mango)", @@ -3529,6 +3530,7 @@ dependencies = [ "async-channel", "async-stream 0.2.1", "async-trait", + "borsh 0.10.3", "bs58 0.3.1", "bytemuck", "bytes 1.5.0", @@ -3540,6 +3542,7 @@ dependencies = [ "futures-core", "futures-util", "hdrhistogram", + "indexmap 2.0.0", "itertools", "jemallocator", "jsonrpc-core 18.0.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -3556,6 +3559,7 @@ dependencies = [ "serum_dex 0.5.10 (git+https://github.com/openbook-dex/program.git)", "shellexpand", "solana-account-decoder", + "solana-address-lookup-table-program", "solana-client", "solana-logger", "solana-rpc", diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index 06ab893be..fe3f5562c 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -92,6 +92,31 @@ struct JupiterSwap { rpc: Rpc, } +#[derive(Args, Debug, Clone)] +struct SanctumSwap { + #[clap(long)] + account: String, + + /// also pays for everything + #[clap(short, long)] + owner: String, + + #[clap(long)] + input_mint: String, + + #[clap(long)] + output_mint: String, + + #[clap(short, long)] + amount: u64, + + #[clap(short, long, default_value = "50")] + max_slippage_bps: u64, + + #[clap(flatten)] + rpc: Rpc, +} + #[derive(ArgEnum, Clone, Debug)] #[repr(u8)] pub enum CliSide { @@ -189,6 +214,7 @@ enum Command { CreateAccount(CreateAccount), Deposit(Deposit), JupiterSwap(JupiterSwap), + SanctumSwap(SanctumSwap), GroupAddress { #[clap(short, long)] creator: String, @@ -312,6 +338,19 @@ async fn main() -> Result<(), anyhow::Error> { .await?; println!("{}", txsig); } + Command::SanctumSwap(cmd) => { + let client = cmd.rpc.client(Some(&cmd.owner))?; + let account = pubkey_from_cli(&cmd.account); + let owner = Arc::new(keypair_from_cli(&cmd.owner)); + let input_mint = pubkey_from_cli(&cmd.input_mint); + let output_mint = pubkey_from_cli(&cmd.output_mint); + let client = MangoClient::new_for_existing_account(client, account, owner).await?; + let txsig = client + .sanctum() + .swap(input_mint, output_mint, cmd.max_slippage_bps, cmd.amount) + .await?; + println!("{}", txsig); + } Command::GroupAddress { creator, num } => { let creator = pubkey_from_cli(&creator); println!("{}", MangoClient::group_for_admin(creator, num)); diff --git a/bin/liquidator/Cargo.toml b/bin/liquidator/Cargo.toml index d591bd37b..9e13f12ef 100644 --- a/bin/liquidator/Cargo.toml +++ b/bin/liquidator/Cargo.toml @@ -42,6 +42,7 @@ shellexpand = "2.1.0" solana-account-decoder = { workspace = true } solana-client = { workspace = true } solana-logger = { workspace = true } +solana-address-lookup-table-program = "~1.16.7" solana-rpc = { workspace = true } solana-sdk = { workspace = true } tokio = { version = "1", features = ["full"] } @@ -49,4 +50,6 @@ tokio-stream = { version = "0.1.9"} tokio-tungstenite = "0.16.1" tracing = "0.1" regex = "1.9.5" -hdrhistogram = "7.5.4" \ No newline at end of file +hdrhistogram = "7.5.4" +indexmap = "2.0.0" +borsh = { version = "0.10.3", features = ["const-generics"] } diff --git a/bin/liquidator/src/cli_args.rs b/bin/liquidator/src/cli_args.rs index 53ea01fad..9396d7a35 100644 --- a/bin/liquidator/src/cli_args.rs +++ b/bin/liquidator/src/cli_args.rs @@ -1,7 +1,7 @@ use crate::trigger_tcs; use anchor_lang::prelude::Pubkey; use clap::Parser; -use mango_v4_client::{jupiter, priority_fees_cli}; +use mango_v4_client::{priority_fees_cli, swap}; use std::collections::HashSet; #[derive(Parser, Debug)] @@ -28,11 +28,11 @@ pub(crate) enum JupiterVersionArg { V6, } -impl From for jupiter::Version { +impl From for swap::Version { fn from(a: JupiterVersionArg) -> Self { match a { - JupiterVersionArg::Mock => jupiter::Version::Mock, - JupiterVersionArg::V6 => jupiter::Version::V6, + JupiterVersionArg::Mock => swap::Version::Mock, + JupiterVersionArg::V6 => swap::Version::V6, } } } @@ -121,6 +121,12 @@ pub struct Cli { #[clap(long, env, value_parser, value_delimiter = ',')] pub(crate) rebalance_alternate_jupiter_route_tokens: Option>, + /// query sanctum for routes to and from these tokens + /// + /// These routes will only be used when trying to rebalance a LST token + #[clap(long, env, value_parser, value_delimiter = ',')] + pub(crate) rebalance_alternate_sanctum_route_tokens: Option>, + /// 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 0.05, then it'll swap borrow_value * (1 + 0.05) quote token into borrow token. @@ -136,6 +142,12 @@ pub struct Cli { #[clap(long, env, value_enum, default_value = "true")] pub(crate) take_tcs: BoolArg, + #[clap(long, env, default_value = "30")] + pub(crate) tcs_refresh_timeout_secs: u64, + + #[clap(long, env, default_value = "1000")] + pub(crate) tcs_check_interval_ms: u64, + /// profit margin at which to take tcs orders #[clap(long, env, default_value = "0.0005")] pub(crate) tcs_profit_fraction: f64, @@ -178,6 +190,10 @@ pub struct Cli { #[clap(long, env, default_value = "https://quote-api.jup.ag/v6")] pub(crate) jupiter_v6_url: String, + /// override the jupiter http request timeout + #[clap(long, env, default_value = "30")] + pub(crate) jupiter_timeout_secs: u64, + /// provide a jupiter token, currently only for jup v6 #[clap(long, env, default_value = "")] pub(crate) jupiter_token: String, @@ -191,6 +207,12 @@ pub struct Cli { #[clap(long, env, value_enum, default_value = "true")] pub(crate) telemetry: BoolArg, + /// if liquidation is enabled + /// + /// might be used to run an instance of liquidator dedicated to TCS and another one for liquidation + #[clap(long, env, value_enum, default_value = "true")] + pub(crate) liquidation_enabled: BoolArg, + /// liquidation refresh timeout in secs #[clap(long, env, default_value = "30")] pub(crate) liquidation_refresh_timeout_secs: u8, @@ -216,4 +238,16 @@ pub struct Cli { /// how long should it wait before logging an oracle error again (for the same token) #[clap(long, env, default_value = "30")] pub(crate) skip_oracle_error_in_logs_duration_secs: u64, + + /// Also use sanctum for rebalancing + #[clap(long, env, value_enum, default_value = "false")] + pub(crate) sanctum_enabled: BoolArg, + + /// override the url to sanctum + #[clap(long, env, default_value = "https://api.sanctum.so/v1")] + pub(crate) sanctum_url: String, + + /// override the sanctum http request timeout + #[clap(long, env, default_value = "30")] + pub(crate) sanctum_timeout_secs: u64, } diff --git a/bin/liquidator/src/main.rs b/bin/liquidator/src/main.rs index cf6814b61..74b724f3e 100644 --- a/bin/liquidator/src/main.rs +++ b/bin/liquidator/src/main.rs @@ -78,8 +78,11 @@ async fn main() -> anyhow::Result<()> { .commitment(commitment) .fee_payer(Some(liqor_owner.clone())) .timeout(rpc_timeout) - .jupiter_v6_url(cli.jupiter_v6_url) - .jupiter_token(cli.jupiter_token) + .jupiter_timeout(Duration::from_secs(cli.jupiter_timeout_secs)) + .jupiter_v6_url(cli.jupiter_v6_url.clone()) + .jupiter_token(cli.jupiter_token.clone()) + .sanctum_url(cli.sanctum_url.clone()) + .sanctum_timeout(Duration::from_secs(cli.sanctum_timeout_secs)) .transaction_builder_config( TransactionBuilderConfig::builder() .priority_fee_provider(prio_provider) @@ -247,6 +250,11 @@ async fn main() -> anyhow::Result<()> { alternate_jupiter_route_tokens: cli .rebalance_alternate_jupiter_route_tokens .unwrap_or_default(), + alternate_sanctum_route_tokens: cli + .rebalance_alternate_sanctum_route_tokens + .clone() + .unwrap_or_default(), + use_sanctum: cli.sanctum_enabled == BoolArg::True, allow_withdraws: true, }; rebalance_config.validate(&mango_client.context); @@ -256,6 +264,7 @@ async fn main() -> anyhow::Result<()> { account_fetcher: account_fetcher.clone(), mango_account_address: cli.liqor_mango_account, config: rebalance_config, + sanctum_supported_mints: HashSet::::new(), }); let mut liquidation = Box::new(LiquidationState { diff --git a/bin/liquidator/src/rebalance.rs b/bin/liquidator/src/rebalance.rs index 9e8ecf87f..7a867587d 100644 --- a/bin/liquidator/src/rebalance.rs +++ b/bin/liquidator/src/rebalance.rs @@ -5,13 +5,16 @@ use mango_v4::state::{ PlaceOrderType, Side, TokenIndex, QUOTE_TOKEN_INDEX, }; use mango_v4_client::{ - chain_data, jupiter, perp_pnl, MangoClient, MangoGroupContext, PerpMarketContext, TokenContext, + chain_data, perp_pnl, swap, MangoClient, MangoGroupContext, PerpMarketContext, TokenContext, TransactionBuilder, TransactionSize, }; +use solana_client::nonblocking::rpc_client::RpcClient; use {fixed::types::I80F48, solana_sdk::pubkey::Pubkey}; use solana_sdk::signature::Signature; +use std::collections::{HashMap, HashSet}; +use std::future::Future; use std::sync::Arc; use std::time::Duration; use tracing::*; @@ -26,10 +29,12 @@ pub struct Config { /// If this is 1.05, then it'll swap borrow_value * 1.05 quote token into borrow token. pub borrow_settle_excess: f64, pub refresh_timeout: Duration, - pub jupiter_version: jupiter::Version, + pub jupiter_version: swap::Version, pub skip_tokens: Vec, pub alternate_jupiter_route_tokens: Vec, + pub alternate_sanctum_route_tokens: Vec, pub allow_withdraws: bool, + pub use_sanctum: bool, } impl Config { @@ -56,6 +61,7 @@ pub struct Rebalancer { pub account_fetcher: Arc, pub mango_account_address: Pubkey, pub config: Config, + pub sanctum_supported_mints: HashSet, } impl Rebalancer { @@ -69,9 +75,19 @@ impl Rebalancer { "checking for rebalance" ); - self.rebalance_perps().await?; - self.rebalance_tokens().await?; + let rebalance_perps_res = self.rebalance_perps().await; + let rebalance_tokens_res = self.rebalance_tokens().await; + if rebalance_perps_res.is_err() && rebalance_tokens_res.is_err() { + anyhow::bail!( + "Failed to rebalance perps ({}) and tokens ({})", + rebalance_perps_res.unwrap_err(), + rebalance_tokens_res.unwrap_err() + ) + } + + rebalance_perps_res?; + rebalance_tokens_res?; Ok(()) } @@ -95,16 +111,16 @@ impl Rebalancer { Ok(true) } - async fn jupiter_quote( + async fn swap_quote( &self, input_mint: Pubkey, output_mint: Pubkey, amount: u64, only_direct_routes: bool, - jupiter_version: jupiter::Version, - ) -> anyhow::Result { + jupiter_version: swap::Version, + ) -> anyhow::Result { self.mango_client - .jupiter() + .swap() .quote( input_mint, output_mint, @@ -116,28 +132,30 @@ impl Rebalancer { .await } - /// Grab three possible routes: + /// Grab multiples possible routes: /// 1. USDC -> output (complex routes) /// 2. USDC -> output (direct route only) - /// 3. alternate_jupiter_route_tokens -> output (direct route only) - /// Use 1. if it fits into a tx. Otherwise use the better of 2./3. + /// 3. if enabled, sanctum routes - might generate 0, 1 or more routes + /// 4. input -> alternate_jupiter_route_tokens (direct route only) - might generate 0, 1 or more routes + /// Use best of 1/2/3. if it fits into a tx, + /// Otherwise use the best of 4. async fn token_swap_buy( &self, output_mint: Pubkey, in_amount_quote: u64, - ) -> anyhow::Result<(Signature, jupiter::Quote)> { + ) -> anyhow::Result<(Signature, swap::Quote)> { let quote_token = self.mango_client.context.token(QUOTE_TOKEN_INDEX); let quote_mint = quote_token.mint; let jupiter_version = self.config.jupiter_version; - let full_route_job = self.jupiter_quote( + let full_route_job = self.swap_quote( quote_mint, output_mint, in_amount_quote, false, jupiter_version, ); - let direct_quote_route_job = self.jupiter_quote( + let direct_quote_route_job = self.swap_quote( quote_mint, output_mint, in_amount_quote, @@ -146,75 +164,124 @@ impl Rebalancer { ); let mut jobs = vec![full_route_job, direct_quote_route_job]; - for in_token_index in &self.config.alternate_jupiter_route_tokens { - let in_token = self.mango_client.context.token(*in_token_index); - // For the alternate output routes we need to adjust the in amount by the token price - let in_price = self - .account_fetcher - .fetch_bank_price(&in_token.first_bank())?; - let in_amount = (I80F48::from(in_amount_quote) / in_price) - .ceil() - .to_num::(); - let direct_route_job = - self.jupiter_quote(in_token.mint, output_mint, in_amount, true, jupiter_version); - jobs.push(direct_route_job); + if self.can_use_sanctum_for_token(output_mint)? { + for in_token_index in &self.config.alternate_sanctum_route_tokens { + let (alt_mint, alt_in_amount) = + self.get_alternative_token_amount(in_token_index, in_amount_quote)?; + let sanctum_alt_route_job = self.swap_quote( + alt_mint, + output_mint, + alt_in_amount, + false, + swap::Version::Sanctum, + ); + jobs.push(sanctum_alt_route_job); + } } - let mut results = futures::future::join_all(jobs).await; - let full_route = results.remove(0)?; - let alternatives = results.into_iter().filter_map(|v| v.ok()).collect_vec(); + let results = futures::future::join_all(jobs).await; + let routes: Vec<_> = results.into_iter().filter_map(|v| v.ok()).collect_vec(); + + let best_route_res = self + .determine_best_swap_tx(routes, quote_mint, output_mint) + .await; + + let (tx_builder, route) = match best_route_res { + Ok(x) => x, + Err(e) => { + warn!("could not use simple routes because of {}, trying with an alternative one (if configured)", e); + + self.get_jupiter_alt_route( + |in_token_index| { + let (alt_mint, alt_in_amount) = + self.get_alternative_token_amount(in_token_index, in_amount_quote)?; + let swap = self.swap_quote( + alt_mint, + output_mint, + alt_in_amount, + true, + jupiter_version, + ); + Ok(swap) + }, + quote_mint, + output_mint, + ) + .await? + } + }; - let (tx_builder, route) = self - .determine_best_jupiter_tx( - // If the best_route couldn't be fetched, something is wrong - &full_route, - &alternatives, - ) - .await?; let sig = tx_builder .send_and_confirm(&self.mango_client.client) .await?; Ok((sig, route)) } - /// Grab three possible routes: + /// Grab multiples possible routes: /// 1. input -> USDC (complex routes) /// 2. input -> USDC (direct route only) - /// 3. input -> alternate_jupiter_route_tokens (direct route only) - /// Use 1. if it fits into a tx. Otherwise use the better of 2./3. + /// 3. if enabled, sanctum routes - might generate 0, 1 or more routes + /// 4. input -> alternate_jupiter_route_tokens (direct route only) - might generate 0, 1 or more routes + /// Use best of 1/2/3. if it fits into a tx, + /// Otherwise use the best of 4. async fn token_swap_sell( &self, input_mint: Pubkey, in_amount: u64, - ) -> anyhow::Result<(Signature, jupiter::Quote)> { + ) -> anyhow::Result<(Signature, swap::Quote)> { let quote_token = self.mango_client.context.token(QUOTE_TOKEN_INDEX); let quote_mint = quote_token.mint; let jupiter_version = self.config.jupiter_version; let full_route_job = - self.jupiter_quote(input_mint, quote_mint, in_amount, false, jupiter_version); + self.swap_quote(input_mint, quote_mint, in_amount, false, jupiter_version); let direct_quote_route_job = - self.jupiter_quote(input_mint, quote_mint, in_amount, true, jupiter_version); + self.swap_quote(input_mint, quote_mint, in_amount, true, jupiter_version); let mut jobs = vec![full_route_job, direct_quote_route_job]; - for out_token_index in &self.config.alternate_jupiter_route_tokens { - let out_token = self.mango_client.context.token(*out_token_index); - let direct_route_job = - self.jupiter_quote(input_mint, out_token.mint, in_amount, true, jupiter_version); - jobs.push(direct_route_job); + if self.can_use_sanctum_for_token(input_mint)? { + for out_token_index in &self.config.alternate_sanctum_route_tokens { + let out_token = self.mango_client.context.token(*out_token_index); + let sanctum_job = self.swap_quote( + input_mint, + out_token.mint, + in_amount, + false, + swap::Version::Sanctum, + ); + jobs.push(sanctum_job); + } } - let mut results = futures::future::join_all(jobs).await; - let full_route = results.remove(0)?; - let alternatives = results.into_iter().filter_map(|v| v.ok()).collect_vec(); + let results = futures::future::join_all(jobs).await; + let routes: Vec<_> = results.into_iter().filter_map(|v| v.ok()).collect_vec(); - let (tx_builder, route) = self - .determine_best_jupiter_tx( - // If the best_route couldn't be fetched, something is wrong - &full_route, - &alternatives, - ) - .await?; + let best_route_res = self + .determine_best_swap_tx(routes, input_mint, quote_mint) + .await; + + let (tx_builder, route) = match best_route_res { + Ok(x) => x, + Err(e) => { + warn!("could not use simple routes because of {}, trying with an alternative one (if configured)", e); + + self.get_jupiter_alt_route( + |out_token_index| { + let out_token = self.mango_client.context.token(*out_token_index); + Ok(self.swap_quote( + input_mint, + out_token.mint, + in_amount, + true, + jupiter_version, + )) + }, + input_mint, + quote_mint, + ) + .await? + } + }; let sig = tx_builder .send_and_confirm(&self.mango_client.client) @@ -222,47 +289,133 @@ impl Rebalancer { Ok((sig, route)) } - async fn determine_best_jupiter_tx( + fn get_alternative_token_amount( &self, - full: &jupiter::Quote, - alternatives: &[jupiter::Quote], - ) -> anyhow::Result<(TransactionBuilder, jupiter::Quote)> { - let builder = self - .mango_client - .jupiter() - .prepare_swap_transaction(full) - .await?; - let tx_size = builder.transaction_size()?; - if tx_size.is_within_limit() { - return Ok((builder, full.clone())); - } - trace!( - route_label = full.first_route_label(), - %full.input_mint, - %full.output_mint, - ?tx_size, - limit = ?TransactionSize::limit(), - "full route does not fit in a tx", - ); + in_token_index: &u16, + in_amount_quote: u64, + ) -> anyhow::Result<(Pubkey, u64)> { + let in_token: &TokenContext = self.mango_client.context.token(*in_token_index); + let in_price = self + .account_fetcher + .fetch_bank_price(&in_token.first_bank())?; + let in_amount = (I80F48::from(in_amount_quote) / in_price) + .ceil() + .to_num::(); - if alternatives.is_empty() { - anyhow::bail!( - "no alternative routes from {} to {}", - full.input_mint, - full.output_mint + Ok((in_token.mint, in_amount)) + } + + fn can_use_sanctum_for_token(&self, mint: Pubkey) -> anyhow::Result { + if !self.config.use_sanctum { + return Ok(false); + } + + let token = self.mango_client.context.token_by_mint(&mint)?; + + let can_swap_on_sanctum = self.can_swap_on_sanctum(mint); + + // forbid swapping to something that could be used in another sanctum swap, creating a cycle + let is_an_alt_for_sanctum = self + .config + .alternate_sanctum_route_tokens + .contains(&token.token_index); + + Ok(can_swap_on_sanctum && !is_an_alt_for_sanctum) + } + + async fn get_jupiter_alt_route>>( + &self, + quote_fetcher: impl Fn(&u16) -> anyhow::Result, + original_input_mint: Pubkey, + original_output_mint: Pubkey, + ) -> anyhow::Result<(TransactionBuilder, swap::Quote)> { + let mut alt_jobs = vec![]; + for in_token_index in &self.config.alternate_jupiter_route_tokens { + alt_jobs.push(quote_fetcher(in_token_index)?); + } + let alt_results = futures::future::join_all(alt_jobs).await; + 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) + .await?; + Ok(best_route) + } + + async fn determine_best_swap_tx( + &self, + mut routes: Vec, + original_input_mint: Pubkey, + original_output_mint: Pubkey, + ) -> anyhow::Result<(TransactionBuilder, swap::Quote)> { + let mut prices = HashMap::::new(); + let mut get_or_fetch_price = |m| { + let entry = prices.entry(m).or_insert_with(|| { + let token = self + .mango_client + .context + .token_by_mint(&m) + .expect("token for mint not found"); + self.account_fetcher + .fetch_bank_price(&token.first_bank()) + .expect("failed to fetch price") + }); + *entry + }; + + routes.sort_by_cached_key(|r| { + let in_price = get_or_fetch_price(r.input_mint); + let out_price = get_or_fetch_price(r.output_mint); + let amount = out_price * I80F48::from_num(r.out_amount) + - in_price * I80F48::from_num(r.in_amount); + + let t = match r.raw { + swap::RawQuote::Mock => "mock", + swap::RawQuote::V6(_) => "jupiter", + swap::RawQuote::Sanctum(_) => "sanctum", + }; + + debug!( + "quote for {} vs {} [using {}] is {}@{} vs {}@{} -> amount={}", + r.input_mint, + r.output_mint, + t, + r.in_amount, + in_price, + r.out_amount, + out_price, + amount + ); + + std::cmp::Reverse(amount) + }); + + for route in routes { + let builder = self + .mango_client + .swap() + .prepare_swap_transaction(&route) + .await?; + let tx_size = builder.transaction_size()?; + if tx_size.is_within_limit() { + return Ok((builder, route.clone())); + } + + trace!( + route_label = route.first_route_label(), + %route.input_mint, + %route.output_mint, + ?tx_size, + limit = ?TransactionSize::limit(), + "route does not fit in a tx", ); } - let best = alternatives - .iter() - .min_by(|a, b| a.price_impact_pct.partial_cmp(&b.price_impact_pct).unwrap()) - .unwrap(); - let builder = self - .mango_client - .jupiter() - .prepare_swap_transaction(best) - .await?; - Ok((builder, best.clone())) + anyhow::bail!( + "no routes from {} to {}", + original_input_mint, + original_output_mint + ); } fn mango_account(&self) -> anyhow::Result> { @@ -278,7 +431,7 @@ impl Rebalancer { // TODO: configurable? let quote_token = self.mango_client.context.token(QUOTE_TOKEN_INDEX); - for token_position in account.active_token_positions() { + for token_position in Self::shuffle(account.active_token_positions()) { let token_index = token_position.token_index; let token = self.mango_client.context.token(token_index); if token_index == quote_token.token_index @@ -400,13 +553,13 @@ impl Rebalancer { } #[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, @@ -468,9 +621,10 @@ impl Rebalancer { return Ok(true); } - let txsig = self + let ixs = self .mango_client - .perp_place_order( + .perp_place_order_instruction( + account, perp_position.market_index, side, price_lots, @@ -484,6 +638,17 @@ impl Rebalancer { mango_v4::state::SelfTradeBehavior::DecrementTake, ) .await?; + + let tx_builder = TransactionBuilder { + instructions: ixs.to_instructions(), + signers: vec![self.mango_client.owner.clone()], + ..self.mango_client.transaction_builder().await? + }; + + let txsig = tx_builder + .send_and_confirm(&self.mango_client.client) + .await?; + info!( %txsig, %order_price, @@ -557,7 +722,7 @@ impl Rebalancer { async fn rebalance_perps(&self) -> anyhow::Result<()> { let account = self.mango_account()?; - for perp_position in account.active_perp_positions() { + for perp_position in Self::shuffle(account.active_perp_positions()) { let perp = self.mango_client.context.perp(perp_position.market_index); if !self.rebalance_perp(&account, perp, perp_position).await? { return Ok(()); @@ -566,4 +731,27 @@ impl Rebalancer { Ok(()) } + + fn shuffle(iterator: impl Iterator) -> Vec { + use rand::seq::SliceRandom; + + let mut result = iterator.collect::>(); + { + let mut rng = rand::thread_rng(); + result.shuffle(&mut rng); + } + + result + } + + fn can_swap_on_sanctum(&self, mint: Pubkey) -> bool { + self.sanctum_supported_mints.contains(&mint) + } + + pub async fn init(&mut self, live_rpc_client: &RpcClient) { + match swap::sanctum::load_supported_token_mints(live_rpc_client).await { + Err(e) => warn!("Could not load list of sanctum supported mint: {}", e), + Ok(mint) => self.sanctum_supported_mints.extend(mint), + } + } } diff --git a/bin/liquidator/src/token_swap_info.rs b/bin/liquidator/src/token_swap_info.rs index 8e4e018ad..9f35e4f97 100644 --- a/bin/liquidator/src/token_swap_info.rs +++ b/bin/liquidator/src/token_swap_info.rs @@ -6,7 +6,7 @@ use mango_v4_client::error_tracking::ErrorTracking; use tracing::*; use mango_v4::state::TokenIndex; -use mango_v4_client::jupiter; +use mango_v4_client::swap; use mango_v4_client::MangoClient; pub struct Config { @@ -15,7 +15,7 @@ pub struct Config { /// Size in quote_index-token native tokens to quote. pub quote_amount: u64, - pub jupiter_version: jupiter::Version, + pub jupiter_version: swap::Version, } #[derive(Clone)] @@ -84,7 +84,7 @@ impl TokenSwapInfoUpdater { lock.swap_infos.get(&token_index).cloned() } - fn in_per_out_price(route: &jupiter::Quote) -> f64 { + fn in_per_out_price(route: &swap::Quote) -> f64 { let in_amount = route.in_amount as f64; let out_amount = route.out_amount as f64; in_amount / out_amount @@ -149,7 +149,7 @@ impl TokenSwapInfoUpdater { let token_amount = (self.config.quote_amount as f64 * token_per_quote_oracle) as u64; let sell_route = self .mango_client - .jupiter() + .swap() .quote( token_mint, quote_mint, @@ -161,7 +161,7 @@ impl TokenSwapInfoUpdater { .await?; let buy_route = self .mango_client - .jupiter() + .swap() .quote( quote_mint, token_mint, diff --git a/bin/liquidator/src/trigger_tcs.rs b/bin/liquidator/src/trigger_tcs.rs index d42104846..8a3ef06ee 100644 --- a/bin/liquidator/src/trigger_tcs.rs +++ b/bin/liquidator/src/trigger_tcs.rs @@ -12,7 +12,7 @@ use mango_v4::{ i80f48::ClampToInt, state::{Bank, MangoAccountValue, TokenConditionalSwap, TokenIndex}, }; -use mango_v4_client::{chain_data, jupiter, MangoClient, TransactionBuilder}; +use mango_v4_client::{chain_data, swap, MangoClient, TransactionBuilder}; use anyhow::Context as AnyhowContext; use solana_sdk::signature::Signature; @@ -70,7 +70,7 @@ pub struct Config { /// Can be set to 0 to allow executions of any size. pub min_buy_fraction: f64, - pub jupiter_version: jupiter::Version, + pub jupiter_version: swap::Version, pub jupiter_slippage_bps: u64, pub mode: Mode, @@ -121,9 +121,9 @@ impl JupiterQuoteCache { output_mint: Pubkey, input_amount: u64, slippage_bps: u64, - version: jupiter::Version, + version: swap::Version, max_in_per_out_price: f64, - ) -> anyhow::Result> { + ) -> anyhow::Result> { let cache_entry = self.cache_entry(input_mint, output_mint); let held_lock = { @@ -181,10 +181,10 @@ impl JupiterQuoteCache { output_mint: Pubkey, input_amount: u64, slippage_bps: u64, - version: jupiter::Version, - ) -> anyhow::Result<(f64, jupiter::Quote)> { + version: swap::Version, + ) -> anyhow::Result<(f64, swap::Quote)> { let quote = client - .jupiter() + .swap() .quote( input_mint, output_mint, @@ -205,8 +205,8 @@ impl JupiterQuoteCache { output_mint: Pubkey, input_amount: u64, slippage_bps: u64, - version: jupiter::Version, - ) -> anyhow::Result<(f64, jupiter::Quote)> { + version: swap::Version, + ) -> anyhow::Result<(f64, swap::Quote)> { match self .quote( client, @@ -252,11 +252,10 @@ impl JupiterQuoteCache { collateral_amount: u64, sell_amount: u64, slippage_bps: u64, - version: jupiter::Version, + version: swap::Version, max_sell_per_buy_price: f64, - ) -> anyhow::Result< - JupiterQuoteCacheResult<(f64, Option, Option)>, - > { + ) -> anyhow::Result, Option)>> + { // First check if we have cached prices for both legs and // if those break the specified limit let cached_collateral_to_buy = self.cached_price(collateral_mint, buy_mint).await; @@ -335,7 +334,7 @@ struct PreparedExecution { max_sell_token_to_liqor: u64, min_buy_token: u64, min_taker_price: f32, - jupiter_quote: Option, + jupiter_quote: Option, } struct PreparationResult { @@ -1191,7 +1190,7 @@ impl Context { // Jupiter quote is provided only for triggers, not close-expired let mut tx_builder = if let Some(jupiter_quote) = pending.jupiter_quote { self.mango_client - .jupiter() + .swap() .prepare_swap_transaction(&jupiter_quote) .await? } else { diff --git a/lib/client/Cargo.toml b/lib/client/Cargo.toml index cc1c29a9d..b23dc9d15 100644 --- a/lib/client/Cargo.toml +++ b/lib/client/Cargo.toml @@ -46,3 +46,4 @@ base64 = "0.13.0" bincode = "1.3.3" tracing = { version = "0.1", features = ["log"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } +borsh = { version = "0.10.3", features = ["const-generics"] } diff --git a/lib/client/src/client.rs b/lib/client/src/client.rs index 9f07df7cc..eea6a2ed4 100644 --- a/lib/client/src/client.rs +++ b/lib/client/src/client.rs @@ -17,7 +17,9 @@ use futures::{stream, StreamExt, TryFutureExt, TryStreamExt}; use itertools::Itertools; use tracing::*; -use mango_v4::accounts_ix::{Serum3OrderType, Serum3SelfTradeBehavior, Serum3Side}; +use mango_v4::accounts_ix::{ + HealthCheckKind, Serum3OrderType, Serum3SelfTradeBehavior, Serum3Side, +}; use mango_v4::accounts_zerocopy::KeyedAccountSharedData; use mango_v4::health::HealthCache; use mango_v4::state::{ @@ -25,14 +27,14 @@ use mango_v4::state::{ PlaceOrderType, SelfTradeBehavior, Serum3MarketIndex, Side, TokenIndex, INSURANCE_TOKEN_INDEX, }; -use crate::account_fetcher::*; 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::{jupiter, util}; +use crate::{account_fetcher::*, swap}; use solana_address_lookup_table_program::state::AddressLookupTable; use solana_client::nonblocking::rpc_client::RpcClient as RpcClientAsync; use solana_client::rpc_client::SerializableTransaction; @@ -80,6 +82,12 @@ pub struct ClientConfig { #[builder(default = "Duration::from_secs(60)")] pub timeout: Duration, + /// Jupiter Timeout, defaults to 30s + /// + /// This timeout applies to jupiter requests. + #[builder(default = "Duration::from_secs(30)")] + pub jupiter_timeout: Duration, + #[builder(default)] pub transaction_builder_config: TransactionBuilderConfig, @@ -97,6 +105,15 @@ pub struct ClientConfig { #[builder(default = "\"\".into()")] pub jupiter_token: String, + #[builder(default = "\"https://api.sanctum.so/v1\".into()")] + pub sanctum_url: String, + + /// Sanctum Timeout, defaults to 30s + /// + /// This timeout applies to jupiter requests. + #[builder(default = "Duration::from_secs(30)")] + pub sanctum_timeout: Duration, + /// Determines how fallback oracle accounts are provided to instructions. Defaults to Dynamic. #[builder(default = "FallbackOracleConfig::Dynamic")] pub fallback_oracle_config: FallbackOracleConfig, @@ -560,6 +577,48 @@ impl MangoClient { self.send_and_confirm_owner_tx(ixs.to_instructions()).await } + /// Assert that health of account is > N + pub async fn health_check_instruction( + &self, + account: &MangoAccountValue, + min_health_value: f64, + affected_tokens: Vec, + affected_perp_markets: Vec, + check_kind: HealthCheckKind, + ) -> anyhow::Result { + let (health_check_metas, health_cu) = self + .derive_health_check_remaining_account_metas( + account, + affected_tokens, + vec![], + affected_perp_markets, + ) + .await?; + + let ixs = PreparedInstructions::from_vec( + vec![Instruction { + program_id: mango_v4::id(), + accounts: { + let mut ams = anchor_lang::ToAccountMetas::to_account_metas( + &mango_v4::accounts::HealthCheck { + group: self.group(), + account: self.mango_account_address, + }, + None, + ); + ams.extend(health_check_metas.into_iter()); + ams + }, + data: anchor_lang::InstructionData::data(&mango_v4::instruction::HealthCheck { + min_health_value, + check_kind, + }), + }], + self.instruction_cu(health_cu), + ); + 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. @@ -618,64 +677,6 @@ impl MangoClient { Ok(ixs) } - /// Creates token withdraw instructions performed by the delegate 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. - pub async fn token_withdraw_as_delegate_instructions( - &self, - account: &MangoAccountValue, - mint: Pubkey, - amount: u64, - allow_borrow: bool, - ) -> anyhow::Result { - let token = self.context.token_by_mint(&mint)?; - let token_index = token.token_index; - - let (health_check_metas, health_cu) = self - .derive_health_check_remaining_account_metas(account, vec![token_index], vec![], vec![]) - .await?; - - let ixs = PreparedInstructions::from_vec( - vec![ - spl_associated_token_account::instruction::create_associated_token_account_idempotent( - &self.owner(), // delegate is payer - &account.fixed.owner, // mango account owner is owner - &mint, - &Token::id(), - ), - 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: token.first_bank(), - vault: token.first_vault(), - oracle: token.oracle, - token_account: get_associated_token_address( - &account.fixed.owner, - &token.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, - }), - }, - ], - self.instruction_cu(health_cu), - ); - Ok(ixs) - } - pub async fn token_withdraw( &self, mint: Pubkey, @@ -683,14 +684,9 @@ impl MangoClient { allow_borrow: bool, ) -> anyhow::Result { let account = self.mango_account().await?; - let is_delegate = account.fixed.is_delegate(self.owner()); - let ixs = if is_delegate { - self.token_withdraw_as_delegate_instructions(&account, mint, amount, allow_borrow) - .await? - } else { - self.token_withdraw_instructions(&account, mint, amount, allow_borrow) - .await? - }; + let ixs = self + .token_withdraw_instructions(&account, mint, amount, allow_borrow) + .await?; self.send_and_confirm_owner_tx(ixs.to_instructions()).await } @@ -2154,14 +2150,68 @@ impl MangoClient { )) } - // jupiter - - pub fn jupiter_v6(&self) -> jupiter::v6::JupiterV6 { - jupiter::v6::JupiterV6 { mango_client: self } + // Swap (jupiter, sanctum) + pub fn swap(&self) -> swap::Swap { + swap::Swap { mango_client: self } } - pub fn jupiter(&self) -> jupiter::Jupiter { - jupiter::Jupiter { mango_client: self } + pub fn jupiter_v6(&self) -> swap::jupiter_v6::JupiterV6 { + swap::jupiter_v6::JupiterV6 { mango_client: self } + } + + pub fn sanctum(&self) -> swap::sanctum::Sanctum { + swap::sanctum::Sanctum { + mango_client: self, + timeout_duration: self.client.config.sanctum_timeout, + } + } + + pub(crate) async fn deserialize_instructions_and_alts( + &self, + message: &solana_sdk::message::VersionedMessage, + ) -> anyhow::Result<(Vec, Vec)> { + let lookups = message.address_table_lookups().unwrap_or_default(); + let address_lookup_tables = self + .fetch_address_lookup_tables(lookups.iter().map(|a| &a.account_key)) + .await?; + + let mut account_keys = message.static_account_keys().to_vec(); + for (lookups, table) in lookups.iter().zip(address_lookup_tables.iter()) { + account_keys.extend( + lookups + .writable_indexes + .iter() + .map(|&index| table.addresses[index as usize]), + ); + } + for (lookups, table) in lookups.iter().zip(address_lookup_tables.iter()) { + account_keys.extend( + lookups + .readonly_indexes + .iter() + .map(|&index| table.addresses[index as usize]), + ); + } + + let compiled_ix = message + .instructions() + .iter() + .map(|ci| solana_sdk::instruction::Instruction { + program_id: *ci.program_id(&account_keys), + accounts: ci + .accounts + .iter() + .map(|&index| AccountMeta { + pubkey: account_keys[index as usize], + is_signer: message.is_signer(index.into()), + is_writable: message.is_maybe_writable(index.into()), + }) + .collect(), + data: ci.data.clone(), + }) + .collect(); + + Ok((compiled_ix, address_lookup_tables)) } pub async fn fetch_address_lookup_table( @@ -2486,6 +2536,11 @@ impl TransactionBuilder { length: bytes.len(), }) } + + pub fn append(&mut self, prepared_instructions: PreparedInstructions) { + self.instructions + .extend(prepared_instructions.to_instructions()); + } } /// Do some manual unpacking on some ClientErrors diff --git a/lib/client/src/gpa.rs b/lib/client/src/gpa.rs index e96aa5418..7c02ed8c0 100644 --- a/lib/client/src/gpa.rs +++ b/lib/client/src/gpa.rs @@ -1,11 +1,12 @@ use anchor_lang::{AccountDeserialize, Discriminator}; +use futures::{stream, StreamExt}; use mango_v4::state::{Bank, MangoAccount, MangoAccountValue, MintInfo, PerpMarket, Serum3Market}; use solana_account_decoder::UiAccountEncoding; use solana_client::nonblocking::rpc_client::RpcClient as RpcClientAsync; use solana_client::rpc_config::{RpcAccountInfoConfig, RpcProgramAccountsConfig}; use solana_client::rpc_filter::{Memcmp, RpcFilterType}; -use solana_sdk::account::AccountSharedData; +use solana_sdk::account::{Account, AccountSharedData}; use solana_sdk::pubkey::Pubkey; pub async fn fetch_mango_accounts( @@ -148,3 +149,49 @@ pub async fn fetch_multiple_accounts( .map(|(acc, key)| (*key, acc.unwrap().into())) .collect()) } + +/// Fetch multiple account using one request per chunk of `max_chunk_size` accounts +/// Can execute in parallel up to `parallel_rpc_requests` +/// +/// WARNING: some accounts requested may be missing from the result +pub async fn fetch_multiple_accounts_in_chunks( + rpc: &RpcClientAsync, + keys: &[Pubkey], + max_chunk_size: usize, + parallel_rpc_requests: usize, +) -> anyhow::Result> { + let config = RpcAccountInfoConfig { + encoding: Some(UiAccountEncoding::Base64), + ..RpcAccountInfoConfig::default() + }; + + let raw_results = stream::iter(keys) + .chunks(max_chunk_size) + .map(|keys| { + let account_info_config = config.clone(); + async move { + let keys = keys.iter().map(|x| **x).collect::>(); + let req_res = rpc + .get_multiple_accounts_with_config(&keys, account_info_config) + .await; + + match req_res { + Ok(v) => Ok(keys.into_iter().zip(v.value).collect::>()), + Err(e) => Err(e), + } + } + }) + .buffer_unordered(parallel_rpc_requests) + .collect::>() + .await; + + let result = raw_results + .into_iter() + .collect::, _>>()? + .into_iter() + .flatten() + .filter_map(|(pubkey, account_opt)| account_opt.map(|acc| (pubkey, acc))) + .collect::>(); + + Ok(result) +} diff --git a/lib/client/src/lib.rs b/lib/client/src/lib.rs index 882a931f6..620477f2a 100644 --- a/lib/client/src/lib.rs +++ b/lib/client/src/lib.rs @@ -13,11 +13,11 @@ mod context; pub mod error_tracking; pub mod gpa; pub mod health_cache; -pub mod jupiter; pub mod perp_pnl; pub mod priority_fees; pub mod priority_fees_cli; pub mod snapshot_source; +pub mod swap; mod util; pub mod websocket_source; diff --git a/lib/client/src/jupiter/v6.rs b/lib/client/src/swap/jupiter_v6.rs similarity index 98% rename from lib/client/src/jupiter/v6.rs rename to lib/client/src/swap/jupiter_v6.rs index 6c73fc741..1ebe27d89 100644 --- a/lib/client/src/jupiter/v6.rs +++ b/lib/client/src/swap/jupiter_v6.rs @@ -72,13 +72,7 @@ pub struct SwapRequest { #[derive(Deserialize, Serialize, Debug, Clone)] #[serde(rename_all = "camelCase")] -pub struct SwapResponse { - pub swap_transaction: String, -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct SwapInstructionsResponse { +pub struct JupiterSwapInstructionsResponse { pub token_ledger_instruction: Option, pub compute_budget_instructions: Option>, pub setup_instructions: Option>, @@ -294,7 +288,7 @@ impl<'a> JupiterV6<'a> { .await .context("swap transaction request to jupiter")?; - let swap: SwapInstructionsResponse = util::http_error_handling(swap_response) + let swap: JupiterSwapInstructionsResponse = util::http_error_handling(swap_response) .await .context("error requesting jupiter swap")?; diff --git a/lib/client/src/jupiter/mod.rs b/lib/client/src/swap/mod.rs similarity index 69% rename from lib/client/src/jupiter/mod.rs rename to lib/client/src/swap/mod.rs index e8eeeb2ed..4a0c9c9d0 100644 --- a/lib/client/src/jupiter/mod.rs +++ b/lib/client/src/swap/mod.rs @@ -1,4 +1,6 @@ -pub mod v6; +pub mod jupiter_v6; +pub mod sanctum; +pub mod sanctum_state; use anchor_lang::prelude::*; use std::str::FromStr; @@ -10,13 +12,15 @@ use fixed::types::I80F48; pub enum Version { Mock, V6, + Sanctum, } #[derive(Clone)] #[allow(clippy::large_enum_variant)] pub enum RawQuote { Mock, - V6(v6::QuoteResponse), + V6(jupiter_v6::QuoteResponse), + Sanctum(sanctum::QuoteResponse), } #[derive(Clone)] @@ -30,7 +34,7 @@ pub struct Quote { } impl Quote { - pub fn try_from_v6(query: v6::QuoteResponse) -> anyhow::Result { + pub fn try_from_v6(query: jupiter_v6::QuoteResponse) -> anyhow::Result { Ok(Quote { input_mint: Pubkey::from_str(&query.input_mint)?, output_mint: Pubkey::from_str(&query.output_mint)?, @@ -45,6 +49,25 @@ impl Quote { }) } + pub fn try_from_sanctum( + input_mint: Pubkey, + output_mint: Pubkey, + query: sanctum::QuoteResponse, + ) -> anyhow::Result { + Ok(Quote { + input_mint: input_mint, + output_mint: output_mint, + price_impact_pct: query.fee_pct.parse()?, + in_amount: query + .in_amount + .as_ref() + .map(|a| a.parse()) + .unwrap_or(Ok(0))?, + out_amount: query.out_amount.parse()?, + raw: RawQuote::Sanctum(query), + }) + } + pub fn first_route_label(&self) -> String { let label_maybe = match &self.raw { RawQuote::Mock => Some("mock".into()), @@ -54,16 +77,17 @@ impl Quote { .and_then(|v| v.swap_info.as_ref()) .and_then(|v| v.label.as_ref()) .cloned(), + RawQuote::Sanctum(raw) => Some(raw.swap_src.clone()), }; label_maybe.unwrap_or_else(|| "unknown".into()) } } -pub struct Jupiter<'a> { +pub struct Swap<'a> { pub mango_client: &'a MangoClient, } -impl<'a> Jupiter<'a> { +impl<'a> Swap<'a> { async fn quote_mock( &self, input_mint: Pubkey, @@ -123,6 +147,14 @@ impl<'a> Jupiter<'a> { ) .await?, )?, + Version::Sanctum => Quote::try_from_sanctum( + input_mint, + output_mint, + self.mango_client + .sanctum() + .quote(input_mint, output_mint, amount) + .await?, + )?, }) } @@ -138,6 +170,18 @@ impl<'a> Jupiter<'a> { .prepare_swap_transaction(raw) .await } + RawQuote::Sanctum(raw) => { + let max_slippage_bps = (quote.price_impact_pct * 100.0).ceil() as u64; + self.mango_client + .sanctum() + .prepare_swap_transaction( + quote.input_mint, + quote.output_mint, + max_slippage_bps, + raw, + ) + .await + } } } } diff --git a/lib/client/src/swap/sanctum.rs b/lib/client/src/swap/sanctum.rs new file mode 100644 index 000000000..ddd2bed06 --- /dev/null +++ b/lib/client/src/swap/sanctum.rs @@ -0,0 +1,406 @@ +use std::collections::HashSet; +use std::str::FromStr; + +use anchor_lang::{system_program, Id}; +use anchor_spl::token::Token; +use anyhow::Context; +use bincode::Options; +use mango_v4::accounts_zerocopy::AccountReader; +use serde::{Deserialize, Serialize}; +use solana_address_lookup_table_program::state::AddressLookupTable; +use solana_client::nonblocking::rpc_client::RpcClient; +use solana_sdk::account::Account; +use solana_sdk::{instruction::Instruction, pubkey::Pubkey, signature::Signature}; +use std::time::Duration; + +use crate::gpa::fetch_multiple_accounts_in_chunks; +use crate::swap::sanctum_state; +use crate::{util, MangoClient, TransactionBuilder}; +use borsh::BorshDeserialize; + +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct QuoteResponse { + pub in_amount: Option, + pub out_amount: String, + pub fee_amount: String, + pub fee_mint: String, + pub fee_pct: String, + pub swap_src: String, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SwapRequest { + pub amount: String, + pub quoted_amount: String, + pub input: String, + pub mode: String, + pub output_lst_mint: String, + pub signer: String, + pub swap_src: String, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SanctumSwapResponse { + pub tx: String, +} + +pub struct Sanctum<'a> { + pub mango_client: &'a MangoClient, + pub timeout_duration: Duration, +} + +impl<'a> Sanctum<'a> { + pub async fn quote( + &self, + input_mint: Pubkey, + output_mint: Pubkey, + amount: u64, + ) -> anyhow::Result { + if input_mint == output_mint { + anyhow::bail!("Need two distinct mint to swap"); + } + + let mut account = self.mango_client.mango_account().await?; + let input_token_index = self + .mango_client + .context + .token_by_mint(&input_mint)? + .token_index; + let output_token_index = self + .mango_client + .context + .token_by_mint(&output_mint)? + .token_index; + account.ensure_token_position(input_token_index)?; + account.ensure_token_position(output_token_index)?; + + let query_args = vec![ + ("input", input_mint.to_string()), + ("outputLstMint", output_mint.to_string()), + ("amount", format!("{}", amount)), + ]; + let config = self.mango_client.client.config(); + + let response = self + .mango_client + .http_client + .get(format!("{}/swap/quote", config.sanctum_url)) + .query(&query_args) + .timeout(self.timeout_duration) + .send() + .await + .context("quote request to sanctum")?; + let quote: QuoteResponse = + util::http_error_handling(response).await.with_context(|| { + format!("error requesting sanctum route between {input_mint} and {output_mint} (using url: {})", config.sanctum_url) + })?; + + Ok(quote) + } + + /// Find the instructions and account lookup tables for a sanctum swap through mango + pub async fn prepare_swap_transaction( + &self, + input_mint: Pubkey, + output_mint: Pubkey, + max_slippage_bps: u64, + quote: &QuoteResponse, + ) -> anyhow::Result { + tracing::info!("swapping using sanctum"); + + let source_token = self.mango_client.context.token_by_mint(&input_mint)?; + let target_token = self.mango_client.context.token_by_mint(&output_mint)?; + + let bank_ams = [source_token.first_bank(), target_token.first_bank()] + .into_iter() + .map(util::to_writable_account_meta) + .collect::>(); + + let vault_ams = [source_token.first_vault(), target_token.first_vault()] + .into_iter() + .map(util::to_writable_account_meta) + .collect::>(); + + let owner = self.mango_client.owner(); + let account = &self.mango_client.mango_account().await?; + + let token_ams = [source_token.mint, target_token.mint] + .into_iter() + .map(|mint| { + util::to_writable_account_meta( + anchor_spl::associated_token::get_associated_token_address(&owner, &mint), + ) + }) + .collect::>(); + + let source_loan = quote + .in_amount + .as_ref() + .map(|v| u64::from_str(v).unwrap()) + .unwrap_or(0); + let loan_amounts = vec![source_loan, 0u64]; + let num_loans: u8 = loan_amounts.len().try_into().unwrap(); + + // This relies on the fact that health account banks will be identical to the first_bank above! + let (health_ams, _health_cu) = self + .mango_client + .derive_health_check_remaining_account_metas( + account, + vec![source_token.token_index, target_token.token_index], + vec![source_token.token_index, target_token.token_index], + vec![], + ) + .await + .context("building health accounts")?; + + let config = self.mango_client.client.config(); + + let in_amount = quote + .in_amount + .clone() + .expect("sanctum require a in amount"); + let quote_amount_u64 = quote.out_amount.parse::()?; + let out_amount = ((quote_amount_u64 as f64) * (1.0 - (max_slippage_bps as f64) / 10_000.0)) + .ceil() as u64; + + let swap_response = self + .mango_client + .http_client + .post(format!("{}/swap", config.sanctum_url)) + .json(&SwapRequest { + amount: in_amount.clone(), + quoted_amount: out_amount.to_string(), + input: input_mint.to_string(), + mode: "ExactIn".to_string(), + output_lst_mint: output_mint.to_string(), + signer: owner.to_string(), + swap_src: quote.swap_src.clone(), + }) + .timeout(self.timeout_duration) + .send() + .await + .context("swap transaction request to sanctum")?; + + let swap_r: SanctumSwapResponse = util::http_error_handling(swap_response) + .await + .context("error requesting sanctum swap")?; + + let tx = bincode::options() + .with_fixint_encoding() + .reject_trailing_bytes() + .deserialize::( + &base64::decode(&swap_r.tx).context("base64 decoding sanctum transaction")?, + ) + .context("parsing sanctum transaction")?; + + let (sanctum_ixs_orig, sanctum_alts) = self + .mango_client + .deserialize_instructions_and_alts(&tx.message) + .await?; + + let system_program = system_program::ID; + let ata_program = anchor_spl::associated_token::ID; + let token_program = anchor_spl::token::ID; + let compute_budget_program: Pubkey = solana_sdk::compute_budget::ID; + // these setup instructions should be placed outside of flashloan begin-end + let is_setup_ix = |k: Pubkey| -> bool { + k == ata_program || k == token_program || k == compute_budget_program + }; + let sync_native_pack = + anchor_spl::token::spl_token::instruction::TokenInstruction::SyncNative.pack(); + + // Remove auto wrapping of SOL->wSOL + let sanctum_ixs: Vec = sanctum_ixs_orig + .clone() + .into_iter() + .filter(|ix| { + !(ix.program_id == system_program) + && !(ix.program_id == token_program && ix.data == sync_native_pack) + }) + .collect(); + + let sanctum_action_ix_begin = sanctum_ixs + .iter() + .position(|ix| !is_setup_ix(ix.program_id)) + .ok_or_else(|| { + anyhow::anyhow!("sanctum swap response only had setup-like instructions") + })?; + let sanctum_action_ix_end = sanctum_ixs.len() + - sanctum_ixs + .iter() + .rev() + .position(|ix| !is_setup_ix(ix.program_id)) + .unwrap(); + + let mut instructions: Vec = Vec::new(); + + for ix in &sanctum_ixs[..sanctum_action_ix_begin] { + instructions.push(ix.clone()); + } + + // Ensure the source token account is created (sanctum takes care of the output account) + instructions.push( + spl_associated_token_account::instruction::create_associated_token_account_idempotent( + &owner, + &owner, + &source_token.mint, + &Token::id(), + ), + ); + + instructions.push(Instruction { + program_id: mango_v4::id(), + accounts: { + let mut ams = anchor_lang::ToAccountMetas::to_account_metas( + &mango_v4::accounts::FlashLoanBegin { + account: self.mango_client.mango_account_address, + owner, + token_program: Token::id(), + instructions: solana_sdk::sysvar::instructions::id(), + }, + None, + ); + ams.extend(bank_ams); + ams.extend(vault_ams.clone()); + ams.extend(token_ams.clone()); + ams.push(util::to_readonly_account_meta(self.mango_client.group())); + ams + }, + data: anchor_lang::InstructionData::data(&mango_v4::instruction::FlashLoanBegin { + loan_amounts, + }), + }); + + for ix in &sanctum_ixs[sanctum_action_ix_begin..sanctum_action_ix_end] { + instructions.push(ix.clone()); + } + + instructions.push(Instruction { + program_id: mango_v4::id(), + accounts: { + let mut ams = anchor_lang::ToAccountMetas::to_account_metas( + &mango_v4::accounts::FlashLoanEnd { + account: self.mango_client.mango_account_address, + owner, + token_program: Token::id(), + }, + None, + ); + ams.extend(health_ams); + ams.extend(vault_ams); + ams.extend(token_ams); + ams.push(util::to_readonly_account_meta(self.mango_client.group())); + ams + }, + data: anchor_lang::InstructionData::data(&mango_v4::instruction::FlashLoanEndV2 { + num_loans, + flash_loan_type: mango_v4::accounts_ix::FlashLoanType::Swap, + }), + }); + + for ix in &sanctum_ixs[sanctum_action_ix_end..] { + instructions.push(ix.clone()); + } + + let mut address_lookup_tables = self.mango_client.mango_address_lookup_tables().await?; + address_lookup_tables.extend(sanctum_alts.into_iter()); + + let payer = owner; // maybe use fee_payer? but usually it's the same + + Ok(TransactionBuilder { + instructions, + address_lookup_tables, + payer, + signers: vec![self.mango_client.owner.clone()], + config: self + .mango_client + .client + .config() + .transaction_builder_config + .clone(), + }) + } + + pub async fn swap( + &self, + input_mint: Pubkey, + output_mint: Pubkey, + max_slippage_bps: u64, + amount: u64, + ) -> anyhow::Result { + let route = self.quote(input_mint, output_mint, amount).await?; + + let tx_builder = self + .prepare_swap_transaction(input_mint, output_mint, max_slippage_bps, &route) + .await?; + + tx_builder.send_and_confirm(&self.mango_client.client).await + } +} + +pub async fn load_supported_token_mints( + live_rpc_client: &RpcClient, +) -> anyhow::Result> { + let address = Pubkey::from_str("EhWxBHdmQ3yDmPzhJbKtGMM9oaZD42emt71kSieghy5")?; + + let lookup_table_data = live_rpc_client.get_account(&address).await?; + let lookup_table = AddressLookupTable::deserialize(&lookup_table_data.data())?; + let accounts: Vec = + fetch_multiple_accounts_in_chunks(live_rpc_client, &lookup_table.addresses, 100, 1) + .await? + .into_iter() + .map(|x| x.1) + .collect(); + + let mut lst_mints = HashSet::new(); + for account in accounts { + let account = Account::from(account); + let mut account_data = account.data(); + let t = sanctum_state::StakePool::deserialize(&mut account_data); + if let Ok(d) = t { + lst_mints.insert(d.pool_mint); + } + } + + // taken from https://github.com/igneous-labs/sanctum-lst-list/blob/master/sanctum-lst-list.toml + let hardcoded_lst_mints = [ + "pathdXw4He1Xk3eX84pDdDZnGKEme3GivBamGCVPZ5a", // pathSOL + "jupSoLaHXQiZZTSfEWMTRRgpnyFm8f6sZdosWBjx93v", // JupSOL + "BgYgFYq4A9a2o5S1QbWkmYVFBh7LBQL8YvugdhieFg38", // juicingJupSOL + "pWrSoLAhue6jUxUkbWgmEy5rD9VJzkFmvfTDV5KgNuu", // pwrSOL + "suPer8CPwxoJPQ7zksGMwFvjBQhjAHwUMmPV4FVatBw", // superSOL + "jucy5XJ76pHVvtPZb5TKRcGQExkwit2P5s4vY8UzmpC", // jucySOL + "BonK1YhkXEGLZzwtcvRTip3gAL9nCeQD7ppZBLXhtTs", // bonkSOL + "Dso1bDeDjCQxTrWHqUUi63oBvV7Mdm6WaobLbQ7gnPQ", // dSOL + "Comp4ssDzXcLeu2MnLuGNNFC4cmLPMng8qWHPvzAMU1h", // compassSOL + "picobAEvs6w7QEknPce34wAE4gknZA9v5tTonnmHYdX", // picoSOL + "GRJQtWwdJmp5LLpy8JWjPgn5FnLyqSJGNhn5ZnCTFUwM", // clockSOL + "HUBsveNpjo5pWqNkH57QzxjQASdTVXcSK7bVKTSZtcSX", // hubSOL + "strng7mqqc1MBJJV6vMzYbEqnwVGvKKGKedeCvtktWA", // strongSOL + "LnTRntk2kTfWEY6cVB8K9649pgJbt6dJLS1Ns1GZCWg", // lanternSOL + "st8QujHLPsX3d6HG9uQg9kJ91jFxUgruwsb1hyYXSNd", // stakeSOL + "pumpkinsEq8xENVZE6QgTS93EN4r9iKvNxNALS1ooyp", // pumpkinSOL + "CgnTSoL3DgY9SFHxcLj6CgCgKKoTBr6tp4CPAEWy25DE", // cgntSOL + "LAinEtNLgpmCP9Rvsf5Hn8W6EhNiKLZQti1xfWMLy6X", // laineSOL + "vSoLxydx6akxyMD9XEcPvGYNGq6Nn66oqVb3UkGkei7", // vSOL + "bSo13r4TkiE4KumL71LsHTPpL2euBYLFx6h9HP3piy1", // bSOL + "GEJpt3Wjmr628FqXxTgxMce1pLntcPV4uFi8ksxMyPQh", // daoSOL + "J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn", // JitoSOL + "7Q2afV64in6N6SeZsAAB81TJzwDoD6zpqmHkzi9Dcavn", // JSOL + "LSTxxxnJzKDFSLr4dUkPcmCf5VyryEqzPLz5j4bpxFp", // LST + "Zippybh3S5xYYam2nvL6hVJKz1got6ShgV4DyD1XQYF", // zippySOL + "edge86g9cVz87xcpKpy3J77vbp4wYd9idEV562CCntt", // edgeSOL + "So11111111111111111111111111111111111111112", // SOL + "5oVNBeEEQvYi1cX3ir8Dx5n1P7pdxydbGF2X4TxVusJm", // INF + "7dHbWXmci3dT8UFYWYZweBLXgycu7Y3iL6trKn1Y7ARj", // stSOL + "mSoLzYCxHdYgdzU16g5QSh3i5K3z3KZK7ytfqcJm7So", // mSOL + ]; + + // Hardcoded for now + lst_mints.extend(hardcoded_lst_mints.map(|x| Pubkey::from_str(x).expect("invalid mint"))); + + Ok(lst_mints) +} diff --git a/lib/client/src/swap/sanctum_state.rs b/lib/client/src/swap/sanctum_state.rs new file mode 100644 index 000000000..ea37e0483 --- /dev/null +++ b/lib/client/src/swap/sanctum_state.rs @@ -0,0 +1,158 @@ +use { + borsh::BorshDeserialize, + solana_sdk::{pubkey::Pubkey, stake::state::Lockup}, +}; + +#[derive(Clone, Debug, PartialEq, BorshDeserialize)] +pub enum AccountType { + /// If the account has not been initialized, the enum will be 0 + Uninitialized, + /// Stake pool + StakePool, + /// Validator stake list + ValidatorList, +} + +#[repr(C)] +#[derive(Clone, Debug, PartialEq, BorshDeserialize)] +pub struct StakePool { + /// Account type, must be StakePool currently + pub account_type: AccountType, + + /// Manager authority, allows for updating the staker, manager, and fee + /// account + pub manager: Pubkey, + + /// Staker authority, allows for adding and removing validators, and + /// managing stake distribution + pub staker: Pubkey, + + /// Stake deposit authority + /// + /// If a depositor pubkey is specified on initialization, then deposits must + /// be signed by this authority. If no deposit authority is specified, + /// then the stake pool will default to the result of: + /// `Pubkey::find_program_address( + /// &[&stake_pool_address.as_ref(), b"deposit"], + /// program_id, + /// )` + pub stake_deposit_authority: Pubkey, + + /// Stake withdrawal authority bump seed + /// for `create_program_address(&[state::StakePool account, "withdrawal"])` + pub stake_withdraw_bump_seed: u8, + + /// Validator stake list storage account + pub validator_list: Pubkey, + + /// Reserve stake account, holds deactivated stake + pub reserve_stake: Pubkey, + + /// Pool Mint + pub pool_mint: Pubkey, + + /// Manager fee account + pub manager_fee_account: Pubkey, + + /// Pool token program id + pub token_program_id: Pubkey, + + /// Total stake under management. + /// Note that if `last_update_epoch` does not match the current epoch then + /// this field may not be accurate + pub total_lamports: u64, + + /// Total supply of pool tokens (should always match the supply in the Pool + /// Mint) + pub pool_token_supply: u64, + + /// Last epoch the `total_lamports` field was updated + pub last_update_epoch: u64, + + /// Lockup that all stakes in the pool must have + pub lockup: Lockup, + + /// Fee taken as a proportion of rewards each epoch + pub epoch_fee: Fee, + + /// Fee for next epoch + pub next_epoch_fee: FutureEpoch, + + /// Preferred deposit validator vote account pubkey + pub preferred_deposit_validator_vote_address: Option, + + /// Preferred withdraw validator vote account pubkey + pub preferred_withdraw_validator_vote_address: Option, + + /// Fee assessed on stake deposits + pub stake_deposit_fee: Fee, + + /// Fee assessed on withdrawals + pub stake_withdrawal_fee: Fee, + + /// Future stake withdrawal fee, to be set for the following epoch + pub next_stake_withdrawal_fee: FutureEpoch, + + /// Fees paid out to referrers on referred stake deposits. + /// Expressed as a percentage (0 - 100) of deposit fees. + /// i.e. `stake_deposit_fee`% of stake deposited is collected as deposit + /// fees for every deposit and `stake_referral_fee`% of the collected + /// stake deposit fees is paid out to the referrer + pub stake_referral_fee: u8, + + /// Toggles whether the `DepositSol` instruction requires a signature from + /// this `sol_deposit_authority` + pub sol_deposit_authority: Option, + + /// Fee assessed on SOL deposits + pub sol_deposit_fee: Fee, + + /// Fees paid out to referrers on referred SOL deposits. + /// Expressed as a percentage (0 - 100) of SOL deposit fees. + /// i.e. `sol_deposit_fee`% of SOL deposited is collected as deposit fees + /// for every deposit and `sol_referral_fee`% of the collected SOL + /// deposit fees is paid out to the referrer + pub sol_referral_fee: u8, + + /// Toggles whether the `WithdrawSol` instruction requires a signature from + /// the `deposit_authority` + pub sol_withdraw_authority: Option, + + /// Fee assessed on SOL withdrawals + pub sol_withdrawal_fee: Fee, + + /// Future SOL withdrawal fee, to be set for the following epoch + pub next_sol_withdrawal_fee: FutureEpoch, + + /// Last epoch's total pool tokens, used only for APR estimation + pub last_epoch_pool_token_supply: u64, + + /// Last epoch's total lamports, used only for APR estimation + pub last_epoch_total_lamports: u64, +} + +/// Fee rate as a ratio, minted on `UpdateStakePoolBalance` as a proportion of +/// the rewards +/// If either the numerator or the denominator is 0, the fee is considered to be +/// 0 +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, BorshDeserialize)] +pub struct Fee { + /// denominator of the fee ratio + pub denominator: u64, + /// numerator of the fee ratio + pub numerator: u64, +} + +/// Wrapper type that "counts down" epochs, which is Borsh-compatible with the +/// native `Option` +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, BorshDeserialize)] +pub enum FutureEpoch { + /// Nothing is set + None, + /// Value is ready after the next epoch boundary + One(T), + /// Value is ready after two epoch boundaries + Two(T), +}