diff --git a/.github/workflows/ci-verifiable-build.yml b/.github/workflows/ci-verifiable-build.yml index 901a97a37..f82e43845 100644 --- a/.github/workflows/ci-verifiable-build.yml +++ b/.github/workflows/ci-verifiable-build.yml @@ -23,7 +23,7 @@ jobs: - name: Verifiable Build run: | - anchor build --verifiable --docker-image backpackapp/build:v0.28.0 --solana-version 1.16.14 -- --features enable-gpl + anchor build --verifiable --docker-image backpackapp/build:v0.28.0 --solana-version 1.16.14 --env GITHUB_SHA --env GITHUB_REF_NAME -- --features enable-gpl - name: Generate Checksum run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d0c37977..7751e234d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Update this for each program release and mainnet deployment. ## not on mainnet +<<<<<<< HEAD ### v0.22.0, 2024-2- - Perp: Allow reusing your own perp order slots immediately (#817) @@ -32,6 +33,61 @@ Update this for each program release and mainnet deployment. ## mainnet +======= +### v0.23.0, 2024-3- + +- Allow disabling asset liquidations for tokens (#867) + + This allows listing tokens that have no reliable oracle. Those tokens could be + traded through mango but can't be borrowed, can't have asset weight and can't + even be liquidated. + +- Add configurable collateral fees for tokens (#868, #880, #894) + + Collateral fees allow the DAO to regularly charge users for using particular + types of collateral to back their liabilities. + +- Add force_withdraw token state (#884) + + There already is a force_close_borrows state, but for a full delisting user + deposits need to be removed too. In force_withdraw, user deposits can be + permissionlessly withdrawn to their owners' token accounts. + +- Flash loan: Add a "swap without flash loan fees" option (#882) +- Cleanup, tests and minor (#878, #875, #854, #838, #895) + +## mainnet + +### v0.22.0, 2024-3-3 + +Deployment: Mar 3, 2024 at 23:52:08 Central European Standard Time, https://explorer.solana.com/tx/3MpEMU12Pv7RpSnwfShoM9sbyr41KAEeJFCVx9ypkq8nuK8Q5vm7CRLkdhH3u91yQ4k44a32armZHaoYguX6NqsY + +- Perp: Allow reusing your own perp order slots immediately (#817) + + Previously users who placed a lot of perp orders and used time-in-force needed + to wait for out-event cranking if their perp order before reusing an order + slot. Now perp order slots can be reused even when the out-event is still on + the event queue. + +- Introduce fallback oracles (#790, #813) + + Fallback oracles can be used when the primary oracle is stale or not confident. + These oracles need to configured by the DAO to be usable by clients. + + Fallback oracles may be based on Orca in addition to the other supported types. + +- Add serum3_cancel_by_client_order_id instruction (#798) + + Can now cancel by client order id and not just the order id. + +- Add configurable platform liquidation fees for tokens and perps (#849, #858) +- Delegates can now withdraw small token amounts to the owner's ata (#820) +- Custom allocator to allow larger heap use if needed (#801) +- Optimize compute use in token_deposit instruction (#786) +- Disable support for v1 and v2 mango accounts (#783) +- Cleanups, logging and tests (#819, #799, #818, #823, #834, #828, #833) + +>>>>>>> main ### v0.21.3, 2024-2-9 Deployment: Feb 9, 2024 at 11:21:58 Central European Standard Time, https://explorer.solana.com/tx/44f2wcLyLiic1aycdaPTdfwXJBMeGeuA984kvCByg4L5iGprH6xW3D35gd3bvZ6kU3SipEtoY3kDuexJghbxL89T diff --git a/Cargo.lock b/Cargo.lock index 52f1bef65..998043764 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3367,7 +3367,11 @@ dependencies = [ [[package]] name = "mango-v4" +<<<<<<< HEAD version = "0.22.0" +======= +version = "0.23.0" +>>>>>>> main dependencies = [ "anchor-lang", "anchor-spl", @@ -3445,6 +3449,7 @@ dependencies = [ "atty", "base64 0.13.1", "bincode", + "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)", "futures 0.3.28", @@ -3464,10 +3469,12 @@ dependencies = [ "solana-client", "solana-rpc", "solana-sdk", + "solana-transaction-status", "spl-associated-token-account 1.1.3", "thiserror", "tokio", "tokio-stream", + "tokio-tungstenite 0.17.2", "tracing", "tracing-subscriber", ] @@ -3528,6 +3535,7 @@ dependencies = [ "once_cell", "pyth-sdk-solana", "rand 0.7.3", + "regex", "serde", "serde_derive", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 879c1918d..afb3f2adc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ solana-program = "~1.16.7" solana-program-test = "~1.16.7" solana-rpc = "~1.16.7" solana-sdk = { version = "~1.16.7", default-features = false } +solana-transaction-status = { version = "~1.16.7" } [profile.release] overflow-checks = true diff --git a/RELEASING.md b/RELEASING.md index 8d8f7587c..01496df95 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -23,7 +23,9 @@ - Do a verifiable build - anchor build --verifiable --solana-version 1.14.13 -- --features enable-gpl + Set GITHUB_SHA and GITHUB_REF_NAME to the release sha1 and tag name. + + anchor build --verifiable --docker-image backpackapp/build:v0.28.0 --solana-version 1.16.14 --env GITHUB_SHA --env GITHUB_REF_NAME -- --features enable-gpl (or wait for github to finish and create the release) diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index 9dd1c5d84..06ab893be 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -1,10 +1,14 @@ +use clap::clap_derive::ArgEnum; use clap::{Args, Parser, Subcommand}; +use mango_v4::accounts_ix::{Serum3OrderType, Serum3SelfTradeBehavior, Serum3Side}; +use mango_v4::state::{PlaceOrderType, SelfTradeBehavior, Side}; use mango_v4_client::{ keypair_from_cli, pubkey_from_cli, Client, MangoClient, TransactionBuilderConfig, }; use solana_sdk::pubkey::Pubkey; use std::str::FromStr; use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; mod save_snapshot; mod test_oracles; @@ -88,6 +92,98 @@ struct JupiterSwap { rpc: Rpc, } +#[derive(ArgEnum, Clone, Debug)] +#[repr(u8)] +pub enum CliSide { + Bid = 0, + Ask = 1, +} + +#[derive(Args, Debug, Clone)] +struct PerpPlaceOrder { + #[clap(long)] + account: String, + + /// also pays for everything + #[clap(short, long)] + owner: String, + + #[clap(long)] + market_name: String, + + #[clap(long, value_enum)] + side: CliSide, + + #[clap(short, long)] + price: f64, + + #[clap(long)] + quantity: f64, + + #[clap(long)] + expiry: u64, + + #[clap(flatten)] + rpc: Rpc, +} + +#[derive(Args, Debug, Clone)] +struct Serum3CreateOpenOrders { + #[clap(long)] + account: String, + + /// also pays for everything + #[clap(short, long)] + owner: String, + + #[clap(long)] + market_name: String, + + #[clap(flatten)] + rpc: Rpc, +} + +#[derive(Args, Debug, Clone)] +struct Serum3CloseOpenOrders { + #[clap(long)] + account: String, + + /// also pays for everything + #[clap(short, long)] + owner: String, + + #[clap(long)] + market_name: String, + + #[clap(flatten)] + rpc: Rpc, +} + +#[derive(Args, Debug, Clone)] +struct Serum3PlaceOrder { + #[clap(long)] + account: String, + + /// also pays for everything + #[clap(short, long)] + owner: String, + + #[clap(long)] + market_name: String, + + #[clap(long, value_enum)] + side: CliSide, + + #[clap(short, long)] + price: f64, + + #[clap(long)] + quantity: f64, + + #[clap(flatten)] + rpc: Rpc, +} + #[derive(Subcommand, Debug, Clone)] enum Command { CreateAccount(CreateAccount), @@ -128,21 +224,28 @@ enum Command { #[clap(short, long)] output: String, }, + PerpPlaceOrder(PerpPlaceOrder), + Serum3CloseOpenOrders(Serum3CloseOpenOrders), + Serum3CreateOpenOrders(Serum3CreateOpenOrders), + Serum3PlaceOrder(Serum3PlaceOrder), } impl Rpc { fn client(&self, override_fee_payer: Option<&str>) -> anyhow::Result { let fee_payer = keypair_from_cli(override_fee_payer.unwrap_or(&self.fee_payer)); - Ok(Client::new( - anchor_client::Cluster::from_str(&self.url)?, - solana_sdk::commitment_config::CommitmentConfig::confirmed(), - Arc::new(fee_payer), - None, - TransactionBuilderConfig { - prioritization_micro_lamports: Some(5), - compute_budget_per_instruction: Some(250_000), - }, - )) + Ok(Client::builder() + .cluster(anchor_client::Cluster::from_str(&self.url)?) + .commitment(solana_sdk::commitment_config::CommitmentConfig::confirmed()) + .fee_payer(Some(Arc::new(fee_payer))) + .transaction_builder_config( + TransactionBuilderConfig::builder() + .prioritization_micro_lamports(Some(5)) + .compute_budget_per_instruction(Some(250_000)) + .build() + .unwrap(), + ) + .build() + .unwrap()) } } @@ -204,15 +307,8 @@ async fn main() -> Result<(), anyhow::Error> { let output_mint = pubkey_from_cli(&cmd.output_mint); let client = MangoClient::new_for_existing_account(client, account, owner).await?; let txsig = client - .jupiter_v4() - .swap( - input_mint, - output_mint, - cmd.amount, - cmd.slippage_bps, - mango_v4_client::JupiterSwapMode::ExactIn, - false, - ) + .jupiter_v6() + .swap(input_mint, output_mint, cmd.amount, cmd.slippage_bps, false) .await?; println!("{}", txsig); } @@ -245,6 +341,111 @@ async fn main() -> Result<(), anyhow::Error> { let client = rpc.client(None)?; save_snapshot::save_snapshot(mango_group, client, output).await? } + Command::PerpPlaceOrder(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 client = MangoClient::new_for_existing_account(client, account, owner).await?; + let market = client + .context + .perp_markets + .iter() + .find(|p| p.1.name == cmd.market_name) + .unwrap() + .1; + + fn native(x: f64, b: u32) -> i64 { + (x * (10_i64.pow(b)) as f64) as i64 + } + + let price_lots = native(cmd.price, 6) * market.base_lot_size + / (market.quote_lot_size * 10_i64.pow(market.base_decimals.into())); + let max_base_lots = + native(cmd.quantity, market.base_decimals.into()) / market.base_lot_size; + + let txsig = client + .perp_place_order( + market.perp_market_index, + match cmd.side { + CliSide::Bid => Side::Bid, + CliSide::Ask => Side::Ask, + }, + price_lots, + max_base_lots, + i64::max_value(), + SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(), + PlaceOrderType::Limit, + false, + if cmd.expiry > 0 { + SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() + cmd.expiry + } else { + 0 + }, + 10, + SelfTradeBehavior::AbortTransaction, + ) + .await?; + println!("{}", txsig); + } + Command::Serum3CreateOpenOrders(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 client = MangoClient::new_for_existing_account(client, account, owner).await?; + + let txsig = client.serum3_create_open_orders(&cmd.market_name).await?; + println!("{}", txsig); + } + Command::Serum3CloseOpenOrders(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 client = MangoClient::new_for_existing_account(client, account, owner).await?; + + let txsig = client.serum3_close_open_orders(&cmd.market_name).await?; + println!("{}", txsig); + } + Command::Serum3PlaceOrder(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 client = MangoClient::new_for_existing_account(client, account, owner).await?; + let market_index = client.context.serum3_market_index(&cmd.market_name); + let market = client.context.serum3(market_index); + let base_token = client.context.token(market.base_token_index); + let quote_token = client.context.token(market.quote_token_index); + + fn native(x: f64, b: u32) -> u64 { + (x * (10_i64.pow(b)) as f64) as u64 + } + + // coin_lot_size = base lot size ? + // cf priceNumberToLots + let price_lots = native(cmd.price, quote_token.decimals as u32) * market.coin_lot_size + / (native(1.0, base_token.decimals as u32) * market.pc_lot_size); + + // cf baseSizeNumberToLots + let max_base_lots = + native(cmd.quantity, base_token.decimals as u32) / market.coin_lot_size; + + let txsig = client + .serum3_place_order( + &cmd.market_name, + match cmd.side { + CliSide::Bid => Serum3Side::Bid, + CliSide::Ask => Serum3Side::Ask, + }, + price_lots, + max_base_lots as u64, + ((price_lots * max_base_lots) as f64 * 1.01) as u64, + Serum3SelfTradeBehavior::AbortTransaction, + Serum3OrderType::Limit, + SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(), + 10, + ) + .await?; + println!("{}", txsig); + } }; Ok(()) diff --git a/bin/cli/src/save_snapshot.rs b/bin/cli/src/save_snapshot.rs index 50124b875..4c0c375eb 100644 --- a/bin/cli/src/save_snapshot.rs +++ b/bin/cli/src/save_snapshot.rs @@ -23,10 +23,10 @@ pub async fn save_snapshot( } fs::create_dir_all(out_path).unwrap(); - let rpc_url = client.cluster.url().to_string(); - let ws_url = client.cluster.ws_url().to_string(); + let rpc_url = client.config().cluster.url().to_string(); + let ws_url = client.config().cluster.ws_url().to_string(); - let group_context = MangoGroupContext::new_from_rpc(&client.rpc_async(), mango_group).await?; + let group_context = MangoGroupContext::new_from_rpc(client.rpc_async(), mango_group).await?; let oracles_and_vaults = group_context .tokens diff --git a/bin/keeper/src/crank.rs b/bin/keeper/src/crank.rs index 90569d765..5884c6ef5 100644 --- a/bin/keeper/src/crank.rs +++ b/bin/keeper/src/crank.rs @@ -1,17 +1,34 @@ -use std::{collections::HashSet, sync::Arc, time::Duration, time::Instant}; +use std::{ + collections::HashSet, + sync::Arc, + time::Instant, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; use crate::MangoClient; +use anyhow::Context; use itertools::Itertools; -use anchor_lang::{__private::bytemuck::cast_ref, solana_program}; +use anchor_lang::{__private::bytemuck::cast_ref, solana_program, Discriminator}; use futures::Future; -use mango_v4::state::{EventQueue, EventType, FillEvent, OutEvent, TokenIndex}; -use mango_v4_client::PerpMarketContext; +use mango_v4::{ + accounts_zerocopy::AccountReader, + state::{ + EventQueue, EventType, FillEvent, Group, MangoAccount, MangoAccountValue, OutEvent, + TokenIndex, + }, +}; +use mango_v4_client::{ + account_fetcher_fetch_anchor_account, AccountFetcher, PerpMarketContext, PreparedInstructions, + RpcAccountFetcher, TransactionBuilder, +}; use prometheus::{register_histogram, Encoder, Histogram, IntCounter, Registry}; use solana_sdk::{ instruction::{AccountMeta, Instruction}, pubkey::Pubkey, + signature::Signature, }; +use tokio::task::JoinHandle; use tracing::*; use warp::Filter; @@ -80,6 +97,9 @@ pub async fn runner( interval_consume_events: u64, interval_update_funding: u64, interval_check_for_changes_and_abort: u64, + interval_charge_collateral_fees: u64, + max_cu_when_batching: u32, + extra_jobs: Vec>, ) -> Result<(), anyhow::Error> { let handles1 = mango_client .context @@ -138,12 +158,18 @@ pub async fn runner( futures::future::join_all(handles1), futures::future::join_all(handles2), futures::future::join_all(handles3), + loop_charge_collateral_fees( + mango_client.clone(), + interval_charge_collateral_fees, + max_cu_when_batching + ), MangoClient::loop_check_for_context_changes_and_abort( mango_client.clone(), Duration::from_secs(interval_check_for_changes_and_abort), ), serve_metrics(), debugging_handle, + futures::future::join_all(extra_jobs), ); Ok(()) @@ -409,3 +435,146 @@ pub async fn loop_update_funding( } } } + +pub async fn loop_charge_collateral_fees( + mango_client: Arc, + interval: u64, + max_cu_when_batching: u32, +) { + if interval == 0 { + return; + } + + // Make a new one separate from the mango_client.account_fetcher, + // because we don't want cached responses + let fetcher = RpcAccountFetcher { + rpc: mango_client.client.new_rpc_async(), + }; + + let group: Group = account_fetcher_fetch_anchor_account(&fetcher, &mango_client.context.group) + .await + .unwrap(); + let collateral_fee_interval = group.collateral_fee_interval; + + let mut interval = mango_v4_client::delay_interval(Duration::from_secs(interval)); + loop { + interval.tick().await; + + match charge_collateral_fees_inner( + &mango_client, + &fetcher, + collateral_fee_interval, + max_cu_when_batching, + ) + .await + { + Ok(()) => {} + Err(err) => { + error!("charge_collateral_fees error: {err:?}"); + } + } + } +} + +async fn charge_collateral_fees_inner( + client: &MangoClient, + fetcher: &RpcAccountFetcher, + collateral_fee_interval: u64, + max_cu_when_batching: u32, +) -> anyhow::Result<()> { + let mango_accounts = fetcher + .fetch_program_accounts(&mango_v4::id(), MangoAccount::DISCRIMINATOR) + .await + .context("fetching mango accounts")? + .into_iter() + .filter_map( + |(pk, data)| match MangoAccountValue::from_bytes(&data.data()[8..]) { + Ok(acc) => Some((pk, acc)), + Err(err) => { + error!(pk=%pk, "charge_collateral_fees could not parse account: {err:?}"); + None + } + }, + ); + + let mut ix_to_send = Vec::new(); + let now_ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as u64; + for (pk, account) in mango_accounts { + let should_reset = + collateral_fee_interval == 0 && account.fixed.last_collateral_fee_charge > 0; + let should_charge = collateral_fee_interval > 0 + && now_ts > account.fixed.last_collateral_fee_charge + collateral_fee_interval; + if !(should_reset || should_charge) { + continue; + } + + let ixs = match client + .token_charge_collateral_fees_instruction((&pk, &account)) + .await + { + Ok(ixs) => ixs, + Err(err) => { + error!(pk=%pk, "charge_collateral_fees could not build instruction: {err:?}"); + continue; + } + }; + + ix_to_send.push(ixs); + } + + let txsigs = send_batched_log_errors_no_confirm( + client.transaction_builder().await?, + &client.client, + &ix_to_send, + max_cu_when_batching, + ) + .await; + info!("charge collateral fees: {:?}", txsigs); + + Ok(()) +} + +/// Try to batch the instructions into transactions and send them +async fn send_batched_log_errors_no_confirm( + mut tx_builder: TransactionBuilder, + client: &mango_v4_client::Client, + ixs_list: &[PreparedInstructions], + max_cu: u32, +) -> Vec { + let mut txsigs = Vec::new(); + + let mut current_batch = PreparedInstructions::new(); + for ixs in ixs_list { + let previous_batch = current_batch.clone(); + current_batch.append(ixs.clone()); + + tx_builder.instructions = current_batch.clone().to_instructions(); + if tx_builder + .transaction_size() + .map(|ts| !ts.is_within_limit()) + .unwrap_or(true) + || current_batch.cu > max_cu + { + tx_builder.instructions = previous_batch.to_instructions(); + match tx_builder.send(client).await { + Err(err) => error!("could not send transaction: {err:?}"), + Ok(txsig) => txsigs.push(txsig), + } + + current_batch = ixs.clone(); + } + } + + if !current_batch.is_empty() { + tx_builder.instructions = current_batch.to_instructions(); + match tx_builder.send(client).await { + Err(err) => error!("could not send transaction: {err:?}"), + Ok(txsig) => txsigs.push(txsig), + } + } + + txsigs +} diff --git a/bin/keeper/src/main.rs b/bin/keeper/src/main.rs index 4e006363d..ba1fc1085 100644 --- a/bin/keeper/src/main.rs +++ b/bin/keeper/src/main.rs @@ -7,7 +7,10 @@ use std::time::Duration; use anchor_client::Cluster; use clap::{Parser, Subcommand}; -use mango_v4_client::{keypair_from_cli, Client, MangoClient, TransactionBuilderConfig}; +use mango_v4_client::{ + keypair_from_cli, priority_fees_cli, Client, FallbackOracleConfig, MangoClient, + TransactionBuilderConfig, +}; use solana_sdk::commitment_config::CommitmentConfig; use solana_sdk::pubkey::Pubkey; use tokio::time; @@ -58,12 +61,23 @@ struct Cli { #[clap(long, env, default_value_t = 120)] interval_check_new_listings_and_abort: u64, + #[clap(long, env, default_value_t = 300)] + interval_charge_collateral_fees: u64, + #[clap(long, env, default_value_t = 10)] timeout: u64, - /// prioritize each transaction with this many microlamports/cu - #[clap(long, env, default_value = "0")] - prioritization_micro_lamports: u64, + #[clap(flatten)] + prioritization_fee_cli: priority_fees_cli::PriorityFeeArgs, + + /// url to the lite-rpc websocket, optional + #[clap(long, env, default_value = "")] + lite_rpc_url: String, + + /// When batching multiple instructions into a transaction, don't exceed + /// this compute unit limit. + #[clap(long, env, default_value_t = 1_000_000)] + max_cu_when_batching: u32, } #[derive(Subcommand, Debug, Clone)] @@ -85,6 +99,10 @@ async fn main() -> Result<(), anyhow::Error> { }; let cli = Cli::parse_from(args); + let (prio_provider, prio_jobs) = cli + .prioritization_fee_cli + .make_prio_provider(cli.lite_rpc_url.clone())?; + let owner = Arc::new(keypair_from_cli(&cli.owner)); let rpc_url = cli.rpc_url; @@ -98,19 +116,23 @@ async fn main() -> Result<(), anyhow::Error> { let mango_client = Arc::new( MangoClient::new_for_existing_account( - Client::new( - cluster, - commitment, - owner.clone(), - Some(Duration::from_secs(cli.timeout)), - TransactionBuilderConfig { - prioritization_micro_lamports: (cli.prioritization_micro_lamports > 0) - .then_some(cli.prioritization_micro_lamports), - compute_budget_per_instruction: None, - }, - ), + Client::builder() + .cluster(cluster) + .commitment(commitment) + .fee_payer(Some(owner.clone())) + .timeout(Duration::from_secs(cli.timeout)) + .transaction_builder_config( + TransactionBuilderConfig::builder() + .priority_fee_provider(prio_provider) + .compute_budget_per_instruction(None) + .build() + .unwrap(), + ) + .fallback_oracle_config(FallbackOracleConfig::Never) + .build() + .unwrap(), cli.mango_account, - owner.clone(), + owner, ) .await?, ); @@ -139,12 +161,15 @@ async fn main() -> Result<(), anyhow::Error> { cli.interval_consume_events, cli.interval_update_funding, cli.interval_check_new_listings_and_abort, + cli.interval_charge_collateral_fees, + cli.max_cu_when_batching, + prio_jobs, ) .await } Command::Taker { .. } => { let client = mango_client.clone(); - taker::runner(client, debugging_handle).await + taker::runner(client, debugging_handle, prio_jobs).await } } } diff --git a/bin/keeper/src/taker.rs b/bin/keeper/src/taker.rs index 90a56d0bb..70f024df0 100644 --- a/bin/keeper/src/taker.rs +++ b/bin/keeper/src/taker.rs @@ -10,13 +10,15 @@ use mango_v4::{ accounts_ix::{Serum3OrderType, Serum3SelfTradeBehavior, Serum3Side}, state::TokenIndex, }; +use tokio::task::JoinHandle; use tracing::*; use crate::MangoClient; pub async fn runner( mango_client: Arc, - _debugging_handle: impl Future, + debugging_handle: impl Future, + extra_jobs: Vec>, ) -> Result<(), anyhow::Error> { ensure_deposit(&mango_client).await?; ensure_oo(&mango_client).await?; @@ -53,7 +55,9 @@ pub async fn runner( futures::join!( futures::future::join_all(handles1), - futures::future::join_all(handles2) + futures::future::join_all(handles2), + debugging_handle, + futures::future::join_all(extra_jobs), ); Ok(()) diff --git a/bin/liquidator/Cargo.toml b/bin/liquidator/Cargo.toml index 75fe0792d..846aaaf7f 100644 --- a/bin/liquidator/Cargo.toml +++ b/bin/liquidator/Cargo.toml @@ -48,3 +48,4 @@ tokio = { version = "1", features = ["full"] } tokio-stream = { version = "0.1.9"} tokio-tungstenite = "0.16.1" tracing = "0.1" +regex = "1.9.5" diff --git a/bin/liquidator/src/cli_args.rs b/bin/liquidator/src/cli_args.rs new file mode 100644 index 000000000..e6d49bac5 --- /dev/null +++ b/bin/liquidator/src/cli_args.rs @@ -0,0 +1,211 @@ +use crate::trigger_tcs; +use anchor_lang::prelude::Pubkey; +use clap::Parser; +use mango_v4_client::{jupiter, priority_fees_cli}; +use std::collections::HashSet; + +#[derive(Parser, Debug)] +#[clap()] +pub(crate) struct CliDotenv { + // When --dotenv is passed, read the specified dotenv file before parsing args + #[clap(long)] + pub(crate) dotenv: std::path::PathBuf, + + pub(crate) remaining_args: Vec, +} + +// Prefer "--rebalance false" over "--no-rebalance" because it works +// better with REBALANCE=false env values. +#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum BoolArg { + True, + False, +} + +#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum JupiterVersionArg { + Mock, + V6, +} + +impl From for jupiter::Version { + fn from(a: JupiterVersionArg) -> Self { + match a { + JupiterVersionArg::Mock => jupiter::Version::Mock, + JupiterVersionArg::V6 => jupiter::Version::V6, + } + } +} + +#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TcsMode { + BorrowBuy, + SwapSellIntoBuy, + SwapCollateralIntoBuy, +} + +impl From for trigger_tcs::Mode { + fn from(a: TcsMode) -> Self { + match a { + TcsMode::BorrowBuy => trigger_tcs::Mode::BorrowBuyToken, + TcsMode::SwapSellIntoBuy => trigger_tcs::Mode::SwapSellIntoBuy, + TcsMode::SwapCollateralIntoBuy => trigger_tcs::Mode::SwapCollateralIntoBuy, + } + } +} + +pub(crate) fn cli_to_hashset>( + str_list: Option>, +) -> HashSet { + return str_list + .map(|v| v.iter().map(|x| T::from(*x)).collect::>()) + .unwrap_or_default(); +} + +#[derive(Parser)] +#[clap()] +pub struct Cli { + #[clap(short, long, env)] + pub(crate) rpc_url: String, + + #[clap(long, env, value_delimiter = ';')] + pub(crate) override_send_transaction_url: Option>, + + #[clap(long, env)] + pub(crate) liqor_mango_account: Pubkey, + + #[clap(long, env)] + pub(crate) liqor_owner: String, + + #[clap(long, env, default_value = "1000")] + pub(crate) check_interval_ms: u64, + + #[clap(long, env, default_value = "300")] + pub(crate) snapshot_interval_secs: u64, + + // how often do we refresh token swap route/prices + #[clap(long, env, default_value = "30")] + pub(crate) token_swap_refresh_interval_secs: u64, + + /// how many getMultipleAccounts requests to send in parallel + #[clap(long, env, default_value = "10")] + pub(crate) parallel_rpc_requests: usize, + + /// typically 100 is the max number of accounts getMultipleAccounts will retrieve at once + #[clap(long, env, default_value = "100")] + pub(crate) get_multiple_accounts_count: usize, + + /// liquidator health ratio should not fall below this value + #[clap(long, env, default_value = "50")] + pub(crate) min_health_ratio: f64, + + /// if rebalancing is enabled + /// + /// typically only disabled for tests where swaps are unavailable + #[clap(long, env, value_enum, default_value = "true")] + pub(crate) rebalance: BoolArg, + + /// max slippage to request on swaps to rebalance spot tokens + #[clap(long, env, default_value = "100")] + pub(crate) rebalance_slippage_bps: u64, + + /// tokens to not rebalance (in addition to USDC=0); use a comma separated list of token index + #[clap(long, env, value_parser, value_delimiter = ',')] + pub(crate) rebalance_skip_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. + #[clap(long, env, default_value = "0.05")] + pub(crate) rebalance_borrow_settle_excess: f64, + + #[clap(long, env, default_value = "30")] + pub(crate) rebalance_refresh_timeout_secs: u64, + + /// if taking tcs orders is enabled + /// + /// typically only disabled for tests where swaps are unavailable + #[clap(long, env, value_enum, default_value = "true")] + pub(crate) take_tcs: BoolArg, + + /// profit margin at which to take tcs orders + #[clap(long, env, default_value = "0.0005")] + pub(crate) tcs_profit_fraction: f64, + + /// control how tcs triggering provides buy tokens + #[clap(long, env, value_enum, default_value = "swap-sell-into-buy")] + pub(crate) tcs_mode: TcsMode, + + /// largest tcs amount to trigger in one transaction, in dollar + #[clap(long, env, default_value = "1000.0")] + pub(crate) tcs_max_trigger_amount: f64, + + /// Minimum fraction of max_buy to buy for success when triggering, + /// useful in conjunction with jupiter swaps in same tx to avoid over-buying. + /// + /// Can be set to 0 to allow executions of any size. + #[clap(long, env, default_value = "0.7")] + pub(crate) tcs_min_buy_fraction: f64, + + #[clap(flatten)] + pub(crate) prioritization_fee_cli: priority_fees_cli::PriorityFeeArgs, + + /// url to the lite-rpc websocket, optional + #[clap(long, env, default_value = "")] + pub(crate) lite_rpc_url: String, + + /// compute limit requested for liquidation instructions + #[clap(long, env, default_value = "250000")] + pub(crate) compute_limit_for_liquidation: u32, + + /// compute limit requested for tcs trigger instructions + #[clap(long, env, default_value = "300000")] + pub(crate) compute_limit_for_tcs: u32, + + /// control which version of jupiter to use + #[clap(long, env, value_enum, default_value = "v6")] + pub(crate) jupiter_version: JupiterVersionArg, + + /// override the url to jupiter v6 + #[clap(long, env, default_value = "https://quote-api.jup.ag/v6")] + pub(crate) jupiter_v6_url: String, + + /// provide a jupiter token, currently only for jup v6 + #[clap(long, env, default_value = "")] + pub(crate) jupiter_token: String, + + /// size of the swap to quote via jupiter to get slippage info, in dollar + /// should be larger than tcs_max_trigger_amount + #[clap(long, env, default_value = "1000.0")] + pub(crate) jupiter_swap_info_amount: f64, + + /// report liquidator's existence and pubkey + #[clap(long, env, value_enum, default_value = "true")] + pub(crate) telemetry: BoolArg, + + /// liquidation refresh timeout in secs + #[clap(long, env, default_value = "30")] + pub(crate) liquidation_refresh_timeout_secs: u8, + + /// tokens to exclude for liquidation/tcs (never liquidate any pair where base or quote is in this list) + #[clap(long, env, value_parser, value_delimiter = ' ')] + pub(crate) forbidden_tokens: Option>, + + /// tokens to allow for liquidation/tcs (only liquidate a pair if base or quote is in this list) + /// when empty, allows all pairs + #[clap(long, env, value_parser, value_delimiter = ' ')] + pub(crate) only_allow_tokens: Option>, + + /// perp market to exclude for liquidation + #[clap(long, env, value_parser, value_delimiter = ' ')] + pub(crate) liquidation_forbidden_perp_markets: Option>, + + /// perp market to allow for liquidation (only liquidate if is in this list) + /// when empty, allows all pairs + #[clap(long, env, value_parser, value_delimiter = ' ')] + pub(crate) liquidation_only_allow_perp_markets: Option>, + + /// 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, +} diff --git a/bin/liquidator/src/liquidate.rs b/bin/liquidator/src/liquidate.rs index 2ddd43606..c355aaa19 100644 --- a/bin/liquidator/src/liquidate.rs +++ b/bin/liquidator/src/liquidate.rs @@ -1,10 +1,11 @@ +use std::cmp::Reverse; use std::collections::HashSet; use std::time::Duration; use itertools::Itertools; use mango_v4::health::{HealthCache, HealthType}; use mango_v4::state::{MangoAccountValue, PerpMarketIndex, Side, TokenIndex, QUOTE_TOKEN_INDEX}; -use mango_v4_client::{chain_data, health_cache, MangoClient}; +use mango_v4_client::{chain_data, MangoClient, PreparedInstructions}; use solana_sdk::signature::Signature; use futures::{stream, StreamExt, TryStreamExt}; @@ -19,6 +20,16 @@ pub struct Config { pub min_health_ratio: f64, pub refresh_timeout: Duration, pub compute_limit_for_liq_ix: u32, + + pub only_allowed_tokens: HashSet, + pub forbidden_tokens: HashSet, + + pub only_allowed_perp_markets: HashSet, + pub forbidden_perp_markets: HashSet, + + /// If we cram multiple ix into a transaction, don't exceed this level + /// of expected-cu. + pub max_cu_per_transaction: u32, } struct LiquidateHelper<'a> { @@ -29,8 +40,6 @@ struct LiquidateHelper<'a> { health_cache: &'a HealthCache, maint_health: I80F48, liqor_min_health_ratio: I80F48, - allowed_asset_tokens: HashSet, - allowed_liab_tokens: HashSet, config: Config, } @@ -46,7 +55,7 @@ impl<'a> LiquidateHelper<'a> { Ok((*orders, *open_orders)) }) .try_collect(); - let serum_force_cancels = serum_oos? + let mut serum_force_cancels = serum_oos? .into_iter() .filter_map(|(orders, open_orders)| { let can_force_cancel = open_orders.native_coin_total > 0 @@ -62,18 +71,42 @@ impl<'a> LiquidateHelper<'a> { if serum_force_cancels.is_empty() { return Ok(None); } - // Cancel all orders on a random serum market - let serum_orders = serum_force_cancels.choose(&mut rand::thread_rng()).unwrap(); - let txsig = self - .client - .serum3_liq_force_cancel_orders( - (self.pubkey, self.liqee), - serum_orders.market_index, - &serum_orders.open_orders, - ) - .await?; + serum_force_cancels.shuffle(&mut rand::thread_rng()); + + let mut ixs = PreparedInstructions::new(); + let mut cancelled_markets = vec![]; + let mut tx_builder = self.client.transaction_builder().await?; + + for force_cancel in serum_force_cancels { + let mut new_ixs = ixs.clone(); + new_ixs.append( + self.client + .serum3_liq_force_cancel_orders_instruction( + (self.pubkey, self.liqee), + force_cancel.market_index, + &force_cancel.open_orders, + ) + .await?, + ); + + let exceeds_cu_limit = new_ixs.cu > self.config.max_cu_per_transaction; + let exceeds_size_limit = { + tx_builder.instructions = new_ixs.clone().to_instructions(); + !tx_builder.transaction_size()?.is_within_limit() + }; + if exceeds_cu_limit || exceeds_size_limit { + break; + } + + ixs = new_ixs; + cancelled_markets.push(force_cancel.market_index); + } + + tx_builder.instructions = ixs.to_instructions(); + + let txsig = tx_builder.send_and_confirm(&self.client.client).await?; info!( - market_index = serum_orders.market_index, + market_indexes = ?cancelled_markets, %txsig, "Force cancelled serum orders", ); @@ -108,6 +141,25 @@ impl<'a> LiquidateHelper<'a> { let all_perp_base_positions: anyhow::Result< Vec>, > = stream::iter(self.liqee.active_perp_positions()) + .filter(|pp| async { + if self + .config + .forbidden_perp_markets + .contains(&pp.market_index) + { + return false; + } + if !self.config.only_allowed_perp_markets.is_empty() + && !self + .config + .only_allowed_perp_markets + .contains(&pp.market_index) + { + return false; + } + + true + }) .then(|pp| async { let base_lots = pp.base_position_lots(); if (base_lots == 0 && pp.quote_position_native() <= 0) || pp.has_open_taker_fills() @@ -155,10 +207,7 @@ impl<'a> LiquidateHelper<'a> { .await .context("getting liquidator account")?; liqor.ensure_perp_position(*perp_market_index, QUOTE_TOKEN_INDEX)?; - let mut health_cache = - health_cache::new(&self.client.context, self.account_fetcher, &liqor) - .await - .context("health cache")?; + let mut health_cache = self.client.health_cache(&liqor).await.expect("always ok"); let quote_bank = self .client .first_bank(QUOTE_TOKEN_INDEX) @@ -328,6 +377,7 @@ impl<'a> LiquidateHelper<'a> { .health_cache .token_infos .iter() + .filter(|p| !self.config.forbidden_tokens.contains(&p.token_index)) .zip( self.health_cache .effective_token_balances(HealthType::LiquidationEnd) @@ -345,34 +395,17 @@ impl<'a> LiquidateHelper<'a> { .filter_map(|(ti, effective)| { // check constraints for liquidatable assets, see also has_possible_spot_liquidations() let tokens = ti.balance_spot.min(effective.spot_and_perp); - let is_valid_asset = tokens >= 1; + let is_valid_asset = tokens >= 1 && ti.allow_asset_liquidation; let quote_value = tokens * ti.prices.oracle; // prefer to liquidate tokens with asset weight that have >$1 liquidatable let is_preferred = - ti.init_asset_weight > 0 && quote_value > I80F48::from(1_000_000); + ti.maint_asset_weight > 0 && quote_value > I80F48::from(1_000_000); is_valid_asset.then_some((ti.token_index, is_preferred, quote_value)) }) .collect_vec(); - // sort such that preferred tokens are at the end, and the one with the larget quote value is - // at the very end - potential_assets.sort_by_key(|(_, is_preferred, amount)| (*is_preferred, *amount)); - - // filter only allowed assets - let potential_allowed_assets = potential_assets.iter().filter_map(|(ti, _, _)| { - let is_allowed = self - .allowed_asset_tokens - .contains(&self.client.context.token(*ti).mint); - is_allowed.then_some(*ti) - }); - - let asset_token_index = match potential_allowed_assets.last() { - Some(token_index) => token_index, - None => anyhow::bail!( - "mango account {}, has no allowed asset tokens that are liquidatable: {:?}", - self.pubkey, - potential_assets, - ), - }; + // sort such that preferred tokens are at the start, and the one with the larget quote value is + // at 0 + potential_assets.sort_by_key(|(_, is_preferred, amount)| Reverse((*is_preferred, *amount))); // // find a good liab, same as for assets @@ -385,29 +418,69 @@ impl<'a> LiquidateHelper<'a> { let tokens = (-ti.balance_spot).min(-effective.spot_and_perp); let is_valid_liab = tokens > 0; let quote_value = tokens * ti.prices.oracle; - is_valid_liab.then_some((ti.token_index, quote_value)) + is_valid_liab.then_some((ti.token_index, false, quote_value)) }) .collect_vec(); - // largest liquidatable liability at the end - potential_liabs.sort_by_key(|(_, amount)| *amount); + // largest liquidatable liability at the start + potential_liabs.sort_by_key(|(_, is_preferred, amount)| Reverse((*is_preferred, *amount))); - // filter only allowed liabs - let potential_allowed_liabs = potential_liabs.iter().filter_map(|(ti, _)| { - let is_allowed = self - .allowed_liab_tokens - .contains(&self.client.context.token(*ti).mint); - is_allowed.then_some(*ti) - }); + // + // Find a pair + // - let liab_token_index = match potential_allowed_liabs.last() { - Some(token_index) => token_index, - None => anyhow::bail!( - "mango account {}, has no liab tokens that are liquidatable: {:?}", + fn find_best_token( + lh: &LiquidateHelper, + token_list: &Vec<(TokenIndex, bool, I80F48)>, + ) -> (Option, Option) { + let mut best_whitelisted = None; + let mut best = None; + + let allowed_token_list = token_list + .iter() + .filter_map(|(ti, _, _)| (!lh.config.forbidden_tokens.contains(ti)).then_some(ti)); + + for ti in allowed_token_list { + let whitelisted = lh.config.only_allowed_tokens.is_empty() + || lh.config.only_allowed_tokens.contains(ti); + if best.is_none() { + best = Some(*ti); + } + + if best_whitelisted.is_none() && whitelisted { + best_whitelisted = Some(*ti); + break; + } + } + + return (best, best_whitelisted); + } + + let (best_asset, best_whitelisted_asset) = find_best_token(self, &potential_assets); + let (best_liab, best_whitelisted_liab) = find_best_token(self, &potential_liabs); + + let best_pair_opt = [ + (best_whitelisted_asset, best_liab), + (best_asset, best_whitelisted_liab), + ] + .iter() + .filter_map(|(a, l)| (a.is_some() && l.is_some()).then_some((a.unwrap(), l.unwrap()))) + .next(); + + if best_pair_opt.is_none() { + anyhow::bail!( + "mango account {}, has no allowed asset/liab tokens pair that are liquidatable: assets={:?}; liabs={:?}", self.pubkey, + potential_assets, potential_liabs, - ), + ) }; + let (asset_token_index, liab_token_index) = best_pair_opt.unwrap(); + + // + // Compute max transfer size + // + let max_liab_transfer = self .max_token_liab_transfer(liab_token_index, asset_token_index) .await @@ -459,9 +532,7 @@ impl<'a> LiquidateHelper<'a> { .iter() .find(|(liab_token_index, _liab_price, liab_usdc_equivalent)| { liab_usdc_equivalent.is_negative() - && self - .allowed_liab_tokens - .contains(&self.client.context.token(*liab_token_index).mint) + && !self.config.forbidden_tokens.contains(liab_token_index) }) .ok_or_else(|| { anyhow::anyhow!( @@ -589,7 +660,8 @@ pub async fn maybe_liquidate_account( let liqor_min_health_ratio = I80F48::from_num(config.min_health_ratio); let account = account_fetcher.fetch_mango_account(pubkey)?; - let health_cache = health_cache::new(&mango_client.context, account_fetcher, &account) + let health_cache = mango_client + .health_cache(&account) .await .context("creating health cache 1")?; let maint_health = health_cache.health(HealthType::Maint); @@ -607,7 +679,8 @@ pub async fn maybe_liquidate_account( // This is -- unfortunately -- needed because the websocket streams seem to not // be great at providing timely updates to the account data. let account = account_fetcher.fetch_fresh_mango_account(pubkey).await?; - let health_cache = health_cache::new(&mango_client.context, account_fetcher, &account) + let health_cache = mango_client + .health_cache(&account) .await .context("creating health cache 2")?; if !health_cache.is_liquidatable() { @@ -616,8 +689,6 @@ pub async fn maybe_liquidate_account( let maint_health = health_cache.health(HealthType::Maint); - let all_token_mints = HashSet::from_iter(mango_client.context.tokens.values().map(|c| c.mint)); - // try liquidating let maybe_txsig = LiquidateHelper { client: mango_client, @@ -627,8 +698,6 @@ pub async fn maybe_liquidate_account( health_cache: &health_cache, maint_health, liqor_min_health_ratio, - allowed_asset_tokens: all_token_mints.clone(), - allowed_liab_tokens: all_token_mints, config: config.clone(), } .send_liq_tx() diff --git a/bin/liquidator/src/main.rs b/bin/liquidator/src/main.rs index e45d79ca3..ce0fcbf38 100644 --- a/bin/liquidator/src/main.rs +++ b/bin/liquidator/src/main.rs @@ -9,7 +9,7 @@ use clap::Parser; use mango_v4::state::{PerpMarketIndex, TokenIndex}; use mango_v4_client::AsyncChannelSendUnlessFull; use mango_v4_client::{ - account_update_stream, chain_data, error_tracking::ErrorTracking, jupiter, keypair_from_cli, + account_update_stream, chain_data, error_tracking::ErrorTracking, keypair_from_cli, snapshot_source, websocket_source, Client, MangoClient, MangoClientError, MangoGroupContext, TransactionBuilderConfig, }; @@ -20,14 +20,17 @@ use solana_sdk::pubkey::Pubkey; use solana_sdk::signer::Signer; use tracing::*; +pub mod cli_args; pub mod liquidate; pub mod metrics; pub mod rebalance; pub mod telemetry; pub mod token_swap_info; pub mod trigger_tcs; +mod unwrappable_oracle_error; pub mod util; +use crate::unwrappable_oracle_error::UnwrappableOracleError; use crate::util::{is_mango_account, is_mint_info, is_perp_market}; // jemalloc seems to be better at keeping the memory footprint reasonable over @@ -35,149 +38,6 @@ use crate::util::{is_mango_account, is_mint_info, is_perp_market}; #[global_allocator] static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc; -#[derive(Parser, Debug)] -#[clap()] -struct CliDotenv { - // When --dotenv is passed, read the specified dotenv file before parsing args - #[clap(long)] - dotenv: std::path::PathBuf, - - remaining_args: Vec, -} - -// Prefer "--rebalance false" over "--no-rebalance" because it works -// better with REBALANCE=false env values. -#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] -enum BoolArg { - True, - False, -} - -#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] -enum JupiterVersionArg { - Mock, - V4, - V6, -} - -impl From for jupiter::Version { - fn from(a: JupiterVersionArg) -> Self { - match a { - JupiterVersionArg::Mock => jupiter::Version::Mock, - JupiterVersionArg::V4 => jupiter::Version::V4, - JupiterVersionArg::V6 => jupiter::Version::V6, - } - } -} - -#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] -enum TcsMode { - BorrowBuy, - SwapSellIntoBuy, - SwapCollateralIntoBuy, -} - -impl From for trigger_tcs::Mode { - fn from(a: TcsMode) -> Self { - match a { - TcsMode::BorrowBuy => trigger_tcs::Mode::BorrowBuyToken, - TcsMode::SwapSellIntoBuy => trigger_tcs::Mode::SwapSellIntoBuy, - TcsMode::SwapCollateralIntoBuy => trigger_tcs::Mode::SwapCollateralIntoBuy, - } - } -} - -#[derive(Parser)] -#[clap()] -struct Cli { - #[clap(short, long, env)] - rpc_url: String, - - #[clap(long, env)] - liqor_mango_account: Pubkey, - - #[clap(long, env)] - liqor_owner: String, - - #[clap(long, env, default_value = "1000")] - check_interval_ms: u64, - - #[clap(long, env, default_value = "300")] - snapshot_interval_secs: u64, - - /// how many getMultipleAccounts requests to send in parallel - #[clap(long, env, default_value = "10")] - parallel_rpc_requests: usize, - - /// 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, - - /// if rebalancing is enabled - /// - /// typically only disabled for tests where swaps are unavailable - #[clap(long, env, value_enum, default_value = "true")] - rebalance: BoolArg, - - /// max slippage to request on swaps to rebalance spot tokens - #[clap(long, env, default_value = "100")] - rebalance_slippage_bps: u64, - - /// tokens to not rebalance (in addition to USDC); use a comma separated list of names - #[clap(long, env, default_value = "")] - rebalance_skip_tokens: String, - - /// if taking tcs orders is enabled - /// - /// typically only disabled for tests where swaps are unavailable - #[clap(long, env, value_enum, default_value = "true")] - take_tcs: BoolArg, - - /// profit margin at which to take tcs orders - #[clap(long, env, default_value = "0.0005")] - tcs_profit_fraction: f64, - - /// control how tcs triggering provides buy tokens - #[clap(long, env, value_enum, default_value = "swap-sell-into-buy")] - tcs_mode: TcsMode, - - /// prioritize each transaction with this many microlamports/cu - #[clap(long, env, default_value = "0")] - prioritization_micro_lamports: u64, - - /// compute limit requested for liquidation instructions - #[clap(long, env, default_value = "250000")] - compute_limit_for_liquidation: u32, - - /// compute limit requested for tcs trigger instructions - #[clap(long, env, default_value = "300000")] - compute_limit_for_tcs: u32, - - /// control which version of jupiter to use - #[clap(long, env, value_enum, default_value = "v6")] - jupiter_version: JupiterVersionArg, - - /// override the url to jupiter v4 - #[clap(long, env, default_value = "https://quote-api.jup.ag/v4")] - jupiter_v4_url: String, - - /// override the url to jupiter v6 - #[clap(long, env, default_value = "https://quote-api.jup.ag/v6")] - jupiter_v6_url: String, - - /// provide a jupiter token, currently only for jup v6 - #[clap(long, env, default_value = "")] - jupiter_token: String, - - /// report liquidator's existence and pubkey - #[clap(long, env, value_enum, default_value = "true")] - telemetry: BoolArg, -} - pub fn encode_address(addr: &Pubkey) -> String { bs58::encode(&addr.to_bytes()).into_string() } @@ -186,20 +46,31 @@ pub fn encode_address(addr: &Pubkey) -> String { async fn main() -> anyhow::Result<()> { mango_v4_client::tracing_subscriber_init(); - let args = if let Ok(cli_dotenv) = CliDotenv::try_parse() { + let args: Vec = if let Ok(cli_dotenv) = CliDotenv::try_parse() { dotenv::from_path(cli_dotenv.dotenv)?; - cli_dotenv.remaining_args + std::env::args_os() + .take(1) + .chain(cli_dotenv.remaining_args.into_iter()) + .collect() } else { dotenv::dotenv().ok(); std::env::args_os().collect() }; let cli = Cli::parse_from(args); - let liqor_owner = Arc::new(keypair_from_cli(&cli.liqor_owner)); + // + // Priority fee setup + // + let (prio_provider, prio_jobs) = cli + .prioritization_fee_cli + .make_prio_provider(cli.lite_rpc_url.clone())?; + // + // Client setup + // + let liqor_owner = Arc::new(keypair_from_cli(&cli.liqor_owner)); let rpc_url = cli.rpc_url; let ws_url = rpc_url.replace("https", "wss"); - let rpc_timeout = Duration::from_secs(10); let cluster = Cluster::Custom(rpc_url.clone(), ws_url.clone()); let commitment = CommitmentConfig::processed(); @@ -207,16 +78,18 @@ async fn main() -> anyhow::Result<()> { .cluster(cluster.clone()) .commitment(commitment) .fee_payer(Some(liqor_owner.clone())) - .timeout(Some(rpc_timeout)) - .jupiter_v4_url(cli.jupiter_v4_url) + .timeout(rpc_timeout) .jupiter_v6_url(cli.jupiter_v6_url) .jupiter_token(cli.jupiter_token) - .transaction_builder_config(TransactionBuilderConfig { - prioritization_micro_lamports: (cli.prioritization_micro_lamports > 0) - .then_some(cli.prioritization_micro_lamports), - // Liquidation and tcs triggers set their own budgets, this is a default for other tx - compute_budget_per_instruction: Some(250_000), - }) + .transaction_builder_config( + TransactionBuilderConfig::builder() + .priority_fee_provider(prio_provider) + // Liquidation and tcs triggers set their own budgets, this is a default for other tx + .compute_budget_per_instruction(Some(250_000)) + .build() + .unwrap(), + ) + .override_send_transaction_urls(cli.override_send_transaction_url) .build() .unwrap(); @@ -225,7 +98,7 @@ async fn main() -> anyhow::Result<()> { // Reading accounts from chain_data let account_fetcher = Arc::new(chain_data::AccountFetcher { chain_data: chain_data.clone(), - rpc: client.rpc_async(), + rpc: client.new_rpc_async(), }); let mango_account = account_fetcher @@ -238,7 +111,7 @@ async fn main() -> anyhow::Result<()> { warn!("rebalancing on delegated accounts will be unable to free token positions reliably, withdraw dust manually"); } - let group_context = MangoGroupContext::new_from_rpc(&client.rpc_async(), mango_group).await?; + let group_context = MangoGroupContext::new_from_rpc(client.rpc_async(), mango_group).await?; let mango_oracles = group_context .tokens @@ -319,8 +192,8 @@ async fn main() -> anyhow::Result<()> { }; let token_swap_info_config = token_swap_info::Config { - quote_index: 0, // USDC - quote_amount: 1_000_000_000, // TODO: config, $1000, should be >= tcs_config.max_trigger_quote_amount + quote_index: 0, // USDC + quote_amount: (cli.jupiter_swap_info_amount * 1e6) as u64, jupiter_version: cli.jupiter_version.into(), }; @@ -332,24 +205,33 @@ async fn main() -> anyhow::Result<()> { let liq_config = liquidate::Config { min_health_ratio: cli.min_health_ratio, compute_limit_for_liq_ix: cli.compute_limit_for_liquidation, - // TODO: config - refresh_timeout: Duration::from_secs(30), + max_cu_per_transaction: 1_000_000, + refresh_timeout: Duration::from_secs(cli.liquidation_refresh_timeout_secs as u64), + only_allowed_tokens: cli_args::cli_to_hashset::(cli.only_allow_tokens), + forbidden_tokens: cli_args::cli_to_hashset::(cli.forbidden_tokens), + only_allowed_perp_markets: cli_args::cli_to_hashset::( + cli.liquidation_only_allow_perp_markets, + ), + forbidden_perp_markets: cli_args::cli_to_hashset::( + cli.liquidation_forbidden_perp_markets, + ), }; let tcs_config = trigger_tcs::Config { min_health_ratio: cli.min_health_ratio, - max_trigger_quote_amount: 1_000_000_000, // TODO: config, $1000 + max_trigger_quote_amount: (cli.tcs_max_trigger_amount * 1e6) as u64, compute_limit_for_trigger: cli.compute_limit_for_tcs, profit_fraction: cli.tcs_profit_fraction, collateral_token_index: 0, // USDC - // TODO: config - refresh_timeout: Duration::from_secs(30), jupiter_version: cli.jupiter_version.into(), jupiter_slippage_bps: cli.rebalance_slippage_bps, mode: cli.tcs_mode.into(), - min_buy_fraction: 0.7, + min_buy_fraction: cli.tcs_min_buy_fraction, + + only_allowed_tokens: liq_config.only_allowed_tokens.clone(), + forbidden_tokens: liq_config.forbidden_tokens.clone(), }; let mut rebalance_interval = tokio::time::interval(Duration::from_secs(30)); @@ -357,16 +239,10 @@ async fn main() -> anyhow::Result<()> { let rebalance_config = rebalance::Config { enabled: cli.rebalance == BoolArg::True, slippage_bps: cli.rebalance_slippage_bps, - // TODO: config - borrow_settle_excess: 1.05, - refresh_timeout: Duration::from_secs(30), + borrow_settle_excess: (1f64 + cli.rebalance_borrow_settle_excess).max(1f64), + refresh_timeout: Duration::from_secs(cli.rebalance_refresh_timeout_secs), jupiter_version: cli.jupiter_version.into(), - skip_tokens: cli - .rebalance_skip_tokens - .split(',') - .filter(|v| !v.is_empty()) - .map(|name| mango_client.context.token_by_name(name).token_index) - .collect(), + skip_tokens: cli.rebalance_skip_tokens.unwrap_or(Vec::new()), allow_withdraws: signer_is_owner, }; @@ -388,6 +264,12 @@ async fn main() -> anyhow::Result<()> { .skip_threshold_for_type(LiqErrorType::Liq, 5) .skip_duration(Duration::from_secs(120)) .build()?, + oracle_errors: ErrorTracking::builder() + .skip_threshold(1) + .skip_duration(Duration::from_secs( + cli.skip_oracle_error_in_logs_duration_secs, + )) + .build()?, }); info!("main loop"); @@ -501,6 +383,7 @@ async fn main() -> anyhow::Result<()> { }; liquidation.errors.update(); + liquidation.oracle_errors.update(); let liquidated = liquidation .maybe_liquidate_one(account_addresses.iter()) @@ -508,16 +391,13 @@ async fn main() -> anyhow::Result<()> { let mut took_tcs = false; if !liquidated && cli.take_tcs == BoolArg::True { - took_tcs = match liquidation + took_tcs = liquidation .maybe_take_token_conditional_swap(account_addresses.iter()) .await - { - Ok(v) => v, - Err(err) => { + .unwrap_or_else(|err| { error!("error during maybe_take_token_conditional_swap: {err}"); false - } - } + }) } if liquidated || took_tcs { @@ -528,14 +408,15 @@ async fn main() -> anyhow::Result<()> { }); let token_swap_info_job = tokio::spawn({ - // TODO: configurable interval - let mut interval = mango_v4_client::delay_interval(Duration::from_secs(60)); + let mut interval = mango_v4_client::delay_interval(Duration::from_secs( + cli.token_swap_refresh_interval_secs, + )); let mut startup_wait = mango_v4_client::delay_interval(Duration::from_secs(1)); let shared_state = shared_state.clone(); async move { loop { - startup_wait.tick().await; if !shared_state.read().unwrap().one_snapshot_done { + startup_wait.tick().await; continue; } @@ -570,6 +451,7 @@ async fn main() -> anyhow::Result<()> { )); } + use cli_args::{BoolArg, Cli, CliDotenv}; use futures::StreamExt; let mut jobs: futures::stream::FuturesUnordered<_> = vec![ data_job, @@ -579,6 +461,7 @@ async fn main() -> anyhow::Result<()> { check_changes_for_abort_job, ] .into_iter() + .chain(prio_jobs.into_iter()) .collect(); jobs.next().await; @@ -625,6 +508,7 @@ struct LiquidationState { trigger_tcs_config: trigger_tcs::Config, errors: ErrorTracking, + oracle_errors: ErrorTracking, } impl LiquidationState { @@ -678,6 +562,25 @@ impl LiquidationState { .await; if let Err(err) = result.as_ref() { + if let Some((ti, ti_name)) = err.try_unwrap_oracle_error() { + if self + .oracle_errors + .had_too_many_errors(LiqErrorType::Liq, &ti, Instant::now()) + .is_none() + { + warn!( + "{:?} recording oracle error for token {} {}", + chrono::offset::Utc::now(), + ti_name, + ti + ); + } + + self.oracle_errors + .record(LiqErrorType::Liq, &ti, err.to_string()); + return result; + } + // Keep track of pubkeys that had errors error_tracking.record(LiqErrorType::Liq, pubkey, err.to_string()); diff --git a/bin/liquidator/src/rebalance.rs b/bin/liquidator/src/rebalance.rs index 01c750e84..6cc431dd0 100644 --- a/bin/liquidator/src/rebalance.rs +++ b/bin/liquidator/src/rebalance.rs @@ -151,18 +151,7 @@ impl Rebalancer { let direct_sol_route_job = self.jupiter_quote(sol_mint, output_mint, in_amount_sol, true, jupiter_version); - let mut jobs = vec![full_route_job, direct_quote_route_job, direct_sol_route_job]; - - // for v6, add a v4 fallback - if self.config.jupiter_version == jupiter::Version::V6 { - jobs.push(self.jupiter_quote( - quote_mint, - output_mint, - in_amount_quote, - false, - jupiter::Version::V4, - )); - } + let jobs = vec![full_route_job, direct_quote_route_job, direct_sol_route_job]; let mut results = futures::future::join_all(jobs).await; let full_route = results.remove(0)?; @@ -211,18 +200,7 @@ impl Rebalancer { let direct_sol_route_job = self.jupiter_quote(input_mint, sol_mint, in_amount, true, jupiter_version); - let mut jobs = vec![full_route_job, direct_quote_route_job, direct_sol_route_job]; - - // for v6, add a v4 fallback - if self.config.jupiter_version == jupiter::Version::V6 { - jobs.push(self.jupiter_quote( - input_mint, - quote_mint, - in_amount, - false, - jupiter::Version::V4, - )); - } + let jobs = vec![full_route_job, direct_quote_route_job, direct_sol_route_job]; let mut results = futures::future::join_all(jobs).await; let full_route = results.remove(0)?; @@ -253,7 +231,7 @@ impl Rebalancer { .prepare_swap_transaction(full) .await?; let tx_size = builder.transaction_size()?; - if tx_size.is_ok() { + if tx_size.is_within_limit() { return Ok((builder, full.clone())); } trace!( @@ -520,6 +498,7 @@ impl Rebalancer { }; let counters = perp_pnl::fetch_top( &self.mango_client.context, + &self.mango_client.client.config().fallback_oracle_config, self.account_fetcher.as_ref(), perp_position.market_index, direction, diff --git a/bin/liquidator/src/token_swap_info.rs b/bin/liquidator/src/token_swap_info.rs index e15fea2fd..8e4e018ad 100644 --- a/bin/liquidator/src/token_swap_info.rs +++ b/bin/liquidator/src/token_swap_info.rs @@ -11,7 +11,10 @@ use mango_v4_client::MangoClient; pub struct Config { pub quote_index: TokenIndex, + + /// Size in quote_index-token native tokens to quote. pub quote_amount: u64, + pub jupiter_version: jupiter::Version, } diff --git a/bin/liquidator/src/trigger_tcs.rs b/bin/liquidator/src/trigger_tcs.rs index 00ad4aadd..d42104846 100644 --- a/bin/liquidator/src/trigger_tcs.rs +++ b/bin/liquidator/src/trigger_tcs.rs @@ -1,8 +1,9 @@ +use std::collections::HashSet; use std::{ collections::HashMap, pin::Pin, sync::{Arc, RwLock}, - time::{Duration, Instant}, + time::Instant, }; use futures_core::Future; @@ -11,10 +12,10 @@ use mango_v4::{ i80f48::ClampToInt, state::{Bank, MangoAccountValue, TokenConditionalSwap, TokenIndex}, }; -use mango_v4_client::{chain_data, health_cache, jupiter, MangoClient, TransactionBuilder}; +use mango_v4_client::{chain_data, jupiter, MangoClient, TransactionBuilder}; use anyhow::Context as AnyhowContext; -use solana_sdk::{signature::Signature, signer::Signer}; +use solana_sdk::signature::Signature; use tracing::*; use {fixed::types::I80F48, solana_sdk::pubkey::Pubkey}; @@ -56,7 +57,6 @@ pub enum Mode { pub struct Config { pub min_health_ratio: f64, pub max_trigger_quote_amount: u64, - pub refresh_timeout: Duration, pub compute_limit_for_trigger: u32, pub collateral_token_index: TokenIndex, @@ -73,6 +73,9 @@ pub struct Config { pub jupiter_version: jupiter::Version, pub jupiter_slippage_bps: u64, pub mode: Mode, + + pub only_allowed_tokens: HashSet, + pub forbidden_tokens: HashSet, } pub enum JupiterQuoteCacheResult { @@ -401,11 +404,43 @@ impl Context { Ok(taker_price >= base_price * cost_over_oracle * (1.0 + self.config.profit_fraction)) } + // excluded by config + fn tcs_pair_is_allowed( + &self, + buy_token_index: TokenIndex, + sell_token_index: TokenIndex, + ) -> bool { + if self.config.forbidden_tokens.contains(&buy_token_index) { + return false; + } + + if self.config.forbidden_tokens.contains(&sell_token_index) { + return false; + } + + if self.config.only_allowed_tokens.is_empty() { + return true; + } + + if self.config.only_allowed_tokens.contains(&buy_token_index) { + return true; + } + + if self.config.only_allowed_tokens.contains(&sell_token_index) { + return true; + } + + return false; + } + // Either expired or triggerable with ok-looking price. fn tcs_is_interesting(&self, tcs: &TokenConditionalSwap) -> anyhow::Result { if tcs.is_expired(self.now_ts) { return Ok(true); } + if !self.tcs_pair_is_allowed(tcs.buy_token_index, tcs.buy_token_index) { + return Ok(false); + } let (_, buy_token_price, _) = self.token_bank_price_mint(tcs.buy_token_index)?; let (_, sell_token_price, _) = self.token_bank_price_mint(tcs.sell_token_index)?; @@ -665,8 +700,9 @@ impl Context { liqee_old: &MangoAccountValue, tcs_id: u64, ) -> anyhow::Result> { - let fetcher = self.account_fetcher.as_ref(); - let health_cache = health_cache::new(&self.mango_client.context, fetcher, liqee_old) + let health_cache = self + .mango_client + .health_cache(liqee_old) .await .context("creating health cache 1")?; if health_cache.is_liquidatable() { @@ -685,7 +721,9 @@ impl Context { return Ok(None); } - let health_cache = health_cache::new(&self.mango_client.context, fetcher, &liqee) + let health_cache = self + .mango_client + .health_cache(&liqee) .await .context("creating health cache 2")?; if health_cache.is_liquidatable() { @@ -1165,10 +1203,8 @@ impl Context { let fee_payer = self.mango_client.client.fee_payer(); TransactionBuilder { instructions: vec![compute_ix], - address_lookup_tables: vec![], - payer: fee_payer.pubkey(), signers: vec![self.mango_client.owner.clone(), fee_payer], - config: self.mango_client.client.transaction_builder_config, + ..self.mango_client.transaction_builder().await? } }; diff --git a/bin/liquidator/src/unwrappable_oracle_error.rs b/bin/liquidator/src/unwrappable_oracle_error.rs new file mode 100644 index 000000000..f27340eea --- /dev/null +++ b/bin/liquidator/src/unwrappable_oracle_error.rs @@ -0,0 +1,126 @@ +use anchor_lang::error::Error::AnchorError; +use mango_v4::error::MangoError; +use mango_v4::state::TokenIndex; +use regex::Regex; + +pub trait UnwrappableOracleError { + fn try_unwrap_oracle_error(&self) -> Option<(TokenIndex, String)>; +} + +impl UnwrappableOracleError for anyhow::Error { + fn try_unwrap_oracle_error(&self) -> Option<(TokenIndex, String)> { + let root_cause = self + .root_cause() + .downcast_ref::(); + + if root_cause.is_none() { + return None; + } + + if let AnchorError(ae) = root_cause.unwrap() { + let is_oracle_error = ae.error_code_number == MangoError::OracleConfidence.error_code() + || ae.error_code_number == MangoError::OracleStale.error_code(); + + if !is_oracle_error { + return None; + } + + let error_str = ae.to_string(); + return parse_oracle_error_string(&error_str); + } + + None + } +} + +fn parse_oracle_error_string(error_str: &str) -> Option<(TokenIndex, String)> { + let token_name_regex = Regex::new(r#"name: (\w+)"#).unwrap(); + let token_index_regex = Regex::new(r#"token index (\d+)"#).unwrap(); + let token_name = token_name_regex + .captures(error_str) + .map(|c| c[1].to_string()) + .unwrap_or_default(); + let token_index = token_index_regex + .captures(error_str) + .map(|c| c[1].parse::().ok()) + .unwrap_or_default(); + + if token_index.is_some() { + return Some((TokenIndex::from(token_index.unwrap()), token_name)); + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + use anchor_lang::error; + use anyhow::Context; + use mango_v4::error::Contextable; + use mango_v4::error::MangoError; + use mango_v4::state::{oracle_log_context, OracleConfig, OracleState, OracleType}; + + fn generate_errored_res() -> std::result::Result { + return Err(MangoError::OracleConfidence.into()); + } + + fn generate_errored_res_with_context() -> anyhow::Result { + let value = Contextable::with_context( + Contextable::with_context(generate_errored_res(), || { + oracle_log_context( + "SOL", + &OracleState { + price: Default::default(), + deviation: Default::default(), + last_update_slot: 0, + oracle_type: OracleType::Pyth, + }, + &OracleConfig { + conf_filter: Default::default(), + max_staleness_slots: 0, + reserved: [0; 72], + }, + None, + ) + }), + || { + format!( + "getting oracle for bank with health account index {} and token index {}, passed account {}", + 10, + 11, + 12, + ) + }, + )?; + + Ok(value) + } + + #[test] + fn should_extract_oracle_error_and_token_infos() { + let error = generate_errored_res_with_context() + .context("Something") + .unwrap_err(); + println!("{}", error); + println!("{}", error.root_cause()); + let oracle_error_opt = error.try_unwrap_oracle_error(); + + assert!(oracle_error_opt.is_some()); + assert_eq!( + oracle_error_opt.unwrap(), + (TokenIndex::from(11u16), "SOL".to_string()) + ); + } + + #[test] + fn should_parse_oracle_error_message() { + assert!(parse_oracle_error_string("").is_none()); + assert!(parse_oracle_error_string("Something went wrong").is_none()); + assert_eq!( + parse_oracle_error_string("Something went wrong token index 4, name: SOL, Stale") + .unwrap(), + (TokenIndex::from(4u16), "SOL".to_string()) + ); + } +} diff --git a/bin/service-mango-crank/src/main.rs b/bin/service-mango-crank/src/main.rs index 1a36dc6e2..bb0c144a1 100644 --- a/bin/service-mango-crank/src/main.rs +++ b/bin/service-mango-crank/src/main.rs @@ -78,7 +78,7 @@ async fn main() -> anyhow::Result<()> { ); let group_pk = Pubkey::from_str(&config.mango_group).unwrap(); let group_context = - Arc::new(MangoGroupContext::new_from_rpc(&client.rpc_async(), group_pk).await?); + Arc::new(MangoGroupContext::new_from_rpc(client.rpc_async(), group_pk).await?); let perp_queue_pks: Vec<_> = group_context .perp_markets diff --git a/bin/service-mango-fills/src/main.rs b/bin/service-mango-fills/src/main.rs index 067ea32aa..77dd666b0 100644 --- a/bin/service-mango-fills/src/main.rs +++ b/bin/service-mango-fills/src/main.rs @@ -373,7 +373,7 @@ async fn main() -> anyhow::Result<()> { ); let group_context = Arc::new( MangoGroupContext::new_from_rpc( - &client.rpc_async(), + client.rpc_async(), Pubkey::from_str(&config.mango_group).unwrap(), ) .await?, diff --git a/bin/service-mango-orderbook/src/main.rs b/bin/service-mango-orderbook/src/main.rs index 47abce5e2..e2691d89d 100644 --- a/bin/service-mango-orderbook/src/main.rs +++ b/bin/service-mango-orderbook/src/main.rs @@ -357,7 +357,7 @@ async fn main() -> anyhow::Result<()> { ); let group_context = Arc::new( MangoGroupContext::new_from_rpc( - &client.rpc_async(), + client.rpc_async(), Pubkey::from_str(&config.mango_group).unwrap(), ) .await?, diff --git a/bin/service-mango-pnl/src/main.rs b/bin/service-mango-pnl/src/main.rs index 2b3d69b56..49869f379 100644 --- a/bin/service-mango-pnl/src/main.rs +++ b/bin/service-mango-pnl/src/main.rs @@ -21,7 +21,8 @@ use fixed::types::I80F48; use mango_feeds_connector::metrics::*; use mango_v4::state::{MangoAccount, MangoAccountValue, PerpMarketIndex}; use mango_v4_client::{ - chain_data, health_cache, AccountFetcher, Client, MangoGroupContext, TransactionBuilderConfig, + chain_data, health_cache, AccountFetcher, Client, FallbackOracleConfig, MangoGroupContext, + TransactionBuilderConfig, }; use solana_sdk::commitment_config::CommitmentConfig; use solana_sdk::{account::ReadableAccount, signature::Keypair}; @@ -52,7 +53,13 @@ async fn compute_pnl( account_fetcher: Arc, account: &MangoAccountValue, ) -> anyhow::Result> { - let health_cache = health_cache::new(&context, account_fetcher.as_ref(), account).await?; + let health_cache = health_cache::new( + &context, + &FallbackOracleConfig::Dynamic, + account_fetcher.as_ref(), + account, + ) + .await?; let pnls = account .active_perp_positions() @@ -265,7 +272,7 @@ async fn main() -> anyhow::Result<()> { ); let group_context = Arc::new( MangoGroupContext::new_from_rpc( - &client.rpc_async(), + client.rpc_async(), Pubkey::from_str(&config.pnl.mango_group).unwrap(), ) .await?, @@ -273,7 +280,7 @@ async fn main() -> anyhow::Result<()> { let chain_data = Arc::new(RwLock::new(chain_data::ChainData::new())); let account_fetcher = Arc::new(chain_data::AccountFetcher { chain_data: chain_data.clone(), - rpc: client.rpc_async(), + rpc: client.new_rpc_async(), }); let metrics_tx = metrics::start(config.metrics, "pnl".into()); diff --git a/bin/settler/src/main.rs b/bin/settler/src/main.rs index 911582ed6..57f408a21 100644 --- a/bin/settler/src/main.rs +++ b/bin/settler/src/main.rs @@ -6,8 +6,8 @@ use anchor_client::Cluster; use clap::Parser; use mango_v4::state::{PerpMarketIndex, TokenIndex}; use mango_v4_client::{ - account_update_stream, chain_data, keypair_from_cli, snapshot_source, websocket_source, Client, - MangoClient, MangoGroupContext, TransactionBuilderConfig, + account_update_stream, chain_data, keypair_from_cli, priority_fees_cli, snapshot_source, + websocket_source, Client, MangoClient, MangoGroupContext, TransactionBuilderConfig, }; use tracing::*; @@ -61,9 +61,12 @@ struct Cli { #[clap(long, env, default_value = "100")] get_multiple_accounts_count: usize, - /// prioritize each transaction with this many microlamports/cu - #[clap(long, env, default_value = "0")] - prioritization_micro_lamports: u64, + #[clap(flatten)] + prioritization_fee_cli: priority_fees_cli::PriorityFeeArgs, + + /// url to the lite-rpc websocket, optional + #[clap(long, env, default_value = "")] + lite_rpc_url: String, /// compute budget for each instruction #[clap(long, env, default_value = "250000")] @@ -87,6 +90,10 @@ async fn main() -> anyhow::Result<()> { }; let cli = Cli::parse_from(args); + let (prio_provider, prio_jobs) = cli + .prioritization_fee_cli + .make_prio_provider(cli.lite_rpc_url.clone())?; + let settler_owner = Arc::new(keypair_from_cli(&cli.settler_owner)); let rpc_url = cli.rpc_url; @@ -100,11 +107,11 @@ async fn main() -> anyhow::Result<()> { commitment, settler_owner.clone(), Some(rpc_timeout), - TransactionBuilderConfig { - prioritization_micro_lamports: (cli.prioritization_micro_lamports > 0) - .then_some(cli.prioritization_micro_lamports), - compute_budget_per_instruction: Some(cli.compute_budget_per_instruction), - }, + TransactionBuilderConfig::builder() + .compute_budget_per_instruction(Some(cli.compute_budget_per_instruction)) + .priority_fee_provider(prio_provider) + .build() + .unwrap(), ); // The representation of current on-chain account data @@ -112,7 +119,7 @@ async fn main() -> anyhow::Result<()> { // Reading accounts from chain_data let account_fetcher = Arc::new(chain_data::AccountFetcher { chain_data: chain_data.clone(), - rpc: client.rpc_async(), + rpc: client.new_rpc_async(), }); let mango_account = account_fetcher @@ -120,7 +127,7 @@ async fn main() -> anyhow::Result<()> { .await?; let mango_group = mango_account.fixed.group; - let group_context = MangoGroupContext::new_from_rpc(&client.rpc_async(), mango_group).await?; + let group_context = MangoGroupContext::new_from_rpc(client.rpc_async(), mango_group).await?; let mango_oracles = group_context .tokens @@ -352,6 +359,7 @@ async fn main() -> anyhow::Result<()> { check_changes_for_abort_job, ] .into_iter() + .chain(prio_jobs.into_iter()) .collect(); jobs.next().await; diff --git a/bin/settler/src/settle.rs b/bin/settler/src/settle.rs index 03149d8be..13e154868 100644 --- a/bin/settler/src/settle.rs +++ b/bin/settler/src/settle.rs @@ -5,10 +5,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use mango_v4::accounts_zerocopy::KeyedAccountSharedData; use mango_v4::health::HealthType; use mango_v4::state::{OracleAccountInfos, PerpMarket, PerpMarketIndex}; -use mango_v4_client::{ - chain_data, health_cache, prettify_solana_client_error, MangoClient, PreparedInstructions, - TransactionBuilder, -}; +use mango_v4_client::{chain_data, MangoClient, PreparedInstructions, TransactionBuilder}; use solana_sdk::address_lookup_table_account::AddressLookupTableAccount; use solana_sdk::commitment_config::CommitmentConfig; use solana_sdk::signature::Signature; @@ -120,11 +117,10 @@ impl SettlementState { continue; } - let health_cache = - match health_cache::new(&mango_client.context, account_fetcher, &account).await { - Ok(hc) => hc, - Err(_) => continue, // Skip for stale/unconfident oracles - }; + let health_cache = match mango_client.health_cache(&account).await { + Ok(hc) => hc, + Err(_) => continue, // Skip for stale/unconfident oracles + }; let liq_end_health = health_cache.health(HealthType::LiquidationEnd); for perp_market_index in perp_indexes { @@ -288,7 +284,7 @@ impl<'a> SettleBatchProcessor<'a> { address_lookup_tables: self.address_lookup_tables.clone(), payer: fee_payer.pubkey(), signers: vec![fee_payer], - config: client.transaction_builder_config, + config: client.config().transaction_builder_config.clone(), } .transaction_with_blockhash(self.blockhash) } @@ -301,13 +297,7 @@ impl<'a> SettleBatchProcessor<'a> { let tx = self.transaction()?; self.instructions.clear(); - let send_result = self - .mango_client - .client - .rpc_async() - .send_transaction_with_config(&tx, self.mango_client.client.rpc_send_transaction_config) - .await - .map_err(prettify_solana_client_error); + let send_result = self.mango_client.client.send_transaction(&tx).await; match send_result { Ok(txsig) => { @@ -328,11 +318,14 @@ impl<'a> SettleBatchProcessor<'a> { ) -> anyhow::Result> { let a_value = self.account_fetcher.fetch_mango_account(&account_a)?; let b_value = self.account_fetcher.fetch_mango_account(&account_b)?; - let new_ixs = self.mango_client.perp_settle_pnl_instruction( - self.perp_market_index, - (&account_a, &a_value), - (&account_b, &b_value), - )?; + let new_ixs = self + .mango_client + .perp_settle_pnl_instruction( + self.perp_market_index, + (&account_a, &a_value), + (&account_b, &b_value), + ) + .await?; let previous = self.instructions.clone(); self.instructions.append(new_ixs.clone()); diff --git a/bin/settler/src/tcs_start.rs b/bin/settler/src/tcs_start.rs index 493161b9b..9f8e37011 100644 --- a/bin/settler/src/tcs_start.rs +++ b/bin/settler/src/tcs_start.rs @@ -123,14 +123,17 @@ impl State { } // Clear newly created token positions, so the liqor account is mostly empty - for token_index in startable_chunk.iter().map(|(_, _, ti)| *ti).unique() { + let new_token_pos_indices = startable_chunk + .iter() + .map(|(_, _, ti)| *ti) + .unique() + .collect_vec(); + for token_index in new_token_pos_indices { let mint = mango_client.context.token(token_index).mint; - let ix = match mango_client.token_withdraw_instructions( - &liqor_account, - mint, - u64::MAX, - false, - ) { + let ix = match mango_client + .token_withdraw_instructions(&liqor_account, mint, u64::MAX, false) + .await + { Ok(ix) => ix, Err(_) => continue, }; diff --git a/lib/client/Cargo.toml b/lib/client/Cargo.toml index 64bc97b1b..cc1c29a9d 100644 --- a/lib/client/Cargo.toml +++ b/lib/client/Cargo.toml @@ -15,6 +15,7 @@ async-channel = "1.6" async-once-cell = { version = "0.4.2", features = ["unpin"] } async-trait = "0.1.52" atty = "0.2" +clap = { version = "3.1.8", features = ["derive", "env"] } derive_builder = "0.12.0" fixed = { workspace = true, features = ["serde", "borsh"] } futures = "0.3.25" @@ -30,6 +31,7 @@ solana-client = { workspace = true } solana-rpc = { workspace = true } solana-sdk = { workspace = true } solana-address-lookup-table-program = { workspace = true } +solana-transaction-status = { workspace = true } mango-feeds-connector = { workspace = true } spl-associated-token-account = "1.0.3" thiserror = "1.0.31" @@ -37,6 +39,7 @@ thiserror = "1.0.31" reqwest = "0.11.17" tokio = { version = "1", features = ["full"] } tokio-stream = { version = "0.1.9"} +tokio-tungstenite = "0.17.0" serde = "1.0.141" serde_json = "1.0.82" base64 = "0.13.0" diff --git a/lib/client/src/account_fetcher.rs b/lib/client/src/account_fetcher.rs index 6c1273a0f..9b769a56c 100644 --- a/lib/client/src/account_fetcher.rs +++ b/lib/client/src/account_fetcher.rs @@ -11,10 +11,14 @@ use anchor_lang::AccountDeserialize; use solana_client::nonblocking::rpc_client::RpcClient as RpcClientAsync; use solana_sdk::account::{AccountSharedData, ReadableAccount}; +use solana_sdk::hash::Hash; +use solana_sdk::hash::Hasher; use solana_sdk::pubkey::Pubkey; use mango_v4::state::MangoAccountValue; +use crate::gpa; + #[async_trait::async_trait] pub trait AccountFetcher: Sync + Send { async fn fetch_raw_account(&self, address: &Pubkey) -> anyhow::Result; @@ -29,6 +33,13 @@ pub trait AccountFetcher: Sync + Send { program: &Pubkey, discriminator: [u8; 8], ) -> anyhow::Result>; + + async fn fetch_multiple_accounts( + &self, + keys: &[Pubkey], + ) -> anyhow::Result>; + + async fn get_slot(&self) -> anyhow::Result; } // Can't be in the trait, since then it would no longer be object-safe... @@ -100,6 +111,17 @@ impl AccountFetcher for RpcAccountFetcher { .map(|(pk, acc)| (pk, acc.into())) .collect::>()) } + + async fn fetch_multiple_accounts( + &self, + keys: &[Pubkey], + ) -> anyhow::Result> { + gpa::fetch_multiple_accounts(&self.rpc, keys).await + } + + async fn get_slot(&self) -> anyhow::Result { + Ok(self.rpc.get_slot().await?) + } } struct CoalescedAsyncJob { @@ -138,6 +160,8 @@ struct AccountCache { keys_for_program_and_discriminator: HashMap<(Pubkey, [u8; 8]), Vec>, account_jobs: CoalescedAsyncJob>, + multiple_accounts_jobs: + CoalescedAsyncJob>>, program_accounts_jobs: CoalescedAsyncJob<(Pubkey, [u8; 8]), anyhow::Result>>, } @@ -261,4 +285,62 @@ impl AccountFetcher for CachedAccountFetcher { )), } } + + async fn fetch_multiple_accounts( + &self, + keys: &[Pubkey], + ) -> anyhow::Result> { + let fetch_job = { + let mut cache = self.cache.lock().unwrap(); + let mut missing_keys: Vec = keys + .iter() + .filter(|k| !cache.accounts.contains_key(k)) + .cloned() + .collect(); + if missing_keys.len() == 0 { + return Ok(keys + .iter() + .map(|pk| (*pk, cache.accounts.get(&pk).unwrap().clone())) + .collect::>()); + } + + let self_copy = self.clone(); + missing_keys.sort(); + let mut hasher = Hasher::default(); + for key in missing_keys.iter() { + hasher.hash(key.as_ref()); + } + let job_key = hasher.result(); + cache + .multiple_accounts_jobs + .run_coalesced(job_key.clone(), async move { + let result = self_copy + .fetcher + .fetch_multiple_accounts(&missing_keys) + .await; + let mut cache = self_copy.cache.lock().unwrap(); + cache.multiple_accounts_jobs.remove(&job_key); + + if let Ok(results) = result.as_ref() { + for (key, account) in results { + cache.accounts.insert(*key, account.clone()); + } + } + result + }) + }; + + match fetch_job.get().await { + Ok(v) => Ok(v.clone()), + // Can't clone the stored error, so need to stringize it + Err(err) => Err(anyhow::format_err!( + "fetch error in CachedAccountFetcher: {:?}", + err + )), + } + } + + async fn get_slot(&self) -> anyhow::Result { + self.fetcher.get_slot().await + } } diff --git a/lib/client/src/chain_data_fetcher.rs b/lib/client/src/chain_data_fetcher.rs index 856453619..2fe9313c9 100644 --- a/lib/client/src/chain_data_fetcher.rs +++ b/lib/client/src/chain_data_fetcher.rs @@ -8,7 +8,10 @@ use anchor_lang::Discriminator; use fixed::types::I80F48; use mango_v4::accounts_zerocopy::{KeyedAccountSharedData, LoadZeroCopy}; -use mango_v4::state::{Bank, MangoAccount, MangoAccountValue, OracleAccountInfos}; +use mango_v4::state::{ + pyth_mainnet_sol_oracle, pyth_mainnet_usdc_oracle, Bank, MangoAccount, MangoAccountValue, + OracleAccountInfos, +}; use anyhow::Context; @@ -64,12 +67,34 @@ impl AccountFetcher { pub fn fetch_bank_and_price(&self, bank: &Pubkey) -> anyhow::Result<(Bank, I80F48)> { let bank: Bank = self.fetch(bank)?; - let oracle = self.fetch_raw(&bank.oracle)?; - let oracle_acc = &KeyedAccountSharedData::new(bank.oracle, oracle.into()); - let price = bank.oracle_price(&OracleAccountInfos::from_reader(oracle_acc), None)?; + let oracle_data = self.fetch_raw(&bank.oracle)?; + let oracle = &KeyedAccountSharedData::new(bank.oracle, oracle_data.into()); + + let fallback_opt = self.fetch_keyed_account_data(bank.fallback_oracle)?; + let sol_opt = self.fetch_keyed_account_data(pyth_mainnet_sol_oracle::ID)?; + let usdc_opt = self.fetch_keyed_account_data(pyth_mainnet_usdc_oracle::ID)?; + + let oracle_acc_infos = OracleAccountInfos { + oracle, + fallback_opt: fallback_opt.as_ref(), + usdc_opt: usdc_opt.as_ref(), + sol_opt: sol_opt.as_ref(), + }; + let price = bank.oracle_price(&oracle_acc_infos, None)?; Ok((bank, price)) } + #[inline(always)] + fn fetch_keyed_account_data( + &self, + key: Pubkey, + ) -> anyhow::Result> { + Ok(self + .fetch_raw(&key) + .ok() + .map(|data| KeyedAccountSharedData::new(key, data))) + } + pub fn fetch_bank_price(&self, bank: &Pubkey) -> anyhow::Result { self.fetch_bank_and_price(bank).map(|(_, p)| p) } @@ -217,4 +242,20 @@ impl crate::AccountFetcher for AccountFetcher { }) .collect::>()) } + + async fn fetch_multiple_accounts( + &self, + keys: &[Pubkey], + ) -> anyhow::Result> { + let chain_data = self.chain_data.read().unwrap(); + Ok(keys + .iter() + .map(|pk| (*pk, chain_data.account(pk).unwrap().account.clone())) + .collect::>()) + } + + async fn get_slot(&self) -> anyhow::Result { + let chain_data = self.chain_data.read().unwrap(); + Ok(chain_data.newest_processed_slot()) + } } diff --git a/lib/client/src/client.rs b/lib/client/src/client.rs index 20ca67cc1..b6e3243a8 100644 --- a/lib/client/src/client.rs +++ b/lib/client/src/client.rs @@ -1,3 +1,4 @@ +use anchor_client::ClientError::AnchorError; use std::ops::Deref; use std::str::FromStr; use std::sync::Arc; @@ -12,18 +13,29 @@ use anchor_spl::associated_token::get_associated_token_address; use anchor_spl::token::Token; use fixed::types::I80F48; -use futures::{stream, StreamExt, TryStreamExt}; +use futures::{stream, StreamExt, TryFutureExt, TryStreamExt}; use itertools::Itertools; +use tracing::*; use mango_v4::accounts_ix::{Serum3OrderType, Serum3SelfTradeBehavior, Serum3Side}; use mango_v4::accounts_zerocopy::KeyedAccountSharedData; +use mango_v4::health::HealthCache; use mango_v4::state::{ Bank, Group, MangoAccountValue, OracleAccountInfos, PerpMarket, PerpMarketIndex, PlaceOrderType, SelfTradeBehavior, Serum3MarketIndex, Side, TokenIndex, INSURANCE_TOKEN_INDEX, }; +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::PreparedInstructions; +use crate::{jupiter, util}; use solana_address_lookup_table_program::state::AddressLookupTable; use solana_client::nonblocking::rpc_client::RpcClient as RpcClientAsync; +use solana_client::rpc_client::SerializableTransaction; use solana_client::rpc_config::RpcSendTransactionConfig; use solana_client::rpc_response::RpcSimulateTransactionResult; use solana_sdk::address_lookup_table_account::AddressLookupTableAccount; @@ -33,13 +45,8 @@ use solana_sdk::hash::Hash; use solana_sdk::signer::keypair; use solana_sdk::transaction::TransactionError; -use crate::account_fetcher::*; -use crate::context::MangoGroupContext; -use crate::gpa::{fetch_anchor_account, fetch_mango_accounts}; -use crate::util::PreparedInstructions; -use crate::{jupiter, util}; - use anyhow::Context; +use mango_v4::error::{IsAnchorErrorWithCode, MangoError}; use solana_sdk::account::ReadableAccount; use solana_sdk::instruction::{AccountMeta, Instruction}; use solana_sdk::signature::{Keypair, Signature}; @@ -49,8 +56,9 @@ use solana_sdk::{commitment_config::CommitmentConfig, pubkey::Pubkey, signer::Si pub const MAX_ACCOUNTS_PER_TRANSACTION: usize = 64; // very close to anchor_client::Client, which unfortunately has no accessors or Clone -#[derive(Clone, Debug, Builder)] -pub struct Client { +#[derive(Clone, Builder)] +#[builder(name = "ClientBuilder", build_fn(name = "build_config"))] +pub struct ClientConfig { /// RPC url /// /// Defaults to Cluster::Mainnet, using the public crowded mainnet-beta rpc endpoint. @@ -66,8 +74,11 @@ pub struct Client { pub commitment: CommitmentConfig, /// Timeout, defaults to 60s - #[builder(default = "Some(Duration::from_secs(60))")] - pub timeout: Option, + /// + /// This timeout applies to rpc requests. Note that the timeout for transaction + /// confirmation is configured separately in rpc_confirm_transaction_config. + #[builder(default = "Duration::from_secs(60)")] + pub timeout: Duration, #[builder(default)] pub transaction_builder_config: TransactionBuilderConfig, @@ -76,23 +87,51 @@ pub struct Client { #[builder(default = "ClientBuilder::default_rpc_send_transaction_config()")] pub rpc_send_transaction_config: RpcSendTransactionConfig, - #[builder(default = "\"https://quote-api.jup.ag/v4\".into()")] - pub jupiter_v4_url: String, + /// Defaults to waiting up to 60s for confirmation + #[builder(default = "ClientBuilder::default_rpc_confirm_transaction_config()")] + pub rpc_confirm_transaction_config: RpcConfirmTransactionConfig, #[builder(default = "\"https://quote-api.jup.ag/v6\".into()")] pub jupiter_v6_url: String, #[builder(default = "\"\".into()")] pub jupiter_token: String, + + /// Determines how fallback oracle accounts are provided to instructions. Defaults to Dynamic. + #[builder(default = "FallbackOracleConfig::Dynamic")] + pub fallback_oracle_config: FallbackOracleConfig, + + /// If set, don't use `cluster` for sending transactions and send to all + /// addresses configured here instead. + #[builder(default = "None")] + pub override_send_transaction_urls: Option>, } impl ClientBuilder { + pub fn build(&self) -> Result { + let config = self.build_config()?; + Ok(Client::new_from_config(config)) + } + pub fn default_rpc_send_transaction_config() -> RpcSendTransactionConfig { RpcSendTransactionConfig { preflight_commitment: Some(CommitmentLevel::Processed), ..Default::default() } } + + pub fn default_rpc_confirm_transaction_config() -> RpcConfirmTransactionConfig { + RpcConfirmTransactionConfig { + timeout: Some(Duration::from_secs(60)), + ..Default::default() + } + } +} + +pub struct Client { + config: ClientConfig, + rpc_async: RpcClientAsync, + send_transaction_rpc_asyncs: Vec, } impl Client { @@ -112,35 +151,101 @@ impl Client { .cluster(cluster) .commitment(commitment) .fee_payer(Some(fee_payer)) - .timeout(timeout) + .timeout(timeout.unwrap_or(Duration::from_secs(30))) .transaction_builder_config(transaction_builder_config) .build() .unwrap() } - pub fn rpc_async(&self) -> RpcClientAsync { - let url = self.cluster.url().to_string(); - if let Some(timeout) = self.timeout.as_ref() { - RpcClientAsync::new_with_timeout_and_commitment(url, *timeout, self.commitment) - } else { - RpcClientAsync::new_with_commitment(url, self.commitment) + pub fn new_from_config(config: ClientConfig) -> Self { + Self { + rpc_async: RpcClientAsync::new_with_timeout_and_commitment( + config.cluster.url().to_string(), + config.timeout, + config.commitment, + ), + send_transaction_rpc_asyncs: config + .override_send_transaction_urls + .clone() + .unwrap_or_else(|| vec![config.cluster.url().to_string()]) + .into_iter() + .map(|url| { + RpcClientAsync::new_with_timeout_and_commitment( + url, + config.timeout, + config.commitment, + ) + }) + .collect_vec(), + config, } } + pub fn config(&self) -> &ClientConfig { + &self.config + } + + pub fn rpc_async(&self) -> &RpcClientAsync { + &self.rpc_async + } + + /// Sometimes clients don't want to borrow the Client instance and just pass on RpcClientAsync + pub fn new_rpc_async(&self) -> RpcClientAsync { + let url = self.config.cluster.url().to_string(); + RpcClientAsync::new_with_timeout_and_commitment( + url, + self.config.timeout, + self.config.commitment, + ) + } + // TODO: this function here is awkward, since it (intentionally) doesn't use MangoClient::account_fetcher pub async fn rpc_anchor_account( &self, address: &Pubkey, ) -> anyhow::Result { - fetch_anchor_account(&self.rpc_async(), address).await + fetch_anchor_account(self.rpc_async(), address).await } pub fn fee_payer(&self) -> Arc { - self.fee_payer + self.config + .fee_payer .as_ref() .expect("fee payer must be set") .clone() } + + /// Sends a transaction via the configured cluster (or all override_send_transaction_urls). + /// + /// Returns the tx signature if at least one send returned ok. + /// Note that a success does not mean that the transaction is confirmed. + pub async fn send_transaction( + &self, + tx: &impl SerializableTransaction, + ) -> anyhow::Result { + let futures = self.send_transaction_rpc_asyncs.iter().map(|rpc| { + rpc.send_transaction_with_config(tx, self.config.rpc_send_transaction_config) + .map_err(prettify_solana_client_error) + }); + let mut results = futures::future::join_all(futures).await; + + // If all fail, return the first + let successful_sends = results.iter().filter(|r| r.is_ok()).count(); + if successful_sends == 0 { + results.remove(0)?; + } + + // Otherwise just log errors + for (result, rpc) in results.iter().zip(self.send_transaction_rpc_asyncs.iter()) { + if let Err(err) = result { + info!( + rpc = rpc.url(), + successful_sends, "one of the transaction sends failed: {err:?}", + ) + } + } + return Ok(*tx.get_signature()); + } } // todo: might want to integrate geyser, websockets, or simple http polling for keeping data fresh @@ -178,7 +283,7 @@ impl MangoClient { group: Pubkey, owner: &Keypair, ) -> anyhow::Result> { - fetch_mango_accounts(&client.rpc_async(), mango_v4::ID, group, owner.pubkey()).await + fetch_mango_accounts(client.rpc_async(), mango_v4::ID, group, owner.pubkey()).await } pub async fn find_or_create_account( @@ -272,7 +377,7 @@ impl MangoClient { address_lookup_tables: vec![], payer: payer.pubkey(), signers: vec![owner, payer], - config: client.transaction_builder_config, + config: client.config.transaction_builder_config.clone(), } .send_and_confirm(&client) .await?; @@ -286,7 +391,7 @@ impl MangoClient { account: Pubkey, owner: Arc, ) -> anyhow::Result { - let rpc = client.rpc_async(); + let rpc = client.new_rpc_async(); let account_fetcher = Arc::new(CachedAccountFetcher::new(Arc::new(RpcAccountFetcher { rpc, }))); @@ -346,35 +451,65 @@ impl MangoClient { pub async fn derive_health_check_remaining_account_metas( &self, + account: &MangoAccountValue, affected_tokens: Vec, writable_banks: Vec, affected_perp_markets: Vec, ) -> anyhow::Result<(Vec, u32)> { - let account = self.mango_account().await?; + let fallback_contexts = self + .context + .derive_fallback_oracle_keys( + &self.client.config.fallback_oracle_config, + &*self.account_fetcher, + ) + .await?; self.context.derive_health_check_remaining_account_metas( &account, affected_tokens, writable_banks, affected_perp_markets, + fallback_contexts, ) } - pub async fn derive_liquidation_health_check_remaining_account_metas( + pub async fn derive_health_check_remaining_account_metas_two_accounts( &self, - liqee: &MangoAccountValue, + account_1: &MangoAccountValue, + account_2: &MangoAccountValue, affected_tokens: &[TokenIndex], writable_banks: &[TokenIndex], ) -> anyhow::Result<(Vec, u32)> { - let account = self.mango_account().await?; + let fallback_contexts = self + .context + .derive_fallback_oracle_keys( + &self.client.config.fallback_oracle_config, + &*self.account_fetcher, + ) + .await?; + self.context .derive_health_check_remaining_account_metas_two_accounts( - &account, - liqee, + account_1, + account_2, affected_tokens, writable_banks, + fallback_contexts, ) } + pub async fn health_cache( + &self, + mango_account: &MangoAccountValue, + ) -> anyhow::Result { + health_cache::new( + &self.context, + &self.client.config.fallback_oracle_config, + &*self.account_fetcher, + mango_account, + ) + .await + } + pub async fn token_deposit( &self, mint: Pubkey, @@ -383,9 +518,15 @@ impl MangoClient { ) -> anyhow::Result { let token = self.context.token_by_mint(&mint)?; let token_index = token.token_index; + let mango_account = &self.mango_account().await?; let (health_check_metas, health_cu) = self - .derive_health_check_remaining_account_metas(vec![token_index], vec![], vec![]) + .derive_health_check_remaining_account_metas( + mango_account, + vec![token_index], + vec![], + vec![], + ) .await?; let ixs = PreparedInstructions::from_single( @@ -422,7 +563,7 @@ impl MangoClient { /// 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. - pub fn token_withdraw_instructions( + pub async fn token_withdraw_instructions( &self, account: &MangoAccountValue, mint: Pubkey, @@ -432,50 +573,46 @@ impl MangoClient { let token = self.context.token_by_mint(&mint)?; let token_index = token.token_index; - let (health_check_metas, health_cu) = - self.context.derive_health_check_remaining_account_metas( - account, - vec![token_index], - vec![], - vec![], - )?; + 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(), - &self.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( - &self.owner(), - &token.mint, - ), - token_program: Token::id(), - }, - None, - ); - ams.extend(health_check_metas.into_iter()); - ams + spl_associated_token_account::instruction::create_associated_token_account_idempotent( + &self.owner(), + &self.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( + &self.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, + }), }, - data: anchor_lang::InstructionData::data(&mango_v4::instruction::TokenWithdraw { - amount, - allow_borrow, - }), - }, - ], + ], self.instruction_cu(health_cu), ); Ok(ixs) @@ -488,7 +625,9 @@ impl MangoClient { allow_borrow: bool, ) -> anyhow::Result { let account = self.mango_account().await?; - let ixs = self.token_withdraw_instructions(&account, mint, amount, allow_borrow)?; + let ixs = self + .token_withdraw_instructions(&account, mint, amount, allow_borrow) + .await?; self.send_and_confirm_owner_tx(ixs.to_instructions()).await } @@ -521,6 +660,45 @@ impl MangoClient { // Serum3 // + pub fn serum3_close_open_orders_instruction( + &self, + market_index: Serum3MarketIndex, + ) -> PreparedInstructions { + let account_pubkey = self.mango_account_address; + let s3 = self.context.serum3(market_index); + + let open_orders = self.serum3_create_open_orders_address(market_index); + + PreparedInstructions::from_single( + Instruction { + program_id: mango_v4::id(), + accounts: anchor_lang::ToAccountMetas::to_account_metas( + &mango_v4::accounts::Serum3CloseOpenOrders { + group: self.group(), + account: account_pubkey, + serum_market: s3.address, + serum_program: s3.serum_program, + serum_market_external: s3.serum_market_external, + open_orders, + owner: self.owner(), + sol_destination: self.owner(), + }, + None, + ), + data: anchor_lang::InstructionData::data( + &mango_v4::instruction::Serum3CloseOpenOrders {}, + ), + }, + self.context.compute_estimates.cu_per_mango_instruction, + ) + } + + pub async fn serum3_close_open_orders(&self, name: &str) -> anyhow::Result { + let market_index = self.context.serum3_market_index(name); + let ix = self.serum3_close_open_orders_instruction(market_index); + self.send_and_confirm_owner_tx(ix.to_instructions()).await + } + pub fn serum3_create_open_orders_instruction( &self, market_index: Serum3MarketIndex, @@ -528,15 +706,7 @@ impl MangoClient { let account_pubkey = self.mango_account_address; let s3 = self.context.serum3(market_index); - let open_orders = Pubkey::find_program_address( - &[ - b"Serum3OO".as_ref(), - account_pubkey.as_ref(), - s3.address.as_ref(), - ], - &mango_v4::ID, - ) - .0; + let open_orders = self.serum3_create_open_orders_address(market_index); Instruction { program_id: mango_v4::id(), @@ -561,6 +731,23 @@ impl MangoClient { } } + fn serum3_create_open_orders_address(&self, market_index: Serum3MarketIndex) -> Pubkey { + let account_pubkey = self.mango_account_address; + let s3 = self.context.serum3(market_index); + + let open_orders = Pubkey::find_program_address( + &[ + b"Serum3OO".as_ref(), + account_pubkey.as_ref(), + s3.address.as_ref(), + ], + &mango_v4::ID, + ) + .0; + + open_orders + } + pub async fn serum3_create_open_orders(&self, name: &str) -> anyhow::Result { let market_index = self.context.serum3_market_index(name); let ix = self.serum3_create_open_orders_instruction(market_index); @@ -568,7 +755,7 @@ impl MangoClient { } #[allow(clippy::too_many_arguments)] - pub fn serum3_place_order_instruction( + pub async fn serum3_place_order_instruction( &self, account: &MangoAccountValue, market_index: Serum3MarketIndex, @@ -584,19 +771,21 @@ impl MangoClient { let s3 = self.context.serum3(market_index); let base = self.context.serum3_base_token(market_index); let quote = self.context.serum3_quote_token(market_index); - let open_orders = account - .serum3_orders(market_index) - .expect("oo is created") - .open_orders; + let (payer_token, receiver_token) = match side { + Serum3Side::Bid => ("e, &base), + Serum3Side::Ask => (&base, "e), + }; + + let open_orders = account.serum3_orders(market_index).map(|x| x.open_orders)?; let (health_check_metas, health_cu) = self - .context - .derive_health_check_remaining_account_metas(account, vec![], vec![], vec![])?; - - let payer_token = match side { - Serum3Side::Bid => "e, - Serum3Side::Ask => &base, - }; + .derive_health_check_remaining_account_metas( + &account, + vec![], + vec![receiver_token.token_index], + vec![], + ) + .await?; let ixs = PreparedInstructions::from_single( Instruction { @@ -629,7 +818,7 @@ impl MangoClient { ams }, data: anchor_lang::InstructionData::data( - &mango_v4::instruction::Serum3PlaceOrder { + &mango_v4::instruction::Serum3PlaceOrderV2 { side, limit_price, max_base_qty, @@ -648,6 +837,205 @@ impl MangoClient { Ok(ixs) } + #[allow(clippy::too_many_arguments)] + pub async fn serum3_create_or_replace_account_instruction( + &self, + mut account: &mut MangoAccountValue, + market_index: Serum3MarketIndex, + side: Serum3Side, + ) -> anyhow::Result { + let mut ixs = PreparedInstructions::new(); + + let base = self.context.serum3_base_token(market_index); + let quote = self.context.serum3_quote_token(market_index); + let (payer_token, receiver_token) = match side { + Serum3Side::Bid => ("e, &base), + Serum3Side::Ask => (&base, "e), + }; + + let open_orders_opt = account + .serum3_orders(market_index) + .map(|x| x.open_orders) + .ok(); + + let mut missing_tokens = false; + + let token_replace_ixs = self + .find_existing_or_try_to_replace_token_positions( + &mut account, + &[payer_token.token_index, receiver_token.token_index], + ) + .await; + match token_replace_ixs { + Ok(res) => { + ixs.append(res); + } + Err(_) => missing_tokens = true, + } + + if open_orders_opt.is_none() { + let has_available_slot = account.all_serum3_orders().any(|p| !p.is_active()); + let should_close_one_open_orders_account = !has_available_slot || missing_tokens; + + if should_close_one_open_orders_account { + ixs.append( + self.deactivate_first_active_unused_serum3_orders(&mut account) + .await?, + ); + } + + // in case of missing token slots + // try again to create, as maybe deactivating the market slot resulted in some token being now unused + // but this time, in case of error, propagate to caller + if missing_tokens { + ixs.append( + self.find_existing_or_try_to_replace_token_positions( + &mut account, + &[payer_token.token_index, receiver_token.token_index], + ) + .await?, + ); + } + + ixs.push( + self.serum3_create_open_orders_instruction(market_index), + self.context.compute_estimates.cu_per_mango_instruction, + ); + + let created_open_orders = self.serum3_create_open_orders_address(market_index); + + account.create_serum3_orders(market_index)?.open_orders = created_open_orders; + } + + Ok(ixs) + } + + async fn deactivate_first_active_unused_serum3_orders( + &self, + account: &mut MangoAccountValue, + ) -> anyhow::Result { + let mut serum3_closable_order_market_index = None; + + for p in account.all_serum3_orders() { + let open_orders_acc = self + .account_fetcher + .fetch_raw_account(&p.open_orders) + .await?; + let open_orders_bytes = open_orders_acc.data(); + let open_orders_data: &serum_dex::state::OpenOrders = bytemuck::from_bytes( + &open_orders_bytes[5..5 + std::mem::size_of::()], + ); + + let is_closable = open_orders_data.free_slot_bits == u128::MAX + && open_orders_data.native_coin_total == 0 + && open_orders_data.native_pc_total == 0; + + if is_closable { + serum3_closable_order_market_index = Some(p.market_index); + break; + } + } + + let first_closable_slot = + serum3_closable_order_market_index.expect("couldn't find any serum3 slot available"); + + let ixs = self.serum3_close_open_orders_instruction(first_closable_slot); + + let first_closable_market = account.serum3_orders(first_closable_slot)?; + let (tk1, tk2) = ( + first_closable_market.base_token_index, + first_closable_market.quote_token_index, + ); + account.token_position_mut(tk1)?.0.decrement_in_use(); + account.token_position_mut(tk2)?.0.decrement_in_use(); + account.deactivate_serum3_orders(first_closable_slot)?; + + Ok(ixs) + } + + async fn find_existing_or_try_to_replace_token_positions( + &self, + account: &mut MangoAccountValue, + token_indexes: &[TokenIndex], + ) -> anyhow::Result { + let mut ixs = PreparedInstructions::new(); + + for token_index in token_indexes { + let result = self + .find_existing_or_try_to_replace_token_position(account, *token_index) + .await?; + if let Some(ix) = result { + ixs.append(ix); + } + } + + Ok(ixs) + } + + async fn find_existing_or_try_to_replace_token_position( + &self, + account: &mut MangoAccountValue, + token_index: TokenIndex, + ) -> anyhow::Result> { + let token_position_missing = account + .ensure_token_position(token_index) + .is_anchor_error_with_code(MangoError::NoFreeTokenPositionIndex.error_code()); + + if !token_position_missing { + return Ok(None); + } + + let ixs = self.deactivate_first_active_unused_token(account).await?; + account.ensure_token_position(token_index)?; + + Ok(Some(ixs)) + } + + async fn deactivate_first_active_unused_token( + &self, + account: &mut MangoAccountValue, + ) -> anyhow::Result { + let closable_tokens = account + .all_token_positions() + .enumerate() + .filter(|(_, p)| p.is_active() && !p.is_in_use()); + + let mut closable_token_position_raw_index_opt = None; + let mut closable_token_bank_opt = None; + + for (closable_token_position_raw_index, closable_token_position) in closable_tokens { + let bank = self.first_bank(closable_token_position.token_index).await?; + let native_balance = closable_token_position.native(&bank); + + if native_balance < I80F48::ZERO { + continue; + } + if native_balance > I80F48::ONE { + continue; + } + + closable_token_position_raw_index_opt = Some(closable_token_position_raw_index); + closable_token_bank_opt = Some(bank); + break; + } + + if closable_token_bank_opt.is_none() { + return Err(AnchorError(MangoError::NoFreeTokenPositionIndex.into()).into()); + } + + let withdraw_ixs = self + .token_withdraw_instructions( + &account, + closable_token_bank_opt.unwrap().mint, + u64::MAX, + false, + ) + .await?; + + account.deactivate_token_position(closable_token_position_raw_index_opt.unwrap()); + return Ok(withdraw_ixs); + } + #[allow(clippy::too_many_arguments)] pub async fn serum3_place_order( &self, @@ -661,20 +1049,29 @@ impl MangoClient { client_order_id: u64, limit: u16, ) -> anyhow::Result { - let account = self.mango_account().await?; + let mut account = self.mango_account().await?.clone(); let market_index = self.context.serum3_market_index(name); - let ixs = self.serum3_place_order_instruction( - &account, - market_index, - side, - limit_price, - max_base_qty, - max_native_quote_qty_including_fees, - self_trade_behavior, - order_type, - client_order_id, - limit, - )?; + let create_or_replace_ixs = self + .serum3_create_or_replace_account_instruction(&mut account, market_index, side) + .await?; + let place_order_ixs = self + .serum3_place_order_instruction( + &account, + market_index, + side, + limit_price, + max_base_qty, + max_native_quote_qty_including_fees, + self_trade_behavior, + order_type, + client_order_id, + limit, + ) + .await?; + + let mut ixs = PreparedInstructions::new(); + ixs.append(create_or_replace_ixs); + ixs.append(place_order_ixs); self.send_and_confirm_owner_tx(ixs.to_instructions()).await } @@ -791,23 +1188,22 @@ impl MangoClient { Ok(orders) } - pub async fn serum3_liq_force_cancel_orders( + pub async fn serum3_liq_force_cancel_orders_instruction( &self, liqee: (&Pubkey, &MangoAccountValue), market_index: Serum3MarketIndex, open_orders: &Pubkey, - ) -> anyhow::Result { + ) -> anyhow::Result { let s3 = self.context.serum3(market_index); let base = self.context.serum3_base_token(market_index); let quote = self.context.serum3_quote_token(market_index); - let (health_remaining_ams, health_cu) = self - .context .derive_health_check_remaining_account_metas(liqee.1, vec![], vec![], vec![]) + .await .unwrap(); let limit = 5; - let ixs = PreparedInstructions::from_single( + let ix = PreparedInstructions::from_single( Instruction { program_id: mango_v4::id(), accounts: { @@ -843,6 +1239,18 @@ impl MangoClient { self.instruction_cu(health_cu) + self.context.compute_estimates.cu_per_serum3_order_cancel * limit as u32, ); + Ok(ix) + } + + pub async fn serum3_liq_force_cancel_orders( + &self, + liqee: (&Pubkey, &MangoAccountValue), + market_index: Serum3MarketIndex, + open_orders: &Pubkey, + ) -> anyhow::Result { + let ixs = self + .serum3_liq_force_cancel_orders_instruction(liqee, market_index, open_orders) + .await?; self.send_and_confirm_permissionless_tx(ixs.to_instructions()) .await } @@ -891,7 +1299,7 @@ impl MangoClient { // #[allow(clippy::too_many_arguments)] - pub fn perp_place_order_instruction( + pub async fn perp_place_order_instruction( &self, account: &MangoAccountValue, market_index: PerpMarketIndex, @@ -906,50 +1314,64 @@ impl MangoClient { limit: u8, self_trade_behavior: SelfTradeBehavior, ) -> anyhow::Result { + let mut ixs = PreparedInstructions::new(); + let perp = self.context.perp(market_index); - let (health_remaining_metas, health_cu) = - self.context.derive_health_check_remaining_account_metas( - account, + let mut account = account.clone(); + + let close_perp_ixs_opt = self + .replace_perp_market_if_needed(&account, market_index) + .await?; + + if let Some((close_perp_ixs, modified_account)) = close_perp_ixs_opt { + account = modified_account; + ixs.append(close_perp_ixs); + } + + let (health_remaining_metas, health_cu) = self + .derive_health_check_remaining_account_metas( + &account, vec![], vec![], vec![market_index], - )?; + ) + .await?; - let ixs = PreparedInstructions::from_single( - Instruction { - program_id: mango_v4::id(), - accounts: { - let mut ams = anchor_lang::ToAccountMetas::to_account_metas( - &mango_v4::accounts::PerpPlaceOrder { - group: self.group(), - account: self.mango_account_address, - owner: self.owner(), - perp_market: perp.address, - bids: perp.bids, - asks: perp.asks, - event_queue: perp.event_queue, - oracle: perp.oracle, - }, - None, - ); - ams.extend(health_remaining_metas.into_iter()); - ams - }, - data: anchor_lang::InstructionData::data( - &mango_v4::instruction::PerpPlaceOrderV2 { - side, - price_lots, - max_base_lots, - max_quote_lots, - client_order_id, - order_type, - reduce_only, - expiry_timestamp, - limit, - self_trade_behavior, + let ix = Instruction { + program_id: mango_v4::id(), + accounts: { + let mut ams = anchor_lang::ToAccountMetas::to_account_metas( + &mango_v4::accounts::PerpPlaceOrder { + group: self.group(), + account: self.mango_account_address, + owner: self.owner(), + perp_market: perp.address, + bids: perp.bids, + asks: perp.asks, + event_queue: perp.event_queue, + oracle: perp.oracle, }, - ), + None, + ); + ams.extend(health_remaining_metas.into_iter()); + ams }, + data: anchor_lang::InstructionData::data(&mango_v4::instruction::PerpPlaceOrderV2 { + side, + price_lots, + max_base_lots, + max_quote_lots, + client_order_id, + order_type, + reduce_only, + expiry_timestamp, + limit, + self_trade_behavior, + }), + }; + + ixs.push( + ix, self.instruction_cu(health_cu) + self.context.compute_estimates.cu_per_perp_order_match * limit as u32, ); @@ -957,6 +1379,44 @@ impl MangoClient { Ok(ixs) } + async fn replace_perp_market_if_needed( + &self, + account: &MangoAccountValue, + perk_market_index: PerpMarketIndex, + ) -> anyhow::Result> { + let context = &self.context; + let settle_token_index = context.perp(perk_market_index).settle_token_index; + + let mut account = account.clone(); + let enforce_position_result = + account.ensure_perp_position(perk_market_index, settle_token_index); + + if !enforce_position_result + .is_anchor_error_with_code(MangoError::NoFreePerpPositionIndex.error_code()) + { + return Ok(None); + } + + let perp_position_to_close_opt = account.find_first_active_unused_perp_position(); + match perp_position_to_close_opt { + Some(perp_position_to_close) => { + let close_ix = self + .perp_deactivate_position_instruction(perp_position_to_close.market_index) + .await?; + + let previous_market = context.perp(perp_position_to_close.market_index); + account.deactivate_perp_position( + perp_position_to_close.market_index, + previous_market.settle_token_index, + )?; + account.ensure_perp_position(perk_market_index, settle_token_index)?; + + Ok(Some((close_ix, account))) + } + None => anyhow::bail!("No perp market slot available"), + } + } + #[allow(clippy::too_many_arguments)] pub async fn perp_place_order( &self, @@ -973,20 +1433,22 @@ impl MangoClient { self_trade_behavior: SelfTradeBehavior, ) -> anyhow::Result { let account = self.mango_account().await?; - let ixs = self.perp_place_order_instruction( - &account, - market_index, - side, - price_lots, - max_base_lots, - max_quote_lots, - client_order_id, - order_type, - reduce_only, - expiry_timestamp, - limit, - self_trade_behavior, - )?; + let ixs = self + .perp_place_order_instruction( + &account, + market_index, + side, + price_lots, + max_base_lots, + max_quote_lots, + client_order_id, + order_type, + reduce_only, + expiry_timestamp, + limit, + self_trade_behavior, + ) + .await?; self.send_and_confirm_owner_tx(ixs.to_instructions()).await } @@ -1027,17 +1489,23 @@ impl MangoClient { &self, market_index: PerpMarketIndex, ) -> anyhow::Result { - let perp = self.context.perp(market_index); - - let (health_check_metas, health_cu) = self - .derive_health_check_remaining_account_metas(vec![], vec![], vec![]) + let ixs = self + .perp_deactivate_position_instruction(market_index) .await?; + self.send_and_confirm_owner_tx(ixs.to_instructions()).await + } + + async fn perp_deactivate_position_instruction( + &self, + market_index: PerpMarketIndex, + ) -> anyhow::Result { + let perp = self.context.perp(market_index); let ixs = PreparedInstructions::from_single( Instruction { program_id: mango_v4::id(), accounts: { - let mut ams = anchor_lang::ToAccountMetas::to_account_metas( + let ams = anchor_lang::ToAccountMetas::to_account_metas( &mango_v4::accounts::PerpDeactivatePosition { group: self.group(), account: self.mango_account_address, @@ -1046,19 +1514,18 @@ impl MangoClient { }, None, ); - ams.extend(health_check_metas.into_iter()); ams }, data: anchor_lang::InstructionData::data( &mango_v4::instruction::PerpDeactivatePosition {}, ), }, - self.instruction_cu(health_cu), + self.context.compute_estimates.cu_per_mango_instruction, ); - self.send_and_confirm_owner_tx(ixs.to_instructions()).await + Ok(ixs) } - pub fn perp_settle_pnl_instruction( + pub async fn perp_settle_pnl_instruction( &self, market_index: PerpMarketIndex, account_a: (&Pubkey, &MangoAccountValue), @@ -1068,13 +1535,13 @@ impl MangoClient { let settlement_token = self.context.token(perp.settle_token_index); let (health_remaining_ams, health_cu) = self - .context .derive_health_check_remaining_account_metas_two_accounts( account_a.1, account_b.1, &[], &[], ) + .await .unwrap(); let ixs = PreparedInstructions::from_single( @@ -1111,7 +1578,9 @@ impl MangoClient { account_a: (&Pubkey, &MangoAccountValue), account_b: (&Pubkey, &MangoAccountValue), ) -> anyhow::Result { - let ixs = self.perp_settle_pnl_instruction(market_index, account_a, account_b)?; + let ixs = self + .perp_settle_pnl_instruction(market_index, account_a, account_b) + .await?; self.send_and_confirm_permissionless_tx(ixs.to_instructions()) .await } @@ -1124,8 +1593,8 @@ impl MangoClient { let perp = self.context.perp(market_index); let (health_remaining_ams, health_cu) = self - .context .derive_health_check_remaining_account_metas(liqee.1, vec![], vec![], vec![]) + .await .unwrap(); let limit = 5; @@ -1166,9 +1635,15 @@ impl MangoClient { ) -> anyhow::Result { let perp = self.context.perp(market_index); let settle_token_info = self.context.token(perp.settle_token_index); + let mango_account = &self.mango_account().await?; let (health_remaining_ams, health_cu) = self - .derive_liquidation_health_check_remaining_account_metas(liqee.1, &[], &[]) + .derive_health_check_remaining_account_metas_two_accounts( + mango_account, + liqee.1, + &[], + &[], + ) .await .unwrap(); @@ -1217,12 +1692,14 @@ impl MangoClient { ) .await?; + let mango_account = &self.mango_account().await?; let perp = self.context.perp(market_index); let settle_token_info = self.context.token(perp.settle_token_index); let insurance_token_info = self.context.token(INSURANCE_TOKEN_INDEX); let (health_remaining_ams, health_cu) = self - .derive_liquidation_health_check_remaining_account_metas( + .derive_health_check_remaining_account_metas_two_accounts( + mango_account, liqee.1, &[INSURANCE_TOKEN_INDEX], &[], @@ -1265,6 +1742,58 @@ impl MangoClient { )) } + pub async fn token_charge_collateral_fees_instruction( + &self, + account: (&Pubkey, &MangoAccountValue), + ) -> anyhow::Result { + let (mut health_remaining_ams, health_cu) = self + .derive_health_check_remaining_account_metas(account.1, vec![], vec![], vec![]) + .await + .unwrap(); + + // The instruction requires mutable banks + for am in &mut health_remaining_ams[0..account.1.active_token_positions().count()] { + am.is_writable = true; + } + + let ix = Instruction { + program_id: mango_v4::id(), + accounts: { + let mut ams = anchor_lang::ToAccountMetas::to_account_metas( + &mango_v4::accounts::TokenChargeCollateralFees { + group: self.group(), + account: *account.0, + }, + None, + ); + ams.extend(health_remaining_ams); + ams + }, + data: anchor_lang::InstructionData::data( + &mango_v4::instruction::TokenChargeCollateralFees {}, + ), + }; + + let mut chargeable_token_positions = 0; + for tp in account.1.active_token_positions() { + let bank = self.first_bank(tp.token_index).await?; + let native = tp.native(&bank); + if native.is_positive() + && bank.maint_asset_weight.is_positive() + && bank.collateral_fee_per_day > 0.0 + { + chargeable_token_positions += 1; + } + } + + let cu_est = &self.context.compute_estimates; + let cu = cu_est.cu_per_charge_collateral_fees + + cu_est.cu_per_charge_collateral_fees_token * chargeable_token_positions + + health_cu; + + Ok(PreparedInstructions::from_single(ix, cu)) + } + // // Liquidation // @@ -1276,8 +1805,10 @@ impl MangoClient { liab_token_index: TokenIndex, max_liab_transfer: I80F48, ) -> anyhow::Result { + let mango_account = &self.mango_account().await?; let (health_remaining_ams, health_cu) = self - .derive_liquidation_health_check_remaining_account_metas( + .derive_health_check_remaining_account_metas_two_accounts( + mango_account, liqee.1, &[], &[asset_token_index, liab_token_index], @@ -1318,6 +1849,7 @@ impl MangoClient { liab_token_index: TokenIndex, max_liab_transfer: I80F48, ) -> anyhow::Result { + let mango_account = &self.mango_account().await?; let quote_token_index = 0; let quote_info = self.context.token(quote_token_index); @@ -1330,7 +1862,8 @@ impl MangoClient { .collect::>(); let (health_remaining_ams, health_cu) = self - .derive_liquidation_health_check_remaining_account_metas( + .derive_health_check_remaining_account_metas_two_accounts( + mango_account, liqee.1, &[INSURANCE_TOKEN_INDEX], &[quote_token_index, liab_token_index], @@ -1384,6 +1917,7 @@ impl MangoClient { min_taker_price: f32, extra_affected_tokens: &[TokenIndex], ) -> anyhow::Result { + let mango_account = &self.mango_account().await?; let (tcs_index, tcs) = liqee .1 .token_conditional_swap_by_id(token_conditional_swap_id)?; @@ -1394,7 +1928,8 @@ impl MangoClient { .copied() .collect_vec(); let (health_remaining_ams, health_cu) = self - .derive_liquidation_health_check_remaining_account_metas( + .derive_health_check_remaining_account_metas_two_accounts( + mango_account, liqee.1, &affected_tokens, &[tcs.buy_token_index, tcs.sell_token_index], @@ -1445,8 +1980,8 @@ impl MangoClient { let affected_tokens = vec![tcs.buy_token_index, tcs.sell_token_index]; let (health_remaining_ams, health_cu) = self - .context .derive_health_check_remaining_account_metas(account.1, vec![], affected_tokens, vec![]) + .await .unwrap(); let ix = Instruction { @@ -1479,20 +2014,21 @@ impl MangoClient { // health region - pub fn health_region_begin_instruction( + pub async fn health_region_begin_instruction( &self, account: &MangoAccountValue, affected_tokens: Vec, writable_banks: Vec, affected_perp_markets: Vec, ) -> anyhow::Result { - let (health_remaining_metas, _health_cu) = - self.context.derive_health_check_remaining_account_metas( + let (health_remaining_metas, _health_cu) = self + .derive_health_check_remaining_account_metas( account, affected_tokens, writable_banks, affected_perp_markets, - )?; + ) + .await?; let ix = Instruction { program_id: mango_v4::id(), @@ -1518,20 +2054,21 @@ impl MangoClient { )) } - pub fn health_region_end_instruction( + pub async fn health_region_end_instruction( &self, account: &MangoAccountValue, affected_tokens: Vec, writable_banks: Vec, affected_perp_markets: Vec, ) -> anyhow::Result { - let (health_remaining_metas, health_cu) = - self.context.derive_health_check_remaining_account_metas( + let (health_remaining_metas, health_cu) = self + .derive_health_check_remaining_account_metas( account, affected_tokens, writable_banks, affected_perp_markets, - )?; + ) + .await?; let ix = Instruction { program_id: mango_v4::id(), @@ -1556,10 +2093,6 @@ impl MangoClient { // jupiter - pub fn jupiter_v4(&self) -> jupiter::v4::JupiterV4 { - jupiter::v4::JupiterV4 { mango_client: self } - } - pub fn jupiter_v6(&self) -> jupiter::v6::JupiterV6 { jupiter::v6::JupiterV6 { mango_client: self } } @@ -1602,54 +2135,6 @@ impl MangoClient { .await } - 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)) - } - fn instruction_cu(&self, health_cu: u32) -> u32 { self.context.compute_estimates.cu_per_mango_instruction + health_cu } @@ -1658,34 +2143,37 @@ impl MangoClient { &self, instructions: Vec, ) -> anyhow::Result { - let fee_payer = self.client.fee_payer(); - TransactionBuilder { + let mut tx_builder = TransactionBuilder { instructions, - address_lookup_tables: self.mango_address_lookup_tables().await?, - payer: fee_payer.pubkey(), - signers: vec![self.owner.clone(), fee_payer], - config: self.client.transaction_builder_config, - } - .send_and_confirm(&self.client) - .await + ..self.transaction_builder().await? + }; + tx_builder.signers.push(self.owner.clone()); + tx_builder.send_and_confirm(&self.client).await } pub async fn send_and_confirm_permissionless_tx( &self, instructions: Vec, ) -> anyhow::Result { - let fee_payer = self.client.fee_payer(); TransactionBuilder { instructions, - address_lookup_tables: self.mango_address_lookup_tables().await?, - payer: fee_payer.pubkey(), - signers: vec![fee_payer], - config: self.client.transaction_builder_config, + ..self.transaction_builder().await? } .send_and_confirm(&self.client) .await } + pub async fn transaction_builder(&self) -> anyhow::Result { + let fee_payer = self.client.fee_payer(); + Ok(TransactionBuilder { + instructions: vec![], + address_lookup_tables: self.mango_address_lookup_tables().await?, + payer: fee_payer.pubkey(), + signers: vec![fee_payer], + config: self.client.config.transaction_builder_config.clone(), + }) + } + pub async fn simulate( &self, instructions: Vec, @@ -1696,7 +2184,7 @@ impl MangoClient { address_lookup_tables: vec![], payer: fee_payer.pubkey(), signers: vec![fee_payer], - config: self.client.transaction_builder_config, + config: self.client.config.transaction_builder_config.clone(), } .simulate(&self.client) .await @@ -1715,7 +2203,7 @@ impl MangoClient { match MangoGroupContext::new_from_rpc(&rpc_async, mango_client.group()).await { Ok(v) => v, Err(e) => { - tracing::warn!("could not fetch context to check for changes: {e:?}"); + warn!("could not fetch context to check for changes: {e:?}"); continue; } }; @@ -1730,7 +2218,7 @@ impl MangoClient { #[derive(Debug, thiserror::Error)] pub enum MangoClientError { #[error("Transaction simulation error. Error: {err:?}, Logs: {}", - .logs.iter().join("; ") + .logs.iter().join("; ") )] SendTransactionPreflightFailure { err: Option, @@ -1745,7 +2233,7 @@ pub struct TransactionSize { } impl TransactionSize { - pub fn is_ok(&self) -> bool { + pub fn is_within_limit(&self) -> bool { let limit = Self::limit(); self.length <= limit.length && self.accounts <= limit.accounts } @@ -1758,14 +2246,48 @@ impl TransactionSize { } } -#[derive(Copy, Clone, Debug, Default)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FallbackOracleConfig { + /// No fallback oracles + Never, + /// Only provided fallback oracles are used + Fixed(Vec), + /// The account_fetcher checks for stale oracles and uses fallbacks only for stale oracles + Dynamic, + /// Every possible fallback oracle (may cause serious issues with the 64 accounts-per-tx limit) + All, +} + +impl Default for FallbackOracleConfig { + fn default() -> Self { + FallbackOracleConfig::Dynamic + } +} + +#[derive(Clone, Default, Builder)] pub struct TransactionBuilderConfig { - // adds a SetComputeUnitPrice instruction in front if none exists - pub prioritization_micro_lamports: Option, - // adds a SetComputeUnitBudget instruction if none exists + /// adds a SetComputeUnitPrice instruction in front if none exists + pub priority_fee_provider: Option>, + /// adds a SetComputeUnitBudget instruction if none exists pub compute_budget_per_instruction: Option, } +impl TransactionBuilderConfig { + pub fn builder() -> TransactionBuilderConfigBuilder { + TransactionBuilderConfigBuilder::default() + } +} + +impl TransactionBuilderConfigBuilder { + pub fn prioritization_micro_lamports(&mut self, cu: Option) -> &mut Self { + self.priority_fee_provider( + cu.map(|cu| { + Arc::new(FixedPriorityFeeProvider::new(cu)) as Arc + }), + ) + } +} + pub struct TransactionBuilder { pub instructions: Vec, pub address_lookup_tables: Vec, @@ -1819,7 +2341,12 @@ impl TransactionBuilder { ); } - let cu_prio = self.config.prioritization_micro_lamports.unwrap_or(0); + let cu_prio = self + .config + .priority_fee_provider + .as_ref() + .map(|provider| provider.compute_unit_fee_microlamports()) + .unwrap_or(0); if !has_compute_unit_price && cu_prio > 0 { ixs.insert(0, ComputeBudgetInstruction::set_compute_unit_price(cu_prio)); } @@ -1855,9 +2382,7 @@ impl TransactionBuilder { pub async fn send(&self, client: &Client) -> anyhow::Result { let rpc = client.rpc_async(); let tx = self.transaction(&rpc).await?; - rpc.send_transaction_with_config(&tx, client.rpc_send_transaction_config) - .await - .map_err(prettify_solana_client_error) + client.send_transaction(&tx).await } pub async fn simulate(&self, client: &Client) -> anyhow::Result { @@ -1869,10 +2394,16 @@ impl TransactionBuilder { pub async fn send_and_confirm(&self, client: &Client) -> anyhow::Result { let rpc = client.rpc_async(); let tx = self.transaction(&rpc).await?; - // TODO: Wish we could use client.rpc_send_transaction_config here too! - rpc.send_and_confirm_transaction(&tx) - .await - .map_err(prettify_solana_client_error) + let recent_blockhash = tx.message.recent_blockhash(); + let signature = client.send_transaction(&tx).await?; + wait_for_transaction_confirmation( + &rpc, + &signature, + recent_blockhash, + &client.config.rpc_confirm_transaction_config, + ) + .await?; + Ok(signature) } pub fn transaction_size(&self) -> anyhow::Result { diff --git a/lib/client/src/confirm_transaction.rs b/lib/client/src/confirm_transaction.rs new file mode 100644 index 000000000..60231e2f0 --- /dev/null +++ b/lib/client/src/confirm_transaction.rs @@ -0,0 +1,117 @@ +use solana_client::nonblocking::rpc_client::RpcClient as RpcClientAsync; +use solana_client::rpc_request::RpcError; +use solana_sdk::{commitment_config::CommitmentConfig, signature::Signature}; +use solana_transaction_status::TransactionStatus; + +use crate::util::delay_interval; +use std::time::Duration; + +#[derive(thiserror::Error, Debug)] +pub enum WaitForTransactionConfirmationError { + #[error("blockhash has expired")] + BlockhashExpired, + #[error("timeout expired")] + Timeout, + #[error("client error: {0:?}")] + ClientError(#[from] solana_client::client_error::ClientError), +} + +#[derive(Clone, Debug, Builder)] +#[builder(default)] +pub struct RpcConfirmTransactionConfig { + /// If none, defaults to the RpcClient's configured default commitment. + pub commitment: Option, + + /// Time after which to start checking for blockhash expiry. + pub recent_blockhash_initial_timeout: Duration, + + /// Interval between signature status queries. + pub signature_status_interval: Duration, + + /// If none, there's no timeout. The confirmation will still abort eventually + /// when the blockhash expires. + pub timeout: Option, +} + +impl Default for RpcConfirmTransactionConfig { + fn default() -> Self { + Self { + commitment: None, + recent_blockhash_initial_timeout: Duration::from_secs(5), + signature_status_interval: Duration::from_millis(500), + timeout: None, + } + } +} + +impl RpcConfirmTransactionConfig { + pub fn builder() -> RpcConfirmTransactionConfigBuilder { + RpcConfirmTransactionConfigBuilder::default() + } +} + +/// Wait for `signature` to be confirmed at `commitment` or until either +/// - `recent_blockhash` is so old that the tx can't be confirmed _and_ +/// `blockhash_initial_timeout` is reached +/// - the `signature_status_timeout` is reached +/// While waiting, query for confirmation every `signature_status_interval` +/// +/// NOTE: RpcClient::config contains confirm_transaction_initial_timeout which is the +/// same as blockhash_initial_timeout. Unfortunately the former is private. +/// +/// Returns: +/// - blockhash and blockhash_initial_timeout expired -> BlockhashExpired error +/// - signature_status_timeout expired -> Timeout error (possibly just didn't reach commitment in time?) +/// - any rpc error -> ClientError error +/// - confirmed at commitment -> ok(slot, opt) +pub async fn wait_for_transaction_confirmation( + rpc_client: &RpcClientAsync, + signature: &Signature, + recent_blockhash: &solana_sdk::hash::Hash, + config: &RpcConfirmTransactionConfig, +) -> Result { + let mut signature_status_interval = delay_interval(config.signature_status_interval); + let commitment = config.commitment.unwrap_or(rpc_client.commitment()); + + let start = std::time::Instant::now(); + let is_timed_out = || config.timeout.map(|t| start.elapsed() > t).unwrap_or(false); + loop { + signature_status_interval.tick().await; + if is_timed_out() { + return Err(WaitForTransactionConfirmationError::Timeout); + } + + let statuses = rpc_client + .get_signature_statuses(&[signature.clone()]) + .await?; + let status_opt = match statuses.value.into_iter().next() { + Some(v) => v, + None => { + return Err(WaitForTransactionConfirmationError::ClientError( + RpcError::ParseError( + "must contain an entry for each requested signature".into(), + ) + .into(), + )); + } + }; + + // If the tx isn't seen at all (not even processed), check blockhash expiry + if status_opt.is_none() { + if start.elapsed() > config.recent_blockhash_initial_timeout { + let blockhash_is_valid = rpc_client + .is_blockhash_valid(recent_blockhash, CommitmentConfig::processed()) + .await?; + if !blockhash_is_valid { + return Err(WaitForTransactionConfirmationError::BlockhashExpired); + } + } + continue; + } + + let status = status_opt.unwrap(); + if status.satisfies_commitment(commitment) { + return Ok(status); + } + } +} diff --git a/lib/client/src/context.rs b/lib/client/src/context.rs index fca1afcf5..3ad362226 100644 --- a/lib/client/src/context.rs +++ b/lib/client/src/context.rs @@ -4,15 +4,20 @@ use anchor_client::ClientError; use anchor_lang::__private::bytemuck; -use mango_v4::state::{ - Group, MangoAccountValue, PerpMarketIndex, Serum3MarketIndex, TokenIndex, MAX_BANKS, +use mango_v4::{ + accounts_zerocopy::{KeyedAccountReader, KeyedAccountSharedData}, + state::{ + determine_oracle_type, load_whirlpool_state, oracle_state_unchecked, Group, + MangoAccountValue, OracleAccountInfos, OracleConfig, OracleConfigParams, OracleType, + PerpMarketIndex, Serum3MarketIndex, TokenIndex, MAX_BANKS, + }, }; use fixed::types::I80F48; use futures::{stream, StreamExt, TryStreamExt}; use itertools::Itertools; -use crate::gpa::*; +use crate::{gpa::*, AccountFetcher, FallbackOracleConfig}; use solana_client::nonblocking::rpc_client::RpcClient as RpcClientAsync; use solana_sdk::account::Account; @@ -28,9 +33,10 @@ pub struct TokenContext { pub oracle: Pubkey, pub banks: [Pubkey; MAX_BANKS], pub vaults: [Pubkey; MAX_BANKS], - pub fallback_oracle: Pubkey, + pub fallback_context: FallbackOracleContext, pub mint_info_address: Pubkey, pub decimals: u8, + pub oracle_config: OracleConfig, } impl TokenContext { @@ -56,6 +62,18 @@ impl TokenContext { } } +#[derive(Clone, PartialEq, Eq)] +pub struct FallbackOracleContext { + pub key: Pubkey, + // only used for CLMM fallback oracles, otherwise Pubkey::default + pub quote_key: Pubkey, +} +impl FallbackOracleContext { + pub fn keys(&self) -> Vec { + vec![self.key, self.quote_key] + } +} + #[derive(Clone, PartialEq, Eq)] pub struct Serum3MarketContext { pub address: Pubkey, @@ -101,6 +119,9 @@ pub struct ComputeEstimates { pub cu_per_serum3_order_cancel: u32, pub cu_per_perp_order_match: u32, pub cu_per_perp_order_cancel: u32, + pub cu_per_oracle_fallback: u32, + pub cu_per_charge_collateral_fees: u32, + pub cu_per_charge_collateral_fees_token: u32, } impl Default for ComputeEstimates { @@ -118,25 +139,40 @@ impl Default for ComputeEstimates { cu_per_perp_order_match: 7_000, // measured around 3.5k, see test_perp_compute cu_per_perp_order_cancel: 7_000, + // measured around 2k, see test_health_compute_tokens_fallback_oracles + cu_per_oracle_fallback: 2000, + // the base cost is mostly the division + cu_per_charge_collateral_fees: 20_000, + // per-chargable-token cost + cu_per_charge_collateral_fees_token: 15_000, } } } impl ComputeEstimates { - pub fn health_for_counts(&self, tokens: usize, perps: usize, serums: usize) -> u32 { + pub fn health_for_counts( + &self, + tokens: usize, + perps: usize, + serums: usize, + fallbacks: usize, + ) -> u32 { let tokens: u32 = tokens.try_into().unwrap(); let perps: u32 = perps.try_into().unwrap(); let serums: u32 = serums.try_into().unwrap(); + let fallbacks: u32 = fallbacks.try_into().unwrap(); tokens * self.health_cu_per_token + perps * self.health_cu_per_perp + serums * self.health_cu_per_serum + + fallbacks * self.cu_per_oracle_fallback } - pub fn health_for_account(&self, account: &MangoAccountValue) -> u32 { + pub fn health_for_account(&self, account: &MangoAccountValue, num_fallbacks: usize) -> u32 { self.health_for_counts( account.active_token_positions().count(), account.active_perp_positions().count(), account.active_serum3_orders().count(), + num_fallbacks, ) } } @@ -227,8 +263,12 @@ impl MangoGroupContext { decimals: u8::MAX, banks: mi.banks, vaults: mi.vaults, - fallback_oracle: mi.fallback_oracle, oracle: mi.oracle, + fallback_context: FallbackOracleContext { + key: mi.fallback_oracle, + quote_key: Pubkey::default(), + }, + oracle_config: OracleConfigParams::default().to_oracle_config(), group: mi.group, mint: mi.mint, }, @@ -236,14 +276,23 @@ impl MangoGroupContext { }) .collect::>(); - // reading the banks is only needed for the token names and decimals + // reading the banks is only needed for the token names, decimals and oracle configs // FUTURE: either store the names on MintInfo as well, or maybe don't store them at all // because they are in metaplex? let bank_tuples = fetch_banks(rpc, program, group).await?; - for (_, bank) in bank_tuples { + let fallback_keys: Vec = bank_tuples + .iter() + .map(|tup| tup.1.fallback_oracle) + .collect(); + let fallback_oracle_accounts = fetch_multiple_accounts(rpc, &fallback_keys[..]).await?; + for (index, (_, bank)) in bank_tuples.iter().enumerate() { let token = tokens.get_mut(&bank.token_index).unwrap(); token.name = bank.name().into(); token.decimals = bank.mint_decimals; + token.oracle_config = bank.oracle_config; + let (key, acc_info) = fallback_oracle_accounts[index].clone(); + token.fallback_context.quote_key = + get_fallback_quote_key(&KeyedAccountSharedData::new(key, acc_info)); } assert!(tokens.values().all(|t| t.decimals != u8::MAX)); @@ -357,6 +406,7 @@ impl MangoGroupContext { affected_tokens: Vec, writable_banks: Vec, affected_perp_markets: Vec, + fallback_contexts: HashMap, ) -> anyhow::Result<(Vec, u32)> { let mut account = account.clone(); for affected_token_index in affected_tokens.iter().chain(writable_banks.iter()) { @@ -370,6 +420,7 @@ impl MangoGroupContext { // figure out all the banks/oracles that need to be passed for the health check let mut banks = vec![]; let mut oracles = vec![]; + let mut fallbacks = vec![]; for position in account.active_token_positions() { let token = self.token(position.token_index); banks.push(( @@ -377,6 +428,9 @@ impl MangoGroupContext { writable_banks.iter().any(|&ti| ti == position.token_index), )); oracles.push(token.oracle); + if let Some(fallback_context) = fallback_contexts.get(&token.oracle) { + fallbacks.extend(fallback_context.keys()); + } } let serum_oos = account.active_serum3_orders().map(|&s| s.open_orders); @@ -386,6 +440,14 @@ impl MangoGroupContext { let perp_oracles = account .active_perp_positions() .map(|&pa| self.perp(pa.market_index).oracle); + // FUTURE: implement fallback oracles for perps + + let fallback_oracles: Vec = fallbacks + .into_iter() + .unique() + .filter(|key| !oracles.contains(key) && key != &Pubkey::default()) + .collect(); + let fallbacks_len = fallback_oracles.len(); let to_account_meta = |pubkey| AccountMeta { pubkey, @@ -404,9 +466,12 @@ impl MangoGroupContext { .chain(perp_markets.map(to_account_meta)) .chain(perp_oracles.map(to_account_meta)) .chain(serum_oos.map(to_account_meta)) + .chain(fallback_oracles.into_iter().map(to_account_meta)) .collect(); - let cu = self.compute_estimates.health_for_account(&account); + let cu = self + .compute_estimates + .health_for_account(&account, fallbacks_len); Ok((accounts, cu)) } @@ -417,10 +482,12 @@ impl MangoGroupContext { account2: &MangoAccountValue, affected_tokens: &[TokenIndex], writable_banks: &[TokenIndex], + fallback_contexts: HashMap, ) -> anyhow::Result<(Vec, u32)> { // figure out all the banks/oracles that need to be passed for the health check let mut banks = vec![]; let mut oracles = vec![]; + let mut fallbacks = vec![]; let token_indexes = account2 .active_token_positions() @@ -434,6 +501,9 @@ impl MangoGroupContext { let writable_bank = writable_banks.iter().contains(&token_index); banks.push((token.first_bank(), writable_bank)); oracles.push(token.oracle); + if let Some(fallback_context) = fallback_contexts.get(&token.oracle) { + fallbacks.extend(fallback_context.keys()); + } } let serum_oos = account2 @@ -452,6 +522,14 @@ impl MangoGroupContext { let perp_oracles = perp_market_indexes .iter() .map(|&index| self.perp(index).oracle); + // FUTURE: implement fallback oracles for perps + + let fallback_oracles: Vec = fallbacks + .into_iter() + .unique() + .filter(|key| !oracles.contains(key) && key != &Pubkey::default()) + .collect(); + let fallbacks_len = fallback_oracles.len(); let to_account_meta = |pubkey| AccountMeta { pubkey, @@ -470,6 +548,7 @@ impl MangoGroupContext { .chain(perp_markets.map(to_account_meta)) .chain(perp_oracles.map(to_account_meta)) .chain(serum_oos.map(to_account_meta)) + .chain(fallback_oracles.into_iter().map(to_account_meta)) .collect(); // Since health is likely to be computed separately for both accounts, we don't use the @@ -490,10 +569,12 @@ impl MangoGroupContext { account1_token_count, account1.active_perp_positions().count(), account1.active_serum3_orders().count(), + fallbacks_len, ) + self.compute_estimates.health_for_counts( account2_token_count, account2.active_perp_positions().count(), account2.active_serum3_orders().count(), + fallbacks_len, ); Ok((accounts, cu)) @@ -554,6 +635,61 @@ impl MangoGroupContext { let new_perp_markets = fetch_perp_markets(rpc, mango_v4::id(), self.group).await?; Ok(new_perp_markets.len() > self.perp_markets.len()) } + + /// Returns a map of oracle pubkey -> FallbackOracleContext + pub async fn derive_fallback_oracle_keys( + &self, + fallback_oracle_config: &FallbackOracleConfig, + account_fetcher: &dyn AccountFetcher, + ) -> anyhow::Result> { + // FUTURE: implement for perp oracles as well + let fallbacks_by_oracle = match fallback_oracle_config { + FallbackOracleConfig::Never => HashMap::new(), + FallbackOracleConfig::Fixed(keys) => self + .tokens + .iter() + .filter(|token| { + token.1.fallback_context.key != Pubkey::default() + && keys.contains(&token.1.fallback_context.key) + }) + .map(|t| (t.1.oracle, t.1.fallback_context.clone())) + .collect(), + FallbackOracleConfig::All => self + .tokens + .iter() + .filter(|token| token.1.fallback_context.key != Pubkey::default()) + .map(|t| (t.1.oracle, t.1.fallback_context.clone())) + .collect(), + FallbackOracleConfig::Dynamic => { + let tokens_by_oracle: HashMap = + self.tokens.iter().map(|t| (t.1.oracle, t.1)).collect(); + let oracle_keys: Vec = + tokens_by_oracle.values().map(|b| b.oracle).collect(); + let oracle_accounts = account_fetcher + .fetch_multiple_accounts(&oracle_keys) + .await?; + let now_slot = account_fetcher.get_slot().await?; + + let mut stale_oracles_with_fallbacks = vec![]; + for (key, acc) in oracle_accounts { + let token = tokens_by_oracle.get(&key).unwrap(); + let state = oracle_state_unchecked( + &OracleAccountInfos::from_reader(&KeyedAccountSharedData::new(key, acc)), + token.decimals, + )?; + let oracle_is_valid = state + .check_confidence_and_maybe_staleness(&token.oracle_config, Some(now_slot)); + if oracle_is_valid.is_err() && token.fallback_context.key != Pubkey::default() { + stale_oracles_with_fallbacks + .push((token.oracle, token.fallback_context.clone())); + } + } + stale_oracles_with_fallbacks.into_iter().collect() + } + }; + + Ok(fallbacks_by_oracle) + } } fn from_serum_style_pubkey(d: [u64; 4]) -> Pubkey { @@ -567,3 +703,22 @@ async fn fetch_raw_account(rpc: &RpcClientAsync, address: Pubkey) -> Result Pubkey { + let maybe_key = match determine_oracle_type(acc_info).ok() { + Some(oracle_type) => match oracle_type { + OracleType::OrcaCLMM => match load_whirlpool_state(acc_info).ok() { + Some(whirlpool) => whirlpool.get_quote_oracle().ok(), + None => None, + }, + _ => None, + }, + None => None, + }; + + maybe_key.unwrap_or_else(|| Pubkey::default()) +} diff --git a/lib/client/src/error_tracking.rs b/lib/client/src/error_tracking.rs index a466287ac..1e0166e52 100644 --- a/lib/client/src/error_tracking.rs +++ b/lib/client/src/error_tracking.rs @@ -32,7 +32,7 @@ impl Default for ErrorTypeState { #[derive(Builder)] pub struct ErrorTracking { - #[builder(setter(custom))] + #[builder(default, setter(custom))] errors_by_type: HashMap>, /// number of errors of a type after which had_too_many_errors returns true diff --git a/lib/client/src/gpa.rs b/lib/client/src/gpa.rs index 5dbd1106c..e96aa5418 100644 --- a/lib/client/src/gpa.rs +++ b/lib/client/src/gpa.rs @@ -1,11 +1,11 @@ use anchor_lang::{AccountDeserialize, Discriminator}; - 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::pubkey::Pubkey; pub async fn fetch_mango_accounts( @@ -129,3 +129,22 @@ pub async fn fetch_perp_markets( ) .await } + +pub async fn fetch_multiple_accounts( + rpc: &RpcClientAsync, + keys: &[Pubkey], +) -> anyhow::Result> { + let config = RpcAccountInfoConfig { + encoding: Some(UiAccountEncoding::Base64), + ..RpcAccountInfoConfig::default() + }; + Ok(rpc + .get_multiple_accounts_with_config(keys, config) + .await? + .value + .into_iter() + .zip(keys.iter()) + .filter(|(maybe_acc, _)| maybe_acc.is_some()) + .map(|(acc, key)| (*key, acc.unwrap().into())) + .collect()) +} diff --git a/lib/client/src/health_cache.rs b/lib/client/src/health_cache.rs index 14716fe51..47a176f54 100644 --- a/lib/client/src/health_cache.rs +++ b/lib/client/src/health_cache.rs @@ -1,22 +1,32 @@ -use crate::{AccountFetcher, MangoGroupContext}; +use crate::{AccountFetcher, FallbackOracleConfig, MangoGroupContext}; use anyhow::Context; use futures::{stream, StreamExt, TryStreamExt}; use mango_v4::accounts_zerocopy::KeyedAccountSharedData; use mango_v4::health::{FixedOrderAccountRetriever, HealthCache}; -use mango_v4::state::MangoAccountValue; +use mango_v4::state::{pyth_mainnet_sol_oracle, pyth_mainnet_usdc_oracle, MangoAccountValue}; +use std::collections::HashMap; use std::time::{SystemTime, UNIX_EPOCH}; pub async fn new( context: &MangoGroupContext, - account_fetcher: &impl AccountFetcher, + fallback_config: &FallbackOracleConfig, + account_fetcher: &dyn AccountFetcher, account: &MangoAccountValue, ) -> anyhow::Result { let active_token_len = account.active_token_positions().count(); let active_perp_len = account.active_perp_positions().count(); - let (metas, _health_cu) = - context.derive_health_check_remaining_account_metas(account, vec![], vec![], vec![])?; + let fallback_keys = context + .derive_fallback_oracle_keys(fallback_config, account_fetcher) + .await?; + let (metas, _health_cu) = context.derive_health_check_remaining_account_metas( + account, + vec![], + vec![], + vec![], + fallback_keys, + )?; let accounts: anyhow::Result> = stream::iter(metas.iter()) .then(|meta| async { Ok(KeyedAccountSharedData::new( @@ -34,9 +44,13 @@ pub async fn new( begin_perp: active_token_len * 2, begin_serum3: active_token_len * 2 + active_perp_len * 2, staleness_slot: None, - begin_fallback_oracles: metas.len(), // TODO: add support for fallback oracle accounts - usd_oracle_index: None, - sol_oracle_index: None, + begin_fallback_oracles: metas.len(), + usdc_oracle_index: metas + .iter() + .position(|m| m.pubkey == pyth_mainnet_usdc_oracle::ID), + sol_oracle_index: metas + .iter() + .position(|m| m.pubkey == pyth_mainnet_sol_oracle::ID), }; let now_ts = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); mango_v4::health::new_health_cache(&account.borrow(), &retriever, now_ts) @@ -51,8 +65,13 @@ pub fn new_sync( let active_token_len = account.active_token_positions().count(); let active_perp_len = account.active_perp_positions().count(); - let (metas, _health_cu) = - context.derive_health_check_remaining_account_metas(account, vec![], vec![], vec![])?; + let (metas, _health_cu) = context.derive_health_check_remaining_account_metas( + account, + vec![], + vec![], + vec![], + HashMap::new(), + )?; let accounts = metas .iter() .map(|meta| { @@ -70,8 +89,8 @@ pub fn new_sync( begin_perp: active_token_len * 2, begin_serum3: active_token_len * 2 + active_perp_len * 2, staleness_slot: None, - begin_fallback_oracles: metas.len(), // TODO: add support for fallback oracle accounts - usd_oracle_index: None, + begin_fallback_oracles: metas.len(), + usdc_oracle_index: None, sol_oracle_index: None, }; let now_ts = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); diff --git a/lib/client/src/jupiter/mod.rs b/lib/client/src/jupiter/mod.rs index 85434c793..e8eeeb2ed 100644 --- a/lib/client/src/jupiter/mod.rs +++ b/lib/client/src/jupiter/mod.rs @@ -1,23 +1,21 @@ -pub mod v4; pub mod v6; use anchor_lang::prelude::*; use std::str::FromStr; -use crate::{JupiterSwapMode, MangoClient, TransactionBuilder}; +use crate::{MangoClient, TransactionBuilder}; use fixed::types::I80F48; #[derive(Clone, Copy, PartialEq, Eq)] pub enum Version { Mock, - V4, V6, } #[derive(Clone)] +#[allow(clippy::large_enum_variant)] pub enum RawQuote { Mock, - V4(v4::QueryRoute), V6(v6::QuoteResponse), } @@ -32,21 +30,6 @@ pub struct Quote { } impl Quote { - pub fn try_from_v4( - input_mint: Pubkey, - output_mint: Pubkey, - route: v4::QueryRoute, - ) -> anyhow::Result { - Ok(Quote { - input_mint, - output_mint, - price_impact_pct: route.price_impact_pct, - in_amount: route.in_amount.parse()?, - out_amount: route.out_amount.parse()?, - raw: RawQuote::V4(route), - }) - } - pub fn try_from_v6(query: v6::QuoteResponse) -> anyhow::Result { Ok(Quote { input_mint: Pubkey::from_str(&query.input_mint)?, @@ -65,7 +48,6 @@ impl Quote { pub fn first_route_label(&self) -> String { let label_maybe = match &self.raw { RawQuote::Mock => Some("mock".into()), - RawQuote::V4(raw) => raw.market_infos.first().map(|v| v.label.clone()), RawQuote::V6(raw) => raw .route_plan .first() @@ -129,21 +111,6 @@ impl<'a> Jupiter<'a> { ) -> anyhow::Result { Ok(match version { Version::Mock => self.quote_mock(input_mint, output_mint, amount).await?, - Version::V4 => Quote::try_from_v4( - input_mint, - output_mint, - self.mango_client - .jupiter_v4() - .quote( - input_mint, - output_mint, - amount, - slippage_bps, - JupiterSwapMode::ExactIn, - only_direct_routes, - ) - .await?, - )?, Version::V6 => Quote::try_from_v6( self.mango_client .jupiter_v6() @@ -165,12 +132,6 @@ impl<'a> Jupiter<'a> { ) -> anyhow::Result { match "e.raw { RawQuote::Mock => anyhow::bail!("can't prepare jupiter swap for the mock"), - RawQuote::V4(raw) => { - self.mango_client - .jupiter_v4() - .prepare_swap_transaction(quote.input_mint, quote.output_mint, raw) - .await - } RawQuote::V6(raw) => { self.mango_client .jupiter_v6() diff --git a/lib/client/src/jupiter/v4.rs b/lib/client/src/jupiter/v4.rs deleted file mode 100644 index 29cf2be06..000000000 --- a/lib/client/src/jupiter/v4.rs +++ /dev/null @@ -1,363 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::str::FromStr; - -use anchor_lang::Id; -use anchor_spl::token::Token; - -use bincode::Options; - -use crate::{util, TransactionBuilder}; -use crate::{JupiterSwapMode, MangoClient}; - -use anyhow::Context; -use solana_sdk::instruction::Instruction; -use solana_sdk::signature::Signature; -use solana_sdk::{pubkey::Pubkey, signer::Signer}; - -#[derive(Deserialize, Serialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct QueryResult { - pub data: Vec, - pub time_taken: f64, - pub context_slot: u64, -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct QueryRoute { - pub in_amount: String, - pub out_amount: String, - pub price_impact_pct: f64, - pub market_infos: Vec, - pub amount: String, - pub slippage_bps: u64, - pub other_amount_threshold: String, - pub swap_mode: String, - pub fees: Option, -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct QueryMarketInfo { - pub id: String, - pub label: String, - pub input_mint: String, - pub output_mint: String, - pub not_enough_liquidity: bool, - pub in_amount: String, - pub out_amount: String, - pub min_in_amount: Option, - pub min_out_amount: Option, - pub price_impact_pct: Option, - pub lp_fee: QueryFee, - pub platform_fee: QueryFee, -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct QueryFee { - pub amount: String, - pub mint: String, - pub pct: Option, -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct QueryRouteFees { - pub signature_fee: f64, - pub open_orders_deposits: Vec, - pub ata_deposits: Vec, - pub total_fee_and_deposits: f64, - #[serde(rename = "minimalSOLForTransaction")] - pub minimal_sol_for_transaction: f64, -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct SwapRequest { - pub route: QueryRoute, - pub user_public_key: String, - #[serde(rename = "wrapUnwrapSOL")] - pub wrap_unwrap_sol: bool, - pub compute_unit_price_micro_lamports: Option, -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct SwapResponse { - pub setup_transaction: Option, - pub swap_transaction: String, - pub cleanup_transaction: Option, -} - -pub struct JupiterV4<'a> { - pub mango_client: &'a MangoClient, -} - -impl<'a> JupiterV4<'a> { - pub async fn quote( - &self, - input_mint: Pubkey, - output_mint: Pubkey, - amount: u64, - slippage_bps: u64, - swap_mode: JupiterSwapMode, - only_direct_routes: bool, - ) -> anyhow::Result { - let response = self - .mango_client - .http_client - .get(format!("{}/quote", self.mango_client.client.jupiter_v4_url)) - .query(&[ - ("inputMint", input_mint.to_string()), - ("outputMint", output_mint.to_string()), - ("amount", format!("{}", amount)), - ("onlyDirectRoutes", only_direct_routes.to_string()), - ("enforceSingleTx", "true".into()), - ("filterTopNResult", "10".into()), - ("slippageBps", format!("{}", slippage_bps)), - ( - "swapMode", - match swap_mode { - JupiterSwapMode::ExactIn => "ExactIn", - JupiterSwapMode::ExactOut => "ExactOut", - } - .into(), - ), - ]) - .send() - .await - .context("quote request to jupiter")?; - let quote: QueryResult = util::http_error_handling(response).await.with_context(|| { - format!("error requesting jupiter route between {input_mint} and {output_mint}") - })?; - - let route = quote.data.first().ok_or_else(|| { - anyhow::anyhow!( - "no route for swap. found {} routes, but none were usable", - quote.data.len() - ) - })?; - - Ok(route.clone()) - } - - /// Find the instructions and account lookup tables for a jupiter swap through mango - /// - /// It would be nice if we didn't have to pass input_mint/output_mint - the data is - /// definitely in QueryRoute - but it's unclear how. - pub async fn prepare_swap_transaction( - &self, - input_mint: Pubkey, - output_mint: Pubkey, - route: &QueryRoute, - ) -> anyhow::Result { - 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 swap_response = self - .mango_client - .http_client - .post(format!("{}/swap", self.mango_client.client.jupiter_v4_url)) - .json(&SwapRequest { - route: route.clone(), - user_public_key: self.mango_client.owner.pubkey().to_string(), - wrap_unwrap_sol: false, - compute_unit_price_micro_lamports: None, // we already prioritize - }) - .send() - .await - .context("swap transaction request to jupiter")?; - - let swap: SwapResponse = util::http_error_handling(swap_response) - .await - .context("error requesting jupiter swap")?; - - if swap.setup_transaction.is_some() || swap.cleanup_transaction.is_some() { - anyhow::bail!( - "chosen jupiter route requires setup or cleanup transactions, can't execute" - ); - } - - let jup_tx = bincode::options() - .with_fixint_encoding() - .reject_trailing_bytes() - .deserialize::( - &base64::decode(&swap.swap_transaction) - .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 compute_budget_program = 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 (jup_ixs, jup_alts) = self - .mango_client - .deserialize_instructions_and_alts(&jup_tx.message) - .await?; - let jup_action_ix_begin = jup_ixs - .iter() - .position(|ix| !is_setup_ix(ix.program_id)) - .ok_or_else(|| { - anyhow::anyhow!("jupiter swap response only had setup-like instructions") - })?; - let jup_action_ix_end = jup_ixs.len() - - jup_ixs - .iter() - .rev() - .position(|ix| !is_setup_ix(ix.program_id)) - .unwrap(); - - 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 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 = if route.swap_mode == "ExactIn" { - u64::from_str(&route.amount).unwrap() - } else if route.swap_mode == "ExactOut" { - u64::from_str(&route.other_amount_threshold).unwrap() - } else { - anyhow::bail!("unknown swap mode: {}", route.swap_mode); - }; - 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( - 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 mut instructions = Vec::new(); - - for ix in &jup_ixs[..jup_action_ix_begin] { - instructions.push(ix.clone()); - } - - // Ensure the source token account is created (jupiter 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 &jup_ixs[jup_action_ix_begin..jup_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 &jup_ixs[jup_action_ix_end..] { - instructions.push(ix.clone()); - } - - let mut address_lookup_tables = self.mango_client.mango_address_lookup_tables().await?; - address_lookup_tables.extend(jup_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.transaction_builder_config, - }) - } - - pub async fn swap( - &self, - input_mint: Pubkey, - output_mint: Pubkey, - amount: u64, - slippage_bps: u64, - swap_mode: JupiterSwapMode, - only_direct_routes: bool, - ) -> anyhow::Result { - let route = self - .quote( - input_mint, - output_mint, - amount, - slippage_bps, - swap_mode, - only_direct_routes, - ) - .await?; - - let tx_builder = self - .prepare_swap_transaction(input_mint, output_mint, &route) - .await?; - - tx_builder.send_and_confirm(&self.mango_client.client).await - } -} diff --git a/lib/client/src/jupiter/v6.rs b/lib/client/src/jupiter/v6.rs index 3b4ab074e..6c73fc741 100644 --- a/lib/client/src/jupiter/v6.rs +++ b/lib/client/src/jupiter/v6.rs @@ -194,15 +194,15 @@ impl<'a> JupiterV6<'a> { ), ), ]; - let client = &self.mango_client.client; - if !client.jupiter_token.is_empty() { - query_args.push(("token", client.jupiter_token.clone())); + let config = self.mango_client.client.config(); + if !config.jupiter_token.is_empty() { + query_args.push(("token", config.jupiter_token.clone())); } let response = self .mango_client .http_client - .get(format!("{}/quote", client.jupiter_v6_url)) + .get(format!("{}/quote", config.jupiter_v6_url)) .query(&query_args) .send() .await @@ -237,6 +237,7 @@ impl<'a> JupiterV6<'a> { .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() @@ -259,6 +260,7 @@ impl<'a> JupiterV6<'a> { 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![], @@ -267,15 +269,15 @@ impl<'a> JupiterV6<'a> { .context("building health accounts")?; let mut query_args = vec![]; - let client = &self.mango_client.client; - if !client.jupiter_token.is_empty() { - query_args.push(("token", client.jupiter_token.clone())); + let config = self.mango_client.client.config(); + if !config.jupiter_token.is_empty() { + query_args.push(("token", config.jupiter_token.clone())); } let swap_response = self .mango_client .http_client - .post(format!("{}/swap-instructions", client.jupiter_v6_url)) + .post(format!("{}/swap-instructions", config.jupiter_v6_url)) .query(&query_args) .json(&SwapRequest { user_public_key: owner.to_string(), @@ -386,7 +388,12 @@ impl<'a> JupiterV6<'a> { address_lookup_tables, payer, signers: vec![self.mango_client.owner.clone()], - config: self.mango_client.client.transaction_builder_config, + config: self + .mango_client + .client + .config() + .transaction_builder_config + .clone(), }) } diff --git a/lib/client/src/lib.rs b/lib/client/src/lib.rs index 559f6f5b7..882a931f6 100644 --- a/lib/client/src/lib.rs +++ b/lib/client/src/lib.rs @@ -8,12 +8,15 @@ pub mod account_update_stream; pub mod chain_data; mod chain_data_fetcher; mod client; +pub mod confirm_transaction; 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; mod util; pub mod websocket_source; diff --git a/lib/client/src/perp_pnl.rs b/lib/client/src/perp_pnl.rs index 86bd3de33..7d76f8918 100644 --- a/lib/client/src/perp_pnl.rs +++ b/lib/client/src/perp_pnl.rs @@ -17,6 +17,7 @@ pub enum Direction { /// Note: keep in sync with perp.ts:getSettlePnlCandidates pub async fn fetch_top( context: &crate::context::MangoGroupContext, + fallback_config: &FallbackOracleConfig, account_fetcher: &impl AccountFetcher, perp_market_index: PerpMarketIndex, direction: Direction, @@ -91,9 +92,10 @@ pub async fn fetch_top( } else { I80F48::ZERO }; - let perp_max_settle = crate::health_cache::new(context, account_fetcher, &acc) - .await? - .perp_max_settle(perp_market.settle_token_index)?; + let perp_max_settle = + crate::health_cache::new(context, fallback_config, account_fetcher, &acc) + .await? + .perp_max_settle(perp_market.settle_token_index)?; let settleable_pnl = if perp_max_settle > 0 { (*pnl).max(-perp_max_settle) } else { diff --git a/lib/client/src/priority_fees.rs b/lib/client/src/priority_fees.rs new file mode 100644 index 000000000..179fc0361 --- /dev/null +++ b/lib/client/src/priority_fees.rs @@ -0,0 +1,240 @@ +use futures::{SinkExt, StreamExt}; +use jsonrpc_core::{MethodCall, Notification, Params, Version}; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; +use tokio::sync::broadcast; +use tokio::task::JoinHandle; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::protocol::Message; +use tracing::*; + +pub trait PriorityFeeProvider: Sync + Send { + fn compute_unit_fee_microlamports(&self) -> u64; +} + +pub struct FixedPriorityFeeProvider { + pub compute_unit_fee_microlamports: u64, +} + +impl FixedPriorityFeeProvider { + pub fn new(fee_microlamports: u64) -> Self { + Self { + compute_unit_fee_microlamports: fee_microlamports, + } + } +} + +impl PriorityFeeProvider for FixedPriorityFeeProvider { + fn compute_unit_fee_microlamports(&self) -> u64 { + self.compute_unit_fee_microlamports + } +} + +#[derive(Builder)] +pub struct EmaPriorityFeeProviderConfig { + pub percentile: u8, + + #[builder(default = "0.2")] + pub alpha: f64, + + pub fallback_prio: u64, + + #[builder(default = "Duration::from_secs(15)")] + pub max_age: Duration, +} + +impl EmaPriorityFeeProviderConfig { + pub fn builder() -> EmaPriorityFeeProviderConfigBuilder { + EmaPriorityFeeProviderConfigBuilder::default() + } +} + +#[derive(Default)] +struct CuPercentileEmaPriorityFeeProviderData { + ema: f64, + last_update: Option, +} + +pub struct CuPercentileEmaPriorityFeeProvider { + data: RwLock, + config: EmaPriorityFeeProviderConfig, +} + +impl PriorityFeeProvider for CuPercentileEmaPriorityFeeProvider { + fn compute_unit_fee_microlamports(&self) -> u64 { + let data = self.data.read().unwrap(); + if let Some(last_update) = data.last_update { + if Instant::now().duration_since(last_update) > self.config.max_age { + return self.config.fallback_prio; + } + } else { + return self.config.fallback_prio; + } + data.ema as u64 + } +} + +impl CuPercentileEmaPriorityFeeProvider { + pub fn run( + config: EmaPriorityFeeProviderConfig, + sender: &broadcast::Sender, + ) -> (Arc, JoinHandle<()>) { + let this = Arc::new(Self { + data: Default::default(), + config, + }); + let handle = tokio::spawn({ + let this_c = this.clone(); + let rx = sender.subscribe(); + async move { Self::run_update_job(this_c, rx).await } + }); + (this, handle) + } + + async fn run_update_job(provider: Arc, mut rx: broadcast::Receiver) { + let config = &provider.config; + loop { + let block_prios = rx.recv().await.unwrap(); + let prio = match block_prios.by_cu_percentile.get(&config.percentile) { + Some(v) => *v as f64, + None => { + error!("percentile not available: {}", config.percentile); + continue; + } + }; + + let mut data = provider.data.write().unwrap(); + data.ema = data.ema * (1.0 - config.alpha) + config.alpha * prio; + data.last_update = Some(Instant::now()); + } + } +} + +#[derive(Clone, Default, Debug)] +pub struct BlockPrioFees { + pub slot: u64, + // prio fee percentile in percent -> prio fee + pub percentile: HashMap, + // cu percentile in percent -> median prio fee of the group + pub by_cu_percentile: HashMap, +} + +#[derive(serde::Deserialize)] +struct BlockPrioritizationFeesNotificationContext { + slot: u64, +} + +#[derive(serde::Deserialize)] +struct BlockPrioritizationFeesNotificationValue { + by_tx: Vec, + by_tx_percentiles: Vec, + by_cu: Vec, + by_cu_percentiles: Vec, +} + +#[derive(serde::Deserialize)] +struct BlockPrioritizationFeesNotificationParams { + context: BlockPrioritizationFeesNotificationContext, + value: BlockPrioritizationFeesNotificationValue, +} + +fn as_block_prioritization_fees_notification( + notification_str: &str, +) -> anyhow::Result> { + let notification: Notification = match serde_json::from_str(¬ification_str) { + Ok(v) => v, + Err(_) => return Ok(None), // not a notification at all + }; + if notification.method != "blockPrioritizationFeesNotification" { + return Ok(None); + } + let map = match notification.params { + Params::Map(m) => m, + _ => anyhow::bail!("unexpected params, expected map"), + }; + let result = map + .get("result") + .ok_or(anyhow::anyhow!("missing params.result"))? + .clone(); + + let mut data = BlockPrioFees::default(); + let v: BlockPrioritizationFeesNotificationParams = serde_json::from_value(result)?; + data.slot = v.context.slot; + for (percentile, prio) in v.value.by_tx_percentiles.iter().zip(v.value.by_tx.iter()) { + let int_perc: u8 = ((percentile * 100.0) as u64).try_into()?; + data.percentile.insert(int_perc, *prio); + } + for (percentile, prio) in v.value.by_cu_percentiles.iter().zip(v.value.by_cu.iter()) { + let int_perc: u8 = ((percentile * 100.0) as u64).try_into()?; + data.by_cu_percentile.insert(int_perc, *prio); + } + + Ok(Some(data)) +} + +async fn connect_and_broadcast( + url: &str, + sender: &broadcast::Sender, +) -> anyhow::Result<()> { + let (ws_stream, _) = connect_async(url).await?; + let (mut write, mut read) = ws_stream.split(); + + // Create a JSON-RPC request + let call = MethodCall { + jsonrpc: Some(Version::V2), + method: "blockPrioritizationFeesSubscribe".to_string(), + params: Params::None, + id: jsonrpc_core::Id::Num(1), + }; + + let request = serde_json::to_string(&call).unwrap(); + write.send(Message::Text(request)).await?; + + loop { + let timeout = tokio::time::sleep(Duration::from_secs(20)); + tokio::select! { + message = read.next() => { + match message { + Some(Ok(Message::Text(text))) => { + if let Some(block_prio) = as_block_prioritization_fees_notification(&text)? { + // Failure might just mean there is no receiver right now + let _ = sender.send(block_prio); + } + } + Some(Ok(Message::Ping(..))) => {} + Some(Ok(Message::Pong(..))) => {} + Some(Ok(msg @ _)) => { + anyhow::bail!("received a non-text message: {:?}", msg); + }, + Some(Err(e)) => { + anyhow::bail!("error receiving message: {}", e); + } + None => { + anyhow::bail!("websocket stream closed"); + } + } + }, + _ = timeout => { + anyhow::bail!("timeout"); + } + } + } +} + +async fn connect_and_broadcast_loop(url: &str, sender: broadcast::Sender) { + loop { + if let Err(err) = connect_and_broadcast(url, &sender).await { + info!("recent block prio feed error, restarting: {err:?}"); + } + } +} + +pub fn run_broadcast_from_websocket_feed( + url: String, +) -> (broadcast::Sender, JoinHandle<()>) { + let (sender, _) = broadcast::channel(10); + let sender_c = sender.clone(); + let handle = tokio::spawn(async move { connect_and_broadcast_loop(&url, sender_c).await }); + (sender, handle) +} diff --git a/lib/client/src/priority_fees_cli.rs b/lib/client/src/priority_fees_cli.rs new file mode 100644 index 000000000..c2f44bc34 --- /dev/null +++ b/lib/client/src/priority_fees_cli.rs @@ -0,0 +1,80 @@ +use std::sync::Arc; +use tokio::task::JoinHandle; +use tracing::*; + +use crate::priority_fees::*; + +#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] +enum PriorityFeeStyleArg { + None, + Fixed, + LiteRpcCuPercentileEma, +} + +#[derive(clap::Args, Debug, Clone)] +pub struct PriorityFeeArgs { + /// choose prio fee style + #[clap(long, env, value_enum, default_value = "none")] + prioritization_style: PriorityFeeStyleArg, + + /// prioritize each transaction with this many microlamports/cu + /// + /// for dynamic prio styles, this is the fallback value + #[clap(long, env, default_value = "0")] + prioritization_micro_lamports: u64, + + #[clap(long, env, default_value = "50")] + prioritization_ema_percentile: u8, + + #[clap(long, env, default_value = "0.2")] + prioritization_ema_alpha: f64, +} + +impl PriorityFeeArgs { + pub fn make_prio_provider( + &self, + lite_rpc_url: String, + ) -> anyhow::Result<(Option>, Vec>)> { + let prio_style; + if self.prioritization_micro_lamports > 0 + && self.prioritization_style == PriorityFeeStyleArg::None + { + info!("forcing prioritization-style to fixed, since prioritization-micro-lamports was set"); + prio_style = PriorityFeeStyleArg::Fixed; + } else { + prio_style = self.prioritization_style; + } + + Ok(match prio_style { + PriorityFeeStyleArg::None => (None, vec![]), + PriorityFeeStyleArg::Fixed => ( + Some(Arc::new(FixedPriorityFeeProvider::new( + self.prioritization_micro_lamports, + ))), + vec![], + ), + PriorityFeeStyleArg::LiteRpcCuPercentileEma => { + if lite_rpc_url.is_empty() { + anyhow::bail!("cannot use recent-cu-percentile-ema prioritization style without a lite-rpc url"); + } + let (block_prio_broadcaster, block_prio_job) = + run_broadcast_from_websocket_feed(lite_rpc_url); + let (prio_fee_provider, prio_fee_provider_job) = + CuPercentileEmaPriorityFeeProvider::run( + EmaPriorityFeeProviderConfig::builder() + .percentile(75) + .fallback_prio(self.prioritization_micro_lamports) + .alpha(self.prioritization_ema_alpha) + .percentile(self.prioritization_ema_percentile) + .build() + .unwrap(), + &block_prio_broadcaster, + ); + ( + Some(prio_fee_provider), + vec![block_prio_job, prio_fee_provider_job], + ) + } + }) + } +} diff --git a/lib/client/src/util.rs b/lib/client/src/util.rs index 81669456f..f54d6cac9 100644 --- a/lib/client/src/util.rs +++ b/lib/client/src/util.rs @@ -1,17 +1,8 @@ -use solana_client::{ - client_error::Result as ClientResult, rpc_client::RpcClient, rpc_request::RpcError, -}; use solana_sdk::compute_budget::ComputeBudgetInstruction; use solana_sdk::instruction::Instruction; -use solana_sdk::transaction::Transaction; -use solana_sdk::{ - clock::Slot, commitment_config::CommitmentConfig, signature::Signature, - transaction::uses_durable_nonce, -}; use anchor_lang::prelude::{AccountMeta, Pubkey}; use anyhow::Context; -use std::{thread, time}; /// Some Result<> types don't convert to anyhow::Result nicely. Force them through stringification. pub trait AnyhowWrap { @@ -57,67 +48,6 @@ pub fn delay_interval(period: std::time::Duration) -> tokio::time::Interval { interval } -/// 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()) -} - /// Convenience function used in binaries to set up the fmt tracing_subscriber, /// with cololring enabled only if logging to a terminal and with EnvFilter. pub fn tracing_subscriber_init() { diff --git a/mango_v4.json b/mango_v4.json index b98f94c20..0a54572d1 100644 --- a/mango_v4.json +++ b/mango_v4.json @@ -1,5 +1,9 @@ { +<<<<<<< HEAD "version": "0.22.0", +======= + "version": "0.23.0", +>>>>>>> main "name": "mango_v4", "instructions": [ { @@ -277,6 +281,12 @@ "type": { "option": "u16" } + }, + { + "name": "collateralFeeIntervalOpt", + "type": { + "option": "u64" + } } ] }, @@ -631,6 +641,17 @@ { "name": "platformLiquidationFee", "type": "f32" +<<<<<<< HEAD +======= + }, + { + "name": "disableAssetLiquidation", + "type": "bool" + }, + { + "name": "collateralFeePerDay", + "type": "f32" +>>>>>>> main } ] }, @@ -1041,6 +1062,27 @@ "type": { "option": "f32" } +<<<<<<< HEAD +======= + }, + { + "name": "disableAssetLiquidationOpt", + "type": { + "option": "bool" + } + }, + { + "name": "collateralFeePerDayOpt", + "type": { + "option": "f32" + } + }, + { + "name": "forceWithdrawOpt", + "type": { + "option": "bool" + } +>>>>>>> main } ] }, @@ -3763,6 +3805,63 @@ } ] }, + { + "name": "tokenForceWithdraw", + "accounts": [ + { + "name": "group", + "isMut": false, + "isSigner": false + }, + { + "name": "account", + "isMut": true, + "isSigner": false, + "relations": [ + "group" + ] + }, + { + "name": "bank", + "isMut": true, + "isSigner": false, + "relations": [ + "group", + "vault", + "oracle" + ] + }, + { + "name": "vault", + "isMut": true, + "isSigner": false + }, + { + "name": "oracle", + "isMut": false, + "isSigner": false + }, + { + "name": "ownerAtaTokenAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "alternateOwnerTokenAccount", + "isMut": true, + "isSigner": false, + "docs": [ + "Only for the unusual case where the owner_ata account is not owned by account.owner" + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, { "name": "perpCreateMarket", "docs": [ @@ -5953,6 +6052,25 @@ } ] }, + { + "name": "tokenChargeCollateralFees", + "accounts": [ + { + "name": "group", + "isMut": false, + "isSigner": false + }, + { + "name": "account", + "isMut": true, + "isSigner": false, + "relations": [ + "group" + ] + } + ], + "args": [] + }, { "name": "altSet", "accounts": [ @@ -7373,12 +7491,24 @@ "name": "forceClose", "type": "u8" }, + { + "name": "disableAssetLiquidation", + "docs": [ + "If set to 1, deposits cannot be liquidated when an account is liquidatable.", + "That means bankrupt accounts may still have assets of this type deposited." + ], + "type": "u8" + }, + { + "name": "forceWithdraw", + "type": "u8" + }, { "name": "padding", "type": { "array": [ "u8", - 6 + 4 ] } }, @@ -7514,11 +7644,36 @@ } }, { +<<<<<<< HEAD +======= + "name": "collectedCollateralFees", + "docs": [ + "Collateral fees that have been collected (in native tokens)", + "", + "See also collected_fees_native and fees_withdrawn." + ], + "type": { + "defined": "I80F48" + } + }, + { + "name": "collateralFeePerDay", + "docs": [ + "The daily collateral fees rate for fully utilized collateral." + ], + "type": "f32" + }, + { +>>>>>>> main "name": "reserved", "type": { "array": [ "u8", +<<<<<<< HEAD 1920 +======= + 1900 +>>>>>>> main ] } } @@ -7646,12 +7801,28 @@ ], "type": "u16" }, + { + "name": "padding2", + "type": { + "array": [ + "u8", + 4 + ] + } + }, + { + "name": "collateralFeeInterval", + "docs": [ + "Intervals in which collateral fee is applied" + ], + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 1812 + 1800 ] } } @@ -7773,12 +7944,27 @@ ], "type": "u64" }, + { + "name": "temporaryDelegate", + "type": "publicKey" + }, + { + "name": "temporaryDelegateExpiry", + "type": "u64" + }, + { + "name": "lastCollateralFeeCharge", + "docs": [ + "Time at which the last collateral fee was charged" + ], + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 200 + 152 ] } }, @@ -9548,12 +9734,16 @@ "name": "temporaryDelegateExpiry", "type": "u64" }, + { + "name": "lastCollateralFeeCharge", + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 160 + 152 ] } } @@ -10474,6 +10664,9 @@ }, { "name": "Swap" + }, + { + "name": "SwapWithoutFee" } ] } @@ -10829,6 +11022,9 @@ }, { "name": "Serum3PlaceOrderV2" + }, + { + "name": "TokenForceWithdraw" } ] } @@ -13746,6 +13942,76 @@ "index": false } ] + }, + { + "name": "TokenCollateralFeeLog", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "mangoAccount", + "type": "publicKey", + "index": false + }, + { + "name": "tokenIndex", + "type": "u16", + "index": false + }, + { + "name": "assetUsageFraction", + "type": "i128", + "index": false + }, + { + "name": "fee", + "type": "i128", + "index": false + }, + { + "name": "price", + "type": "i128", + "index": false + } + ] + }, + { + "name": "ForceWithdrawLog", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "mangoAccount", + "type": "publicKey", + "index": false + }, + { + "name": "tokenIndex", + "type": "u16", + "index": false + }, + { + "name": "quantity", + "type": "u64", + "index": false + }, + { + "name": "price", + "type": "i128", + "index": false + }, + { + "name": "toTokenAccount", + "type": "publicKey", + "index": false + } + ] } ], "errors": [ @@ -14093,6 +14359,11 @@ "code": 6068, "name": "MissingFeedForCLMMOracle", "msg": "Pyth USDC/USD or SOL/USD feed not found (required by CLMM oracle)" + }, + { + "code": 6069, + "name": "TokenAssetLiquidationDisabled", + "msg": "the asset does not allow liquidation" } ] } \ No newline at end of file diff --git a/programs/mango-v4/Cargo.toml b/programs/mango-v4/Cargo.toml index b0b3fc7b2..f9d5433bb 100644 --- a/programs/mango-v4/Cargo.toml +++ b/programs/mango-v4/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mango-v4" -version = "0.22.0" +version = "0.23.0" description = "Created with Anchor" edition = "2021" @@ -32,7 +32,11 @@ borsh = { version = "0.10.3", features = ["const-generics"] } bytemuck = { version = "^1.7.2", features = ["min_const_generics"] } default-env = "0.1.1" derivative = "2.2.0" -fixed = { workspace = true, features = ["serde", "borsh", "debug-assert-in-release"] } +fixed = { workspace = true, features = [ + "serde", + "borsh", + "debug-assert-in-release", +] } num_enum = "0.5.1" pyth-sdk-solana = { workspace = true } serde = "^1.0" @@ -48,7 +52,9 @@ switchboard-program = "0.2" switchboard-v2 = { package = "switchboard-solana", version = "0.28" } -openbook-v2 = { git = "https://github.com/openbook-dex/openbook-v2.git", features = ["no-entrypoint"] } +openbook-v2 = { git = "https://github.com/openbook-dex/openbook-v2.git", features = [ + "no-entrypoint", +] } [dev-dependencies] @@ -56,7 +62,9 @@ solana-sdk = { workspace = true, default-features = false } solana-program-test = { workspace = true } solana-logger = { workspace = true } spl-token = { version = "^3.0.0", features = ["no-entrypoint"] } -spl-associated-token-account = { version = "^1.0.3", features = ["no-entrypoint"] } +spl-associated-token-account = { version = "^1.0.3", features = [ + "no-entrypoint", +] } bincode = "^1.3.1" log = "0.4.14" env_logger = "0.9.0" diff --git a/programs/mango-v4/resources/test/mangoaccount-v0.21.3.bin b/programs/mango-v4/resources/test/mangoaccount-v0.21.3.bin new file mode 100644 index 000000000..f8389611f Binary files /dev/null and b/programs/mango-v4/resources/test/mangoaccount-v0.21.3.bin differ diff --git a/programs/mango-v4/src/accounts_ix/flash_loan.rs b/programs/mango-v4/src/accounts_ix/flash_loan.rs index a60a6b202..424af18d0 100644 --- a/programs/mango-v4/src/accounts_ix/flash_loan.rs +++ b/programs/mango-v4/src/accounts_ix/flash_loan.rs @@ -92,6 +92,12 @@ pub struct FlashLoanEnd<'info> { #[derive(PartialEq, Copy, Clone, Debug, AnchorSerialize, AnchorDeserialize)] #[repr(u8)] pub enum FlashLoanType { + /// An arbitrary flash loan Unknown, + /// A flash loan used for a swap where one token is exchanged for another. + /// + /// Deposits in this type get charged the flash_loan_swap_fee_rate Swap, + /// Like Swap, but without the flash_loan_swap_fee_rate + SwapWithoutFee, } diff --git a/programs/mango-v4/src/accounts_ix/mod.rs b/programs/mango-v4/src/accounts_ix/mod.rs index d2b42f23c..df8ea1f30 100644 --- a/programs/mango-v4/src/accounts_ix/mod.rs +++ b/programs/mango-v4/src/accounts_ix/mod.rs @@ -59,6 +59,7 @@ pub use stub_oracle_close::*; pub use stub_oracle_create::*; pub use stub_oracle_set::*; pub use token_add_bank::*; +pub use token_charge_collateral_fees::*; pub use token_conditional_swap_cancel::*; pub use token_conditional_swap_create::*; pub use token_conditional_swap_start::*; @@ -67,6 +68,7 @@ pub use token_deposit::*; pub use token_deregister::*; pub use token_edit::*; pub use token_force_close_borrows_with_token::*; +pub use token_force_withdraw::*; pub use token_liq_bankruptcy::*; pub use token_liq_with_token::*; pub use token_register::*; @@ -135,6 +137,7 @@ mod stub_oracle_close; mod stub_oracle_create; mod stub_oracle_set; mod token_add_bank; +mod token_charge_collateral_fees; mod token_conditional_swap_cancel; mod token_conditional_swap_create; mod token_conditional_swap_start; @@ -143,6 +146,7 @@ mod token_deposit; mod token_deregister; mod token_edit; mod token_force_close_borrows_with_token; +mod token_force_withdraw; mod token_liq_bankruptcy; mod token_liq_with_token; mod token_register; diff --git a/programs/mango-v4/src/accounts_ix/token_charge_collateral_fees.rs b/programs/mango-v4/src/accounts_ix/token_charge_collateral_fees.rs new file mode 100644 index 000000000..d6c7f218f --- /dev/null +++ b/programs/mango-v4/src/accounts_ix/token_charge_collateral_fees.rs @@ -0,0 +1,16 @@ +use crate::error::MangoError; +use crate::state::*; +use anchor_lang::prelude::*; + +/// Charges collateral fees on an account +#[derive(Accounts)] +pub struct TokenChargeCollateralFees<'info> { + pub group: AccountLoader<'info, Group>, + + #[account( + mut, + has_one = group, + constraint = account.load()?.is_operational() @ MangoError::AccountIsFrozen + )] + pub account: AccountLoader<'info, MangoAccountFixed>, +} diff --git a/programs/mango-v4/src/accounts_ix/token_force_withdraw.rs b/programs/mango-v4/src/accounts_ix/token_force_withdraw.rs new file mode 100644 index 000000000..69ee070aa --- /dev/null +++ b/programs/mango-v4/src/accounts_ix/token_force_withdraw.rs @@ -0,0 +1,54 @@ +use anchor_lang::prelude::*; +use anchor_spl::associated_token::get_associated_token_address; +use anchor_spl::token::Token; +use anchor_spl::token::TokenAccount; + +use crate::error::*; +use crate::state::*; + +#[derive(Accounts)] +pub struct TokenForceWithdraw<'info> { + #[account( + constraint = group.load()?.is_ix_enabled(IxGate::TokenForceWithdraw) @ MangoError::IxIsDisabled, + )] + pub group: AccountLoader<'info, Group>, + + #[account( + mut, + has_one = group, + constraint = account.load()?.is_operational() @ MangoError::AccountIsFrozen, + )] + pub account: AccountLoader<'info, MangoAccountFixed>, + + #[account( + mut, + has_one = group, + has_one = vault, + has_one = oracle, + // the mints of bank/vault/token_accounts are implicitly the same because + // spl::token::transfer succeeds between token_account and vault + )] + pub bank: AccountLoader<'info, Bank>, + + #[account(mut)] + pub vault: Box>, + + /// CHECK: The oracle can be one of several different account types + pub oracle: UncheckedAccount<'info>, + + #[account( + mut, + address = get_associated_token_address(&account.load()?.owner, &vault.mint), + // NOTE: the owner may have been changed (before immutable owner was a thing) + )] + pub owner_ata_token_account: Box>, + + /// Only for the unusual case where the owner_ata account is not owned by account.owner + #[account( + mut, + constraint = alternate_owner_token_account.owner == account.load()?.owner, + )] + pub alternate_owner_token_account: Box>, + + pub token_program: Program<'info, Token>, +} diff --git a/programs/mango-v4/src/error.rs b/programs/mango-v4/src/error.rs index ed4702bfd..1859d26aa 100644 --- a/programs/mango-v4/src/error.rs +++ b/programs/mango-v4/src/error.rs @@ -143,6 +143,8 @@ pub enum MangoError { InvalidFeedForCLMMOracle, #[msg("Pyth USDC/USD or SOL/USD feed not found (required by CLMM oracle)")] MissingFeedForCLMMOracle, + #[msg("the asset does not allow liquidation")] + TokenAssetLiquidationDisabled, } impl MangoError { diff --git a/programs/mango-v4/src/health/account_retriever.rs b/programs/mango-v4/src/health/account_retriever.rs index 8296bc2af..27bc0f14d 100644 --- a/programs/mango-v4/src/health/account_retriever.rs +++ b/programs/mango-v4/src/health/account_retriever.rs @@ -59,7 +59,7 @@ pub struct FixedOrderAccountRetriever { pub begin_serum3: usize, pub staleness_slot: Option, pub begin_fallback_oracles: usize, - pub usd_oracle_index: Option, + pub usdc_oracle_index: Option, pub sol_oracle_index: Option, } @@ -78,7 +78,7 @@ pub fn new_fixed_order_account_retriever<'a, 'info>( ais.len(), expected_ais, active_token_len, active_token_len, active_perp_len, active_perp_len, active_serum3_len ); - let usd_oracle_index = ais[..] + let usdc_oracle_index = ais[..] .iter() .position(|o| o.key == &pyth_mainnet_usdc_oracle::ID); let sol_oracle_index = ais[..] @@ -93,7 +93,7 @@ pub fn new_fixed_order_account_retriever<'a, 'info>( begin_serum3: active_token_len * 2 + active_perp_len * 2, staleness_slot: Some(Clock::get()?.slot), begin_fallback_oracles: expected_ais, - usd_oracle_index, + usdc_oracle_index, sol_oracle_index, }) } @@ -139,7 +139,7 @@ impl FixedOrderAccountRetriever { OracleAccountInfos { oracle, fallback_opt, - usd_opt: self.usd_oracle_index.map(|i| &self.ais[i]), + usdc_opt: self.usdc_oracle_index.map(|i| &self.ais[i]), sol_opt: self.sol_oracle_index.map(|i| &self.ais[i]), } } @@ -324,7 +324,7 @@ impl<'a, 'info> ScannedBanksAndOracles<'a, 'info> { OracleAccountInfos { oracle, fallback_opt, - usd_opt: self.usd_oracle_index.map(|i| &self.fallback_oracles[i]), + usdc_opt: self.usd_oracle_index.map(|i| &self.fallback_oracles[i]), sol_opt: self.sol_oracle_index.map(|i| &self.fallback_oracles[i]), } } diff --git a/programs/mango-v4/src/health/cache.rs b/programs/mango-v4/src/health/cache.rs index 8d29215e3..4c9fa7c3e 100644 --- a/programs/mango-v4/src/health/cache.rs +++ b/programs/mango-v4/src/health/cache.rs @@ -175,6 +175,8 @@ pub struct TokenInfo { /// Includes TokenPosition and free Serum3OpenOrders balances. /// Does not include perp upnl or Serum3 reserved amounts. pub balance_spot: I80F48, + + pub allow_asset_liquidation: bool, } /// Temporary value used during health computations @@ -907,6 +909,7 @@ impl HealthCache { } /// Liquidatable spot assets mean: actual token deposits and also a positive effective token balance + /// and is available for asset liquidation pub fn has_liq_spot_assets(&self) -> bool { let health_token_balances = self.effective_token_balances(HealthType::LiquidationEnd); self.token_infos @@ -914,11 +917,11 @@ impl HealthCache { .zip(health_token_balances.iter()) .any(|(ti, b)| { // need 1 native token to use token_liq_with_token - ti.balance_spot >= 1 && b.spot_and_perp >= 1 + ti.balance_spot >= 1 && b.spot_and_perp >= 1 && ti.allow_asset_liquidation }) } - /// Liquidatable spot borrows mean: actual toen borrows plus a negative effective token balance + /// Liquidatable spot borrows mean: actual token borrows plus a negative effective token balance pub fn has_liq_spot_borrows(&self) -> bool { let health_token_balances = self.effective_token_balances(HealthType::LiquidationEnd); self.token_infos @@ -932,7 +935,9 @@ impl HealthCache { let health_token_balances = self.effective_token_balances(HealthType::LiquidationEnd); let all_iter = || self.token_infos.iter().zip(health_token_balances.iter()); all_iter().any(|(ti, b)| ti.balance_spot < 0 && b.spot_and_perp < 0) - && all_iter().any(|(ti, b)| ti.balance_spot >= 1 && b.spot_and_perp >= 1) + && all_iter().any(|(ti, b)| { + ti.balance_spot >= 1 && b.spot_and_perp >= 1 && ti.allow_asset_liquidation + }) } pub fn has_serum3_open_orders_funds(&self) -> bool { @@ -1286,6 +1291,7 @@ fn new_health_cache_impl( init_scaled_liab_weight: bank.scaled_init_liab_weight(liab_price), prices, balance_spot: native, + allow_asset_liquidation: bank.allows_asset_liquidation(), }); } diff --git a/programs/mango-v4/src/health/client.rs b/programs/mango-v4/src/health/client.rs index 73b003280..c7bad1009 100644 --- a/programs/mango-v4/src/health/client.rs +++ b/programs/mango-v4/src/health/client.rs @@ -682,6 +682,7 @@ mod tests { init_scaled_liab_weight: I80F48::from_num(1.0 + x), prices: Prices::new_single_price(I80F48::from_num(price)), balance_spot: I80F48::ZERO, + allow_asset_liquidation: true, } } @@ -1461,27 +1462,49 @@ mod tests { I80F48::ZERO ); - let find_max_borrow = |c: &HealthCache, ratio: f64| { - let max_borrow = c - .max_borrow_for_health_ratio(&account, bank0_data, I80F48::from_num(ratio)) - .unwrap(); - // compute the health ratio we'd get when executing the trade - let actual_ratio = { - let mut c = c.clone(); - c.token_infos[0].balance_spot -= max_borrow; - c.health_ratio(HealthType::Init).to_num::() - }; - // the ratio for borrowing one native token extra - let plus_ratio = { - let mut c = c.clone(); - c.token_infos[0].balance_spot -= max_borrow + I80F48::ONE; - c.health_ratio(HealthType::Init).to_num::() - }; - (max_borrow, actual_ratio, plus_ratio) + let now_ts = system_epoch_secs(); + + let cache_after_borrow = |account: &MangoAccountValue, + c: &HealthCache, + bank: &Bank, + amount: I80F48| + -> Result { + let mut position = account.token_position(bank.token_index)?.clone(); + + let mut bank = bank.clone(); + bank.withdraw_with_fee(&mut position, amount, now_ts)?; + bank.check_net_borrows(c.token_info(bank.token_index)?.prices.oracle)?; + + let mut resulting_cache = c.clone(); + resulting_cache.adjust_token_balance(&bank, -amount)?; + + Ok(resulting_cache) }; - let check_max_borrow = |c: &HealthCache, ratio: f64| -> f64 { + + let find_max_borrow = + |account: &MangoAccountValue, c: &HealthCache, ratio: f64, bank: &Bank| { + let max_borrow = c + .max_borrow_for_health_ratio(account, bank, I80F48::from_num(ratio)) + .unwrap(); + // compute the health ratio we'd get when executing the trade + let actual_ratio = { + let c = cache_after_borrow(account, c, bank, max_borrow).unwrap(); + c.health_ratio(HealthType::Init).to_num::() + }; + // the ratio for borrowing one native token extra + let plus_ratio = { + let c = cache_after_borrow(account, c, bank, max_borrow + I80F48::ONE).unwrap(); + c.health_ratio(HealthType::Init).to_num::() + }; + (max_borrow, actual_ratio, plus_ratio) + }; + let check_max_borrow = |account: &MangoAccountValue, + c: &HealthCache, + ratio: f64, + bank: &Bank| + -> f64 { let initial_ratio = c.health_ratio(HealthType::Init).to_num::(); - let (max_borrow, actual_ratio, plus_ratio) = find_max_borrow(c, ratio); + let (max_borrow, actual_ratio, plus_ratio) = find_max_borrow(account, c, ratio, bank); println!( "checking target ratio {ratio}: initial ratio: {initial_ratio}, actual ratio: {actual_ratio}, plus ratio: {plus_ratio}, borrow: {max_borrow}", ); @@ -1496,30 +1519,66 @@ mod tests { { let mut health_cache = health_cache.clone(); health_cache.token_infos[0].balance_spot = I80F48::from_num(100.0); - assert_eq!(check_max_borrow(&health_cache, 50.0), 100.0); + assert_eq!( + check_max_borrow(&account, &health_cache, 50.0, bank0_data), + 100.0 + ); } { let mut health_cache = health_cache.clone(); health_cache.token_infos[1].balance_spot = I80F48::from_num(50.0); // price 2, so 2*50*0.8 = 80 health - check_max_borrow(&health_cache, 100.0); - check_max_borrow(&health_cache, 50.0); - check_max_borrow(&health_cache, 0.0); + check_max_borrow(&account, &health_cache, 100.0, bank0_data); + check_max_borrow(&account, &health_cache, 50.0, bank0_data); + check_max_borrow(&account, &health_cache, 0.0, bank0_data); } { let mut health_cache = health_cache.clone(); health_cache.token_infos[0].balance_spot = I80F48::from_num(50.0); health_cache.token_infos[1].balance_spot = I80F48::from_num(50.0); - check_max_borrow(&health_cache, 100.0); - check_max_borrow(&health_cache, 50.0); - check_max_borrow(&health_cache, 0.0); + check_max_borrow(&account, &health_cache, 100.0, bank0_data); + check_max_borrow(&account, &health_cache, 50.0, bank0_data); + check_max_borrow(&account, &health_cache, 0.0, bank0_data); } { let mut health_cache = health_cache.clone(); health_cache.token_infos[0].balance_spot = I80F48::from_num(-50.0); health_cache.token_infos[1].balance_spot = I80F48::from_num(50.0); - check_max_borrow(&health_cache, 100.0); - check_max_borrow(&health_cache, 50.0); - check_max_borrow(&health_cache, 0.0); + check_max_borrow(&account, &health_cache, 100.0, bank0_data); + check_max_borrow(&account, &health_cache, 50.0, bank0_data); + check_max_borrow(&account, &health_cache, 0.0, bank0_data); + } + + // A test that includes init weight scaling + { + let mut account = account.clone(); + let mut bank0 = bank0_data.clone(); + let mut health_cache = health_cache.clone(); + let tok0_deposits = I80F48::from_num(500.0); + health_cache.token_infos[0].balance_spot = tok0_deposits; + health_cache.token_infos[1].balance_spot = I80F48::from_num(-100.0); // 2 * 100 * 1.2 = 240 liab + + // This test case needs the bank to know about the deposits + let position = account.token_position_mut(bank0.token_index).unwrap().0; + bank0.deposit(position, tok0_deposits, now_ts).unwrap(); + + // Set up scaling such that token0 health contrib is 500 * 1.0 * 1.0 * (600 / (500 + 300)) = 375 + bank0.deposit_weight_scale_start_quote = 600.0; + bank0.potential_serum_tokens = 300; + health_cache.token_infos[0].init_scaled_asset_weight = + bank0.scaled_init_asset_weight(I80F48::ONE); + + check_max_borrow(&account, &health_cache, 100.0, &bank0); + check_max_borrow(&account, &health_cache, 50.0, &bank0); + + let max_borrow = check_max_borrow(&account, &health_cache, 0.0, &bank0); + // that borrow leaves 240 tokens in the account and <600 total in bank + assert!((260.0 - max_borrow).abs() < 0.3); + + bank0.deposit_weight_scale_start_quote = 500.0; + let max_borrow = check_max_borrow(&account, &health_cache, 0.0, &bank0); + // 500 - 222.6 = 277.4 remaining token 0 deposits + // 277.4 * 500 / (277.4 + 300) = 240.2 (compensating the -240 liab) + assert!((222.6 - max_borrow).abs() < 0.3); } } diff --git a/programs/mango-v4/src/instructions/flash_loan.rs b/programs/mango-v4/src/instructions/flash_loan.rs index 7fce888c5..5b2ecfc18 100644 --- a/programs/mango-v4/src/instructions/flash_loan.rs +++ b/programs/mango-v4/src/instructions/flash_loan.rs @@ -337,7 +337,7 @@ pub fn flash_loan_end<'key, 'accounts, 'remaining, 'info>( // Create the token position now, so we can compute the pre-health with fixed order health accounts let (_, raw_token_index, _) = account.ensure_token_position(bank.token_index)?; - // Transfer any excess over the inital balance of the token account back + // Transfer any excess over the initial balance of the token account back // into the vault. Compute the total change in the vault balance. let mut change = -I80F48::from(bank.flash_loan_approved_amount); if token_account.amount > bank.flash_loan_token_account_initial { @@ -378,10 +378,10 @@ pub fn flash_loan_end<'key, 'accounts, 'remaining, 'info>( match flash_loan_type { FlashLoanType::Unknown => {} - FlashLoanType::Swap => { + FlashLoanType::Swap | FlashLoanType::SwapWithoutFee => { require_msg!( changes.len() == 2, - "when flash_loan_type is Swap there must be exactly 2 token vault changes" + "when flash_loan_type is Swap or SwapWithoutFee there must be exactly 2 token vault changes" ) } } diff --git a/programs/mango-v4/src/instructions/group_edit.rs b/programs/mango-v4/src/instructions/group_edit.rs index 7eb631f61..e74df1c2a 100644 --- a/programs/mango-v4/src/instructions/group_edit.rs +++ b/programs/mango-v4/src/instructions/group_edit.rs @@ -19,6 +19,7 @@ pub fn group_edit( mngo_token_index_opt: Option, buyback_fees_expiry_interval_opt: Option, allowed_fast_listings_per_interval_opt: Option, + collateral_fee_interval_opt: Option, ) -> Result<()> { let mut group = ctx.accounts.group.load_mut()?; @@ -116,5 +117,14 @@ pub fn group_edit( group.allowed_fast_listings_per_interval = allowed_fast_listings_per_interval; } + if let Some(collateral_fee_interval) = collateral_fee_interval_opt { + msg!( + "Collateral fee interval old {:?}, new {:?}", + group.collateral_fee_interval, + collateral_fee_interval + ); + group.collateral_fee_interval = collateral_fee_interval; + } + Ok(()) } diff --git a/programs/mango-v4/src/instructions/ix_gate_set.rs b/programs/mango-v4/src/instructions/ix_gate_set.rs index 22c8255de..413b9ceff 100644 --- a/programs/mango-v4/src/instructions/ix_gate_set.rs +++ b/programs/mango-v4/src/instructions/ix_gate_set.rs @@ -95,6 +95,7 @@ pub fn ix_gate_set(ctx: Context, ix_gate: u128) -> Result<()> { IxGate::TokenConditionalSwapCreateLinearAuction, ); log_if_changed(&group, ix_gate, IxGate::Serum3PlaceOrderV2); + log_if_changed(&group, ix_gate, IxGate::TokenForceWithdraw); group.ix_gate = ix_gate; diff --git a/programs/mango-v4/src/instructions/mod.rs b/programs/mango-v4/src/instructions/mod.rs index 297eb5611..faa5d8e88 100644 --- a/programs/mango-v4/src/instructions/mod.rs +++ b/programs/mango-v4/src/instructions/mod.rs @@ -50,6 +50,7 @@ pub use stub_oracle_close::*; pub use stub_oracle_create::*; pub use stub_oracle_set::*; pub use token_add_bank::*; +pub use token_charge_collateral_fees::*; pub use token_conditional_swap_cancel::*; pub use token_conditional_swap_create::*; pub use token_conditional_swap_start::*; @@ -58,6 +59,7 @@ pub use token_deposit::*; pub use token_deregister::*; pub use token_edit::*; pub use token_force_close_borrows_with_token::*; +pub use token_force_withdraw::*; pub use token_liq_bankruptcy::*; pub use token_liq_with_token::*; pub use token_register::*; @@ -117,6 +119,7 @@ mod stub_oracle_close; mod stub_oracle_create; mod stub_oracle_set; mod token_add_bank; +mod token_charge_collateral_fees; mod token_conditional_swap_cancel; mod token_conditional_swap_create; mod token_conditional_swap_start; @@ -125,6 +128,7 @@ mod token_deposit; mod token_deregister; mod token_edit; mod token_force_close_borrows_with_token; +mod token_force_withdraw; mod token_liq_bankruptcy; mod token_liq_with_token; mod token_register; diff --git a/programs/mango-v4/src/instructions/token_charge_collateral_fees.rs b/programs/mango-v4/src/instructions/token_charge_collateral_fees.rs new file mode 100644 index 000000000..f58fbf1d3 --- /dev/null +++ b/programs/mango-v4/src/instructions/token_charge_collateral_fees.rs @@ -0,0 +1,129 @@ +use crate::accounts_zerocopy::*; +use crate::health::*; +use crate::state::*; +use anchor_lang::prelude::*; +use fixed::types::I80F48; + +use crate::accounts_ix::*; +use crate::logs::{emit_stack, TokenBalanceLog, TokenCollateralFeeLog}; + +pub fn token_charge_collateral_fees(ctx: Context) -> Result<()> { + let group = ctx.accounts.group.load()?; + let mut account = ctx.accounts.account.load_full_mut()?; + let now_ts: u64 = Clock::get()?.unix_timestamp.try_into().unwrap(); + + if group.collateral_fee_interval == 0 { + // By resetting, a new enabling of collateral fees will not immediately create a charge + account.fixed.last_collateral_fee_charge = 0; + return Ok(()); + } + + // When collateral fees are enabled the first time, don't immediately charge + if account.fixed.last_collateral_fee_charge == 0 { + account.fixed.last_collateral_fee_charge = now_ts; + return Ok(()); + } + + // Is the next fee-charging due? + let last_charge_ts = account.fixed.last_collateral_fee_charge; + if now_ts < last_charge_ts + group.collateral_fee_interval { + return Ok(()); + } + account.fixed.last_collateral_fee_charge = now_ts; + + // Charge the user at most for 2x the interval. So if no one calls this for a long time + // there won't be a huge charge based only on the end state. + let charge_seconds = (now_ts - last_charge_ts).min(2 * group.collateral_fee_interval); + + // The fees are configured in "interest per day" so we need to get the fraction of days + // that has passed since the last update for scaling + let inv_seconds_per_day = I80F48::from_num(1.157407407407e-5); // 1 / (24 * 60 * 60) + let time_scaling = I80F48::from(charge_seconds) * inv_seconds_per_day; + + let health_cache = { + let retriever = + new_fixed_order_account_retriever(ctx.remaining_accounts, &account.borrow())?; + new_health_cache(&account.borrow(), &retriever, now_ts)? + }; + + // We want to find the total asset health and total liab health, but don't want + // to treat borrows that moved into open orders accounts as realized. Hence we + // pretend all spot orders are closed and settled and add their funds back to + // the token positions. + let mut token_balances = health_cache.effective_token_balances(HealthType::Maint); + for s3info in health_cache.serum3_infos.iter() { + token_balances[s3info.base_info_index].spot_and_perp += s3info.reserved_base; + token_balances[s3info.quote_info_index].spot_and_perp += s3info.reserved_quote; + } + + let mut total_liab_health = I80F48::ZERO; + let mut total_asset_health = I80F48::ZERO; + for (info, balance) in health_cache.token_infos.iter().zip(token_balances.iter()) { + let health = info.health_contribution(HealthType::Maint, balance.spot_and_perp); + if health.is_positive() { + total_asset_health += health; + } else { + total_liab_health -= health; + } + } + + // If there's no assets or no liabs, we can't charge fees + if total_asset_health.is_zero() || total_liab_health.is_zero() { + return Ok(()); + } + + // Users only pay for assets that are actively used to cover their liabilities. + let asset_usage_scaling = (total_liab_health / total_asset_health) + .max(I80F48::ZERO) + .min(I80F48::ONE); + + let scaling = asset_usage_scaling * time_scaling; + + let token_position_count = account.active_token_positions().count(); + for bank_ai in &ctx.remaining_accounts[0..token_position_count] { + let mut bank = bank_ai.load_mut::()?; + if bank.collateral_fee_per_day <= 0.0 || bank.maint_asset_weight.is_zero() { + continue; + } + + let (token_position, raw_token_index) = account.token_position_mut(bank.token_index)?; + let token_balance = token_position.native(&bank); + if token_balance <= 0 { + continue; + } + + let fee = token_balance * scaling * I80F48::from_num(bank.collateral_fee_per_day); + assert!(fee <= token_balance); + + let is_active = bank.withdraw_without_fee(token_position, fee, now_ts)?; + if !is_active { + account.deactivate_token_position_and_log(raw_token_index, ctx.accounts.account.key()); + } + + bank.collected_fees_native += fee; + bank.collected_collateral_fees += fee; + + let token_info = health_cache.token_info(bank.token_index)?; + let token_position = account.token_position(bank.token_index)?; + + emit_stack(TokenCollateralFeeLog { + mango_group: ctx.accounts.group.key(), + mango_account: ctx.accounts.account.key(), + token_index: bank.token_index, + fee: fee.to_bits(), + asset_usage_fraction: asset_usage_scaling.to_bits(), + price: token_info.prices.oracle.to_bits(), + }); + + emit_stack(TokenBalanceLog { + mango_group: ctx.accounts.group.key(), + mango_account: ctx.accounts.account.key(), + token_index: bank.token_index, + indexed_position: token_position.indexed_position.to_bits(), + deposit_index: bank.deposit_index.to_bits(), + borrow_index: bank.borrow_index.to_bits(), + }) + } + + Ok(()) +} diff --git a/programs/mango-v4/src/instructions/token_edit.rs b/programs/mango-v4/src/instructions/token_edit.rs index e44e6c2ee..485aa70c8 100644 --- a/programs/mango-v4/src/instructions/token_edit.rs +++ b/programs/mango-v4/src/instructions/token_edit.rs @@ -53,6 +53,9 @@ pub fn token_edit( deposit_limit_opt: Option, zero_util_rate: Option, platform_liquidation_fee: Option, + disable_asset_liquidation_opt: Option, + collateral_fee_per_day: Option, + force_withdraw_opt: Option, ) -> Result<()> { let group = ctx.accounts.group.load()?; @@ -482,6 +485,43 @@ pub fn token_edit( platform_liquidation_fee ); bank.platform_liquidation_fee = I80F48::from_num(platform_liquidation_fee); +<<<<<<< HEAD +======= + if platform_liquidation_fee != 0.0 { + require_group_admin = true; + } + } + + if let Some(collateral_fee_per_day) = collateral_fee_per_day { + msg!( + "Collateral fee per day old {:?}, new {:?}", + bank.collateral_fee_per_day, + collateral_fee_per_day + ); + bank.collateral_fee_per_day = collateral_fee_per_day; + if collateral_fee_per_day != 0.0 { + require_group_admin = true; + } + } + + if let Some(disable_asset_liquidation) = disable_asset_liquidation_opt { + msg!( + "Asset liquidation disabled old {:?}, new {:?}", + bank.disable_asset_liquidation, + disable_asset_liquidation + ); + bank.disable_asset_liquidation = u8::from(disable_asset_liquidation); + require_group_admin = true; + } + + if let Some(force_withdraw) = force_withdraw_opt { + msg!( + "Force withdraw old {:?}, new {:?}", + bank.force_withdraw, + force_withdraw + ); + bank.force_withdraw = u8::from(force_withdraw); +>>>>>>> main require_group_admin = true; } } diff --git a/programs/mango-v4/src/instructions/token_force_withdraw.rs b/programs/mango-v4/src/instructions/token_force_withdraw.rs new file mode 100644 index 000000000..70eade859 --- /dev/null +++ b/programs/mango-v4/src/instructions/token_force_withdraw.rs @@ -0,0 +1,100 @@ +use crate::accounts_zerocopy::AccountInfoRef; +use crate::error::*; +use crate::state::*; +use anchor_lang::prelude::*; +use anchor_spl::token; +use fixed::types::I80F48; + +use crate::accounts_ix::*; +use crate::logs::{emit_stack, ForceWithdrawLog, TokenBalanceLog}; + +pub fn token_force_withdraw(ctx: Context) -> Result<()> { + let group = ctx.accounts.group.load()?; + let token_index = ctx.accounts.bank.load()?.token_index; + let now_ts: u64 = Clock::get()?.unix_timestamp.try_into().unwrap(); + + let mut bank = ctx.accounts.bank.load_mut()?; + require!(bank.is_force_withdraw(), MangoError::SomeError); + + let mut account = ctx.accounts.account.load_full_mut()?; + + let withdraw_target = if ctx.accounts.owner_ata_token_account.owner == account.fixed.owner { + ctx.accounts.owner_ata_token_account.to_account_info() + } else { + ctx.accounts.alternate_owner_token_account.to_account_info() + }; + + let (position, raw_token_index) = account.token_position_mut(token_index)?; + let native_position = position.native(&bank); + + // Check >= to allow calling this on 0 deposits to close the token position + require_gte!(native_position, I80F48::ZERO); + let amount = native_position.floor().to_num::(); + let amount_i80f48 = I80F48::from(amount); + + // Update the bank and position + let position_is_active = bank.withdraw_without_fee(position, amount_i80f48, now_ts)?; + + // Provide a readable error message in case the vault doesn't have enough tokens + if ctx.accounts.vault.amount < amount { + return err!(MangoError::InsufficentBankVaultFunds).with_context(|| { + format!( + "bank vault does not have enough tokens, need {} but have {}", + amount, ctx.accounts.vault.amount + ) + }); + } + + // Transfer the actual tokens + let group_seeds = group_seeds!(group); + token::transfer( + CpiContext::new( + ctx.accounts.token_program.to_account_info(), + token::Transfer { + from: ctx.accounts.vault.to_account_info(), + to: withdraw_target.clone(), + authority: ctx.accounts.group.to_account_info(), + }, + ) + .with_signer(&[group_seeds]), + amount, + )?; + + emit_stack(TokenBalanceLog { + mango_group: ctx.accounts.group.key(), + mango_account: ctx.accounts.account.key(), + token_index, + indexed_position: position.indexed_position.to_bits(), + deposit_index: bank.deposit_index.to_bits(), + borrow_index: bank.borrow_index.to_bits(), + }); + + // Get the oracle price, even if stale or unconfident: We want to allow force withdraws + // even if the oracle is bad. + let oracle_ref = &AccountInfoRef::borrow(ctx.accounts.oracle.as_ref())?; + let unsafe_oracle_state = oracle_state_unchecked( + &OracleAccountInfos::from_reader(oracle_ref), + bank.mint_decimals, + )?; + + // Update the net deposits - adjust by price so different tokens are on the same basis (in USD terms) + let amount_usd = (amount_i80f48 * unsafe_oracle_state.price).to_num::(); + account.fixed.net_deposits -= amount_usd; + + if !position_is_active { + account.deactivate_token_position_and_log(raw_token_index, ctx.accounts.account.key()); + } + + emit_stack(ForceWithdrawLog { + mango_group: ctx.accounts.group.key(), + mango_account: ctx.accounts.account.key(), + token_index, + quantity: amount, + price: unsafe_oracle_state.price.to_bits(), + to_token_account: withdraw_target.key(), + }); + + bank.enforce_borrows_lte_deposits()?; + + Ok(()) +} diff --git a/programs/mango-v4/src/instructions/token_liq_with_token.rs b/programs/mango-v4/src/instructions/token_liq_with_token.rs index c40a3a3c7..c064e2216 100644 --- a/programs/mango-v4/src/instructions/token_liq_with_token.rs +++ b/programs/mango-v4/src/instructions/token_liq_with_token.rs @@ -112,6 +112,10 @@ pub(crate) fn liquidation_action( liqee.token_position_and_raw_index(asset_token_index)?; let liqee_asset_native = liqee_asset_position.native(asset_bank); require_gt!(liqee_asset_native, 0); + require!( + asset_bank.allows_asset_liquidation(), + MangoError::TokenAssetLiquidationDisabled + ); let (liqee_liab_position, liqee_liab_raw_index) = liqee.token_position_and_raw_index(liab_token_index)?; diff --git a/programs/mango-v4/src/instructions/token_register.rs b/programs/mango-v4/src/instructions/token_register.rs index e9b6f5b2f..7d545ad97 100644 --- a/programs/mango-v4/src/instructions/token_register.rs +++ b/programs/mango-v4/src/instructions/token_register.rs @@ -44,6 +44,8 @@ pub fn token_register( deposit_limit: u64, zero_util_rate: f32, platform_liquidation_fee: f32, + disable_asset_liquidation: bool, + collateral_fee_per_day: f32, ) -> Result<()> { // Require token 0 to be in the insurance token if token_index == INSURANCE_TOKEN_INDEX { @@ -109,6 +111,8 @@ pub fn token_register( deposit_weight_scale_start_quote, reduce_only, force_close: 0, + disable_asset_liquidation: u8::from(disable_asset_liquidation), + force_withdraw: 0, padding: Default::default(), fees_withdrawn: 0, token_conditional_swap_taker_fee_rate, @@ -127,7 +131,9 @@ pub fn token_register( zero_util_rate: I80F48::from_num(zero_util_rate), platform_liquidation_fee: I80F48::from_num(platform_liquidation_fee), collected_liquidation_fees: I80F48::ZERO, - reserved: [0; 1920], + collected_collateral_fees: I80F48::ZERO, + collateral_fee_per_day, + reserved: [0; 1900], }; let oracle_ref = &AccountInfoRef::borrow(ctx.accounts.oracle.as_ref())?; diff --git a/programs/mango-v4/src/instructions/token_register_trustless.rs b/programs/mango-v4/src/instructions/token_register_trustless.rs index fa2f9a3f1..6b6284228 100644 --- a/programs/mango-v4/src/instructions/token_register_trustless.rs +++ b/programs/mango-v4/src/instructions/token_register_trustless.rs @@ -90,6 +90,8 @@ pub fn token_register_trustless( deposit_weight_scale_start_quote: 5_000_000_000.0, // $5k reduce_only: 2, // deposit-only force_close: 0, + disable_asset_liquidation: 1, + force_withdraw: 0, padding: Default::default(), fees_withdrawn: 0, token_conditional_swap_taker_fee_rate: 0.0, @@ -107,7 +109,9 @@ pub fn token_register_trustless( deposit_limit: 0, zero_util_rate: I80F48::ZERO, collected_liquidation_fees: I80F48::ZERO, - reserved: [0; 1920], + collected_collateral_fees: I80F48::ZERO, + collateral_fee_per_day: 0.0, // TODO + reserved: [0; 1900], }; let oracle_ref = &AccountInfoRef::borrow(ctx.accounts.oracle.as_ref())?; if let Ok(oracle_price) = bank.oracle_price(&OracleAccountInfos::from_reader(oracle_ref), None) diff --git a/programs/mango-v4/src/lib.rs b/programs/mango-v4/src/lib.rs index bc5deb976..b1f4b9102 100644 --- a/programs/mango-v4/src/lib.rs +++ b/programs/mango-v4/src/lib.rs @@ -84,6 +84,7 @@ pub mod mango_v4 { mngo_token_index_opt: Option, buyback_fees_expiry_interval_opt: Option, allowed_fast_listings_per_interval_opt: Option, + collateral_fee_interval_opt: Option, ) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::group_edit( @@ -100,6 +101,7 @@ pub mod mango_v4 { mngo_token_index_opt, buyback_fees_expiry_interval_opt, allowed_fast_listings_per_interval_opt, + collateral_fee_interval_opt, )?; Ok(()) } @@ -157,6 +159,8 @@ pub mod mango_v4 { deposit_limit: u64, zero_util_rate: f32, platform_liquidation_fee: f32, + disable_asset_liquidation: bool, + collateral_fee_per_day: f32, ) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::token_register( @@ -190,6 +194,8 @@ pub mod mango_v4 { deposit_limit, zero_util_rate, platform_liquidation_fee, + disable_asset_liquidation, + collateral_fee_per_day, )?; Ok(()) } @@ -245,6 +251,9 @@ pub mod mango_v4 { deposit_limit_opt: Option, zero_util_rate_opt: Option, platform_liquidation_fee_opt: Option, + disable_asset_liquidation_opt: Option, + collateral_fee_per_day_opt: Option, + force_withdraw_opt: Option, ) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::token_edit( @@ -287,6 +296,9 @@ pub mod mango_v4 { deposit_limit_opt, zero_util_rate_opt, platform_liquidation_fee_opt, + disable_asset_liquidation_opt, + collateral_fee_per_day_opt, + force_withdraw_opt, )?; Ok(()) } @@ -807,6 +819,12 @@ pub mod mango_v4 { Ok(()) } + pub fn token_force_withdraw(ctx: Context) -> Result<()> { + #[cfg(feature = "enable-gpl")] + instructions::token_force_withdraw(ctx)?; + Ok(()) + } + /// /// Perps /// @@ -1605,6 +1623,12 @@ pub mod mango_v4 { Ok(()) } + pub fn token_charge_collateral_fees(ctx: Context) -> Result<()> { + #[cfg(feature = "enable-gpl")] + instructions::token_charge_collateral_fees(ctx)?; + Ok(()) + } + pub fn alt_set(ctx: Context, index: u8) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::alt_set(ctx, index)?; diff --git a/programs/mango-v4/src/logs.rs b/programs/mango-v4/src/logs.rs index 8eeb68627..9032b3bc6 100644 --- a/programs/mango-v4/src/logs.rs +++ b/programs/mango-v4/src/logs.rs @@ -795,3 +795,23 @@ pub struct TokenConditionalSwapStartLog { pub incentive_token_index: u16, pub incentive_amount: u64, } + +#[event] +pub struct TokenCollateralFeeLog { + pub mango_group: Pubkey, + pub mango_account: Pubkey, + pub token_index: u16, + pub asset_usage_fraction: i128, + pub fee: i128, + pub price: i128, +} + +#[event] +pub struct ForceWithdrawLog { + pub mango_group: Pubkey, + pub mango_account: Pubkey, + pub token_index: u16, + pub quantity: u64, + pub price: i128, // I80F48 + pub to_token_account: Pubkey, +} diff --git a/programs/mango-v4/src/state/bank.rs b/programs/mango-v4/src/state/bank.rs index 9261aaaa5..cfc6aea3d 100644 --- a/programs/mango-v4/src/state/bank.rs +++ b/programs/mango-v4/src/state/bank.rs @@ -158,8 +158,14 @@ pub struct Bank { pub reduce_only: u8, pub force_close: u8, + /// If set to 1, deposits cannot be liquidated when an account is liquidatable. + /// That means bankrupt accounts may still have assets of this type deposited. + pub disable_asset_liquidation: u8, + + pub force_withdraw: u8, + #[derivative(Debug = "ignore")] - pub padding: [u8; 6], + pub padding: [u8; 4], // Do separate bookkeping for how many tokens were withdrawn // This ensures that collected_fees_native is strictly increasing for stats gathering purposes @@ -217,8 +223,16 @@ pub struct Bank { /// See also collected_fees_native and fees_withdrawn. pub collected_liquidation_fees: I80F48, + /// Collateral fees that have been collected (in native tokens) + /// + /// See also collected_fees_native and fees_withdrawn. + pub collected_collateral_fees: I80F48, + + /// The daily collateral fees rate for fully utilized collateral. + pub collateral_fee_per_day: f32, + #[derivative(Debug = "ignore")] - pub reserved: [u8; 1920], + pub reserved: [u8; 1900], } const_assert_eq!( size_of::(), @@ -255,8 +269,9 @@ const_assert_eq!( + 16 * 3 + 32 + 8 - + 16 * 3 - + 1920 + + 16 * 4 + + 4 + + 1900 ); const_assert_eq!(size_of::(), 3064); const_assert_eq!(size_of::() % 8, 0); @@ -300,6 +315,7 @@ impl Bank { indexed_borrows: I80F48::ZERO, collected_fees_native: I80F48::ZERO, collected_liquidation_fees: I80F48::ZERO, + collected_collateral_fees: I80F48::ZERO, fees_withdrawn: 0, dust: I80F48::ZERO, flash_loan_approved_amount: 0, @@ -346,7 +362,9 @@ impl Bank { deposit_weight_scale_start_quote: existing_bank.deposit_weight_scale_start_quote, reduce_only: existing_bank.reduce_only, force_close: existing_bank.force_close, - padding: [0; 6], + disable_asset_liquidation: existing_bank.disable_asset_liquidation, + force_withdraw: existing_bank.force_withdraw, + padding: [0; 4], token_conditional_swap_taker_fee_rate: existing_bank .token_conditional_swap_taker_fee_rate, token_conditional_swap_maker_fee_rate: existing_bank @@ -363,7 +381,8 @@ impl Bank { deposit_limit: existing_bank.deposit_limit, zero_util_rate: existing_bank.zero_util_rate, platform_liquidation_fee: existing_bank.platform_liquidation_fee, - reserved: [0; 1920], + collateral_fee_per_day: existing_bank.collateral_fee_per_day, + reserved: [0; 1900], } } @@ -375,14 +394,18 @@ impl Bank { require_gte!(self.rate0, I80F48::ZERO); require_gte!(self.rate1, I80F48::ZERO); require_gte!(self.max_rate, I80F48::ZERO); + require_gte!(self.adjustment_factor, 0.0); require_gte!(self.loan_fee_rate, 0.0); require_gte!(self.loan_origination_fee_rate, 0.0); - require_gte!(self.maint_asset_weight, 0.0); + require_gte!(self.stable_price_model.delay_growth_limit, 0.0); + require_gte!(self.stable_price_model.stable_growth_limit, 0.0); require_gte!(self.init_asset_weight, 0.0); + require_gte!(self.maint_asset_weight, self.init_asset_weight); require_gte!(self.maint_liab_weight, 0.0); - require_gte!(self.init_liab_weight, 0.0); + require_gte!(self.init_liab_weight, self.maint_liab_weight); require_gte!(self.liquidation_fee, 0.0); require_gte!(self.min_vault_to_deposits_ratio, 0.0); + require_gte!(1.0, self.min_vault_to_deposits_ratio); require_gte!(self.net_borrow_limit_per_window_quote, -1); require_gt!(self.borrow_weight_scale_start_quote, 0.0); require_gt!(self.deposit_weight_scale_start_quote, 0.0); @@ -392,11 +415,22 @@ impl Bank { require_gte!(self.flash_loan_swap_fee_rate, 0.0); require_gte!(self.interest_curve_scaling, 1.0); require_gte!(self.interest_target_utilization, 0.0); + require_gte!(1.0, self.interest_target_utilization); require_gte!(self.maint_weight_shift_duration_inv, 0.0); require_gte!(self.maint_weight_shift_asset_target, 0.0); require_gte!(self.maint_weight_shift_liab_target, 0.0); require_gte!(self.zero_util_rate, I80F48::ZERO); require_gte!(self.platform_liquidation_fee, 0.0); + if !self.allows_asset_liquidation() { + require!(self.are_borrows_reduce_only(), MangoError::SomeError); + require_eq!(self.maint_asset_weight, I80F48::ZERO); + } + require_gte!(self.collateral_fee_per_day, 0.0); + if self.is_force_withdraw() { + require!(self.are_deposits_reduce_only(), MangoError::SomeError); + require!(!self.allows_asset_liquidation(), MangoError::SomeError); + require_eq!(self.maint_asset_weight, I80F48::ZERO); + } Ok(()) } @@ -418,6 +452,14 @@ impl Bank { self.force_close == 1 } + pub fn is_force_withdraw(&self) -> bool { + self.force_withdraw == 1 + } + + pub fn allows_asset_liquidation(&self) -> bool { + self.disable_asset_liquidation == 0 + } + #[inline(always)] pub fn native_borrows(&self) -> I80F48 { self.borrow_index * self.indexed_borrows @@ -732,7 +774,7 @@ impl Bank { }) } - // withdraw the loan origination fee for a borrow that happenend earlier + // withdraw the loan origination fee for a borrow that happened earlier pub fn withdraw_loan_origination_fee( &mut self, position: &mut TokenPosition, @@ -1052,7 +1094,7 @@ impl Bank { ) } - /// calcualtor function that can be used to compute an interest + /// calculator function that can be used to compute an interest /// rate based on the given parameters #[inline(always)] pub fn interest_rate_curve_calculator( diff --git a/programs/mango-v4/src/state/group.rs b/programs/mango-v4/src/state/group.rs index b0e55a987..19fc8db03 100644 --- a/programs/mango-v4/src/state/group.rs +++ b/programs/mango-v4/src/state/group.rs @@ -98,11 +98,32 @@ pub struct Group { /// Number of fast listings that are allowed per interval pub allowed_fast_listings_per_interval: u16, - pub reserved: [u8; 1812], + pub padding2: [u8; 4], + + /// Intervals in which collateral fee is applied + pub collateral_fee_interval: u64, + + pub reserved: [u8; 1800], } const_assert_eq!( size_of::(), - 32 + 4 + 32 * 2 + 4 + 32 * 2 + 4 + 4 + 20 * 32 + 32 + 8 + 16 + 32 + 8 + 8 + 2 * 2 + 1812 + 32 + 4 + + 32 * 2 + + 4 + + 32 * 2 + + 4 + + 4 + + 20 * 32 + + 32 + + 8 + + 16 + + 32 + + 8 + + 8 + + 2 * 2 + + 4 + + 8 + + 1800 ); const_assert_eq!(size_of::(), 2736); const_assert_eq!(size_of::() % 8, 0); @@ -224,6 +245,7 @@ pub enum IxGate { TokenConditionalSwapCreatePremiumAuction = 69, TokenConditionalSwapCreateLinearAuction = 70, Serum3PlaceOrderV2 = 71, + TokenForceWithdraw = 72, // NOTE: Adding new variants requires matching changes in ts and the ix_gate_set instruction. } diff --git a/programs/mango-v4/src/state/mango_account.rs b/programs/mango-v4/src/state/mango_account.rs index 9a759b250..99ea08781 100644 --- a/programs/mango-v4/src/state/mango_account.rs +++ b/programs/mango-v4/src/state/mango_account.rs @@ -86,7 +86,7 @@ impl MangoAccountPdaSeeds { // When not reading via idl, MangoAccount binary data is backwards compatible: when ignoring trailing bytes, // a v2 account can be read as a v1 account and a v3 account can be read as v1 or v2 etc. #[account] -#[derive(Derivative)] +#[derive(Derivative, PartialEq)] #[derivative(Debug)] pub struct MangoAccount { // fixed @@ -151,8 +151,14 @@ pub struct MangoAccount { /// Next id to use when adding a token condition swap pub next_token_conditional_swap_id: u64, + pub temporary_delegate: Pubkey, + pub temporary_delegate_expiry: u64, + + /// Time at which the last collateral fee was charged + pub last_collateral_fee_charge: u64, + #[derivative(Debug = "ignore")] - pub reserved: [u8; 200], + pub reserved: [u8; 152], // dynamic pub header_version: u8, @@ -203,7 +209,10 @@ impl MangoAccount { buyback_fees_accrued_previous: 0, buyback_fees_expiry_timestamp: 0, next_token_conditional_swap_id: 0, - reserved: [0; 200], + temporary_delegate: Pubkey::default(), + temporary_delegate_expiry: 0, + last_collateral_fee_charge: 0, + reserved: [0; 152], header_version: DEFAULT_MANGO_ACCOUNT_VERSION, padding3: Default::default(), padding4: Default::default(), @@ -327,11 +336,12 @@ pub struct MangoAccountFixed { pub next_token_conditional_swap_id: u64, pub temporary_delegate: Pubkey, pub temporary_delegate_expiry: u64, - pub reserved: [u8; 160], + pub last_collateral_fee_charge: u64, + pub reserved: [u8; 152], } const_assert_eq!( size_of::(), - 32 * 4 + 8 + 8 * 8 + 32 + 8 + 160 + 32 * 4 + 8 + 8 * 8 + 32 + 8 + 8 + 152 ); const_assert_eq!(size_of::(), 400); const_assert_eq!(size_of::() % 8, 0); @@ -737,6 +747,12 @@ impl< self.dynamic.deref_or_borrow() } + #[allow(dead_code)] + fn dynamic_reserved_bytes(&self) -> &[u8] { + let reserved_offset = self.header().reserved_bytes_offset(); + &self.dynamic()[reserved_offset..reserved_offset + DYNAMIC_RESERVED_BYTES] + } + /// Returns /// - the position /// - the raw index into the token positions list (for use with get_raw/deactivate) @@ -1155,6 +1171,7 @@ impl< } } + // Only used in unit tests pub fn deactivate_perp_position( &mut self, perp_market_index: PerpMarketIndex, @@ -1196,6 +1213,19 @@ impl< Ok(()) } + pub fn find_first_active_unused_perp_position(&self) -> Option<&PerpPosition> { + let first_unused_position_opt = self.all_perp_positions().find(|p| { + p.is_active() + && p.base_position_lots == 0 + && p.quote_position_native == 0 + && p.bids_base_lots == 0 + && p.asks_base_lots == 0 + && p.taker_base_lots == 0 + && p.taker_quote_lots == 0 + }); + first_unused_position_opt + } + pub fn add_perp_order( &mut self, perp_market_index: PerpMarketIndex, @@ -1852,6 +1882,7 @@ impl<'a, 'info: 'a> MangoAccountLoader<'a> for &'a AccountLoader<'info, MangoAcc mod tests { use bytemuck::Zeroable; use itertools::Itertools; + use std::path::PathBuf; use crate::state::PostOrderType; @@ -2378,12 +2409,7 @@ mod tests { ); } - let reserved_offset = account.header.reserved_bytes_offset(); - assert!( - account.dynamic[reserved_offset..reserved_offset + DYNAMIC_RESERVED_BYTES] - .iter() - .all(|&v| v == 0) - ); + assert!(account.dynamic_reserved_bytes().iter().all(|&v| v == 0)); Ok(()) } @@ -2808,4 +2834,118 @@ mod tests { Ok(()) } + + #[test] + fn test_perp_auto_close_first_unused() { + let mut account = make_test_account(); + + // Fill all perp slots + assert_eq!(account.header.perp_count, 4); + account.ensure_perp_position(1, 0).unwrap(); + account.ensure_perp_position(2, 0).unwrap(); + account.ensure_perp_position(3, 0).unwrap(); + account.ensure_perp_position(4, 0).unwrap(); + assert_eq!(account.active_perp_positions().count(), 4); + + // Force usage of some perp slot (leaves 3 unused) + account.perp_position_mut(1).unwrap().taker_base_lots = 10; + account.perp_position_mut(2).unwrap().base_position_lots = 10; + account.perp_position_mut(4).unwrap().quote_position_native = I80F48::from_num(10); + assert!(account.perp_position(3).ok().is_some()); + + // Should not succeed anymore + { + let e = account.ensure_perp_position(5, 0); + assert!(e.is_anchor_error_with_code(MangoError::NoFreePerpPositionIndex.error_code())); + } + + // Act + let to_be_closed_account_opt = account.find_first_active_unused_perp_position(); + + assert_eq!(to_be_closed_account_opt.unwrap().market_index, 3) + } + + // Attempts reading old mango account data with borsh and with zerocopy + #[test] + fn test_mango_account_backwards_compatibility() -> Result<()> { + use solana_program_test::{find_file, read_file}; + + let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + d.push("resources/test"); + + // Grab live accounts with + // solana account CZGf1qbYPaSoabuA1EmdN8W5UHvH5CeXcNZ7RTx65aVQ --output-file programs/mango-v4/resources/test/mangoaccount-v0.21.3.bin + let fixtures = vec!["mangoaccount-v0.21.3"]; + + for fixture in fixtures { + let filename = format!("resources/test/{}.bin", fixture); + let account_bytes = read_file(find_file(&filename).unwrap()); + + // Read with borsh + let mut account_bytes_slice: &[u8] = &account_bytes; + let borsh_account = MangoAccount::try_deserialize(&mut account_bytes_slice)?; + + // Read with zerocopy + let zerocopy_reader = MangoAccountValue::from_bytes(&account_bytes[8..])?; + let fixed = &zerocopy_reader.fixed; + let zerocopy_account = MangoAccount { + group: fixed.group, + owner: fixed.owner, + name: fixed.name, + delegate: fixed.delegate, + account_num: fixed.account_num, + being_liquidated: fixed.being_liquidated, + in_health_region: fixed.in_health_region, + bump: fixed.bump, + padding: Default::default(), + net_deposits: fixed.net_deposits, + perp_spot_transfers: fixed.perp_spot_transfers, + health_region_begin_init_health: fixed.health_region_begin_init_health, + frozen_until: fixed.frozen_until, + buyback_fees_accrued_current: fixed.buyback_fees_accrued_current, + buyback_fees_accrued_previous: fixed.buyback_fees_accrued_previous, + buyback_fees_expiry_timestamp: fixed.buyback_fees_expiry_timestamp, + next_token_conditional_swap_id: fixed.next_token_conditional_swap_id, + temporary_delegate: fixed.temporary_delegate, + temporary_delegate_expiry: fixed.temporary_delegate_expiry, + last_collateral_fee_charge: fixed.last_collateral_fee_charge, + reserved: [0u8; 152], + + header_version: *zerocopy_reader.header_version(), + padding3: Default::default(), + + padding4: Default::default(), + tokens: zerocopy_reader.all_token_positions().cloned().collect_vec(), + + padding5: Default::default(), + serum3: zerocopy_reader.all_serum3_orders().cloned().collect_vec(), + + padding6: Default::default(), + perps: zerocopy_reader.all_perp_positions().cloned().collect_vec(), + + padding7: Default::default(), + perp_open_orders: zerocopy_reader.all_perp_orders().cloned().collect_vec(), + + padding8: Default::default(), + token_conditional_swaps: zerocopy_reader + .all_token_conditional_swaps() + .cloned() + .collect_vec(), + + reserved_dynamic: zerocopy_reader.dynamic_reserved_bytes().try_into().unwrap(), + }; + + // Both methods agree? + assert_eq!(borsh_account, zerocopy_account); + + // Serializing and deserializing produces the same data? + let mut borsh_bytes = Vec::new(); + borsh_account.try_serialize(&mut borsh_bytes)?; + let mut slice: &[u8] = &borsh_bytes; + let roundtrip_account = MangoAccount::try_deserialize(&mut slice)?; + assert_eq!(borsh_account, roundtrip_account); + } + + Ok(()) + } } diff --git a/programs/mango-v4/src/state/mango_account_components.rs b/programs/mango-v4/src/state/mango_account_components.rs index 45e97b09e..987f0e8a7 100644 --- a/programs/mango-v4/src/state/mango_account_components.rs +++ b/programs/mango-v4/src/state/mango_account_components.rs @@ -12,7 +12,7 @@ use crate::state::*; pub const FREE_ORDER_SLOT: PerpMarketIndex = PerpMarketIndex::MAX; #[zero_copy] -#[derive(AnchorDeserialize, AnchorSerialize, Derivative)] +#[derive(AnchorDeserialize, AnchorSerialize, Derivative, PartialEq)] #[derivative(Debug)] pub struct TokenPosition { // TODO: Why did we have deposits and borrows as two different values @@ -110,7 +110,7 @@ impl TokenPosition { } #[zero_copy] -#[derive(AnchorSerialize, AnchorDeserialize, Derivative)] +#[derive(AnchorSerialize, AnchorDeserialize, Derivative, PartialEq)] #[derivative(Debug)] pub struct Serum3Orders { pub open_orders: Pubkey, @@ -203,7 +203,7 @@ impl Default for Serum3Orders { } #[zero_copy] -#[derive(AnchorSerialize, AnchorDeserialize, Derivative)] +#[derive(AnchorSerialize, AnchorDeserialize, Derivative, PartialEq)] #[derivative(Debug)] pub struct PerpPosition { pub market_index: PerpMarketIndex, @@ -800,7 +800,7 @@ impl PerpPosition { } #[zero_copy] -#[derive(AnchorSerialize, AnchorDeserialize, Derivative)] +#[derive(AnchorSerialize, AnchorDeserialize, Derivative, PartialEq)] #[derivative(Debug)] pub struct PerpOpenOrder { pub side_and_tree: u8, // SideAndOrderTree -- enums aren't POD diff --git a/programs/mango-v4/src/state/oracle.rs b/programs/mango-v4/src/state/oracle.rs index f69e2d9fc..fc4106941 100644 --- a/programs/mango-v4/src/state/oracle.rs +++ b/programs/mango-v4/src/state/oracle.rs @@ -82,7 +82,7 @@ pub mod sol_mint_mainnet { } #[zero_copy] -#[derive(AnchorDeserialize, AnchorSerialize, Derivative)] +#[derive(AnchorDeserialize, AnchorSerialize, Derivative, PartialEq, Eq)] #[derivative(Debug)] pub struct OracleConfig { pub conf_filter: I80F48, @@ -94,7 +94,7 @@ const_assert_eq!(size_of::(), 16 + 8 + 72); const_assert_eq!(size_of::(), 96); const_assert_eq!(size_of::() % 8, 0); -#[derive(AnchorDeserialize, AnchorSerialize, Debug)] +#[derive(AnchorDeserialize, AnchorSerialize, Debug, Default)] pub struct OracleConfigParams { pub conf_filter: f32, pub max_staleness_slots: Option, @@ -278,7 +278,7 @@ fn get_pyth_state( pub struct OracleAccountInfos<'a, T: KeyedAccountReader> { pub oracle: &'a T, pub fallback_opt: Option<&'a T>, - pub usd_opt: Option<&'a T>, + pub usdc_opt: Option<&'a T>, pub sol_opt: Option<&'a T>, } @@ -287,7 +287,7 @@ impl<'a, T: KeyedAccountReader> OracleAccountInfos<'a, T> { OracleAccountInfos { oracle: acc_reader, fallback_opt: None, - usd_opt: None, + usdc_opt: None, sol_opt: None, } } @@ -406,9 +406,7 @@ fn oracle_state_unchecked_inner( OracleType::OrcaCLMM => { let whirlpool = load_whirlpool_state(oracle_info)?; - let inverted = whirlpool.token_mint_a == usdc_mint_mainnet::ID - || (whirlpool.token_mint_a == sol_mint_mainnet::ID - && whirlpool.token_mint_b != usdc_mint_mainnet::ID); + let inverted = whirlpool.is_inverted(); let quote_state = if inverted { quote_state_unchecked(acc_infos, &whirlpool.token_mint_a)? } else { @@ -441,7 +439,7 @@ fn quote_state_unchecked( ) -> Result { if quote_mint == &usdc_mint_mainnet::ID { let usd_feed = acc_infos - .usd_opt + .usdc_opt .ok_or_else(|| error!(MangoError::MissingFeedForCLMMOracle))?; let usd_state = get_pyth_state(usd_feed, QUOTE_DECIMALS as u8)?; return Ok(usd_state); @@ -590,13 +588,13 @@ mod tests { let usdc_ais = OracleAccountInfos { oracle: usdc_ai, fallback_opt: None, - usd_opt: None, + usdc_opt: None, sol_opt: None, }; let orca_ais = OracleAccountInfos { oracle: ai, fallback_opt: None, - usd_opt: Some(usdc_ai), + usdc_opt: Some(usdc_ai), sol_opt: None, }; let usdc = oracle_state_unchecked(&usdc_ais, usdc_decimals).unwrap(); @@ -635,7 +633,7 @@ mod tests { let oracle_infos = OracleAccountInfos { oracle: ai, fallback_opt: None, - usd_opt: None, + usdc_opt: None, sol_opt: None, }; assert!(oracle_state_unchecked(&oracle_infos, base_decimals) diff --git a/programs/mango-v4/src/state/orca_cpi.rs b/programs/mango-v4/src/state/orca_cpi.rs index 19c14d870..f33f7ff0b 100644 --- a/programs/mango-v4/src/state/orca_cpi.rs +++ b/programs/mango-v4/src/state/orca_cpi.rs @@ -3,6 +3,10 @@ use solana_program::pubkey::Pubkey; use crate::{accounts_zerocopy::KeyedAccountReader, error::MangoError}; +use super::{ + pyth_mainnet_sol_oracle, pyth_mainnet_usdc_oracle, sol_mint_mainnet, usdc_mint_mainnet, +}; + pub mod orca_mainnet_whirlpool { use solana_program::declare_id; declare_id!("whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc"); @@ -18,6 +22,30 @@ pub struct WhirlpoolState { pub token_mint_b: Pubkey, // 32 } +impl WhirlpoolState { + pub fn is_inverted(&self) -> bool { + self.token_mint_a == usdc_mint_mainnet::ID + || (self.token_mint_a == sol_mint_mainnet::ID + && self.token_mint_b != usdc_mint_mainnet::ID) + } + + pub fn get_quote_oracle(&self) -> Result { + let mint = if self.is_inverted() { + self.token_mint_a + } else { + self.token_mint_b + }; + + if mint == usdc_mint_mainnet::ID { + return Ok(pyth_mainnet_usdc_oracle::ID); + } else if mint == sol_mint_mainnet::ID { + return Ok(pyth_mainnet_sol_oracle::ID); + } else { + return Err(MangoError::MissingFeedForCLMMOracle.into()); + } + } +} + pub fn load_whirlpool_state(acc_info: &impl KeyedAccountReader) -> Result { let data = &acc_info.data(); require!( diff --git a/programs/mango-v4/src/state/token_conditional_swap.rs b/programs/mango-v4/src/state/token_conditional_swap.rs index 190485769..c14cb2724 100644 --- a/programs/mango-v4/src/state/token_conditional_swap.rs +++ b/programs/mango-v4/src/state/token_conditional_swap.rs @@ -45,7 +45,7 @@ pub enum TokenConditionalSwapType { } #[zero_copy] -#[derive(AnchorDeserialize, AnchorSerialize, Derivative)] +#[derive(AnchorDeserialize, AnchorSerialize, Derivative, PartialEq)] #[derivative(Debug)] pub struct TokenConditionalSwap { pub id: u64, diff --git a/programs/mango-v4/tests/cases/mod.rs b/programs/mango-v4/tests/cases/mod.rs index 3b6d5324c..3d2c3b175 100644 --- a/programs/mango-v4/tests/cases/mod.rs +++ b/programs/mango-v4/tests/cases/mod.rs @@ -17,6 +17,7 @@ mod test_bankrupt_tokens; mod test_basic; mod test_benchmark; mod test_borrow_limits; +mod test_collateral_fees; mod test_delegate; mod test_fees_buyback_with_mngo; mod test_force_close; diff --git a/programs/mango-v4/tests/cases/test_basic.rs b/programs/mango-v4/tests/cases/test_basic.rs index 6cc9e1a6d..dbbbbfa76 100644 --- a/programs/mango-v4/tests/cases/test_basic.rs +++ b/programs/mango-v4/tests/cases/test_basic.rs @@ -462,6 +462,8 @@ async fn test_bank_maint_weight_shift() -> Result<(), TransportError> { mint: mints[0].pubkey, fallback_oracle: Pubkey::default(), options: mango_v4::instruction::TokenEdit { + init_asset_weight_opt: Some(0.0), + init_liab_weight_opt: Some(2.0), maint_weight_shift_start_opt: Some(start_time + 1000), maint_weight_shift_end_opt: Some(start_time + 2000), maint_weight_shift_asset_target_opt: Some(0.5), diff --git a/programs/mango-v4/tests/cases/test_collateral_fees.rs b/programs/mango-v4/tests/cases/test_collateral_fees.rs new file mode 100644 index 000000000..5d069f023 --- /dev/null +++ b/programs/mango-v4/tests/cases/test_collateral_fees.rs @@ -0,0 +1,218 @@ +#![allow(unused_assignments)] +use super::*; + +#[tokio::test] +async fn test_collateral_fees() -> Result<(), TransportError> { + let context = TestContext::new().await; + let solana = &context.solana.clone(); + + let admin = TestKeypair::new(); + let owner = context.users[0].key; + let payer = context.users[1].key; + let mints = &context.mints[0..2]; + + let mango_setup::GroupWithTokens { group, tokens, .. } = mango_setup::GroupWithTokensConfig { + admin, + payer, + mints: mints.to_vec(), + ..mango_setup::GroupWithTokensConfig::default() + } + .create(solana) + .await; + + // fund the vaults to allow borrowing + create_funded_account( + &solana, + group, + owner, + 0, + &context.users[1], + mints, + 1_000_000, + 0, + ) + .await; + + let account = create_funded_account( + &solana, + group, + owner, + 1, + &context.users[1], + &mints[0..1], + 1_500, // maint: 0.8 * 1500 = 1200 + 0, + ) + .await; + + let empty_account = create_funded_account( + &solana, + group, + owner, + 2, + &context.users[1], + &mints[0..0], + 0, + 0, + ) + .await; + + let hour = 60 * 60; + + send_tx( + solana, + GroupEdit { + group, + admin, + options: mango_v4::instruction::GroupEdit { + collateral_fee_interval_opt: Some(6 * hour), + ..group_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); + + send_tx( + solana, + TokenEdit { + group, + admin, + mint: mints[0].pubkey, + fallback_oracle: Pubkey::default(), + options: mango_v4::instruction::TokenEdit { + collateral_fee_per_day_opt: Some(0.1), + ..token_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); + + send_tx( + solana, + TokenEdit { + group, + admin, + mint: mints[1].pubkey, + fallback_oracle: Pubkey::default(), + options: mango_v4::instruction::TokenEdit { + loan_origination_fee_rate_opt: Some(0.0), + ..token_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); + + // + // TEST: It works on empty accounts + // + + send_tx( + solana, + TokenChargeCollateralFeesInstruction { + account: empty_account, + }, + ) + .await + .unwrap(); + let mut last_time = solana.clock_timestamp().await; + solana.set_clock_timestamp(last_time + 9 * hour).await; + + // send it twice, because the first time will never charge anything + send_tx( + solana, + TokenChargeCollateralFeesInstruction { + account: empty_account, + }, + ) + .await + .unwrap(); + last_time = solana.clock_timestamp().await; + + // + // TEST: Without borrows, charging collateral fees has no effect + // + + send_tx(solana, TokenChargeCollateralFeesInstruction { account }) + .await + .unwrap(); + last_time = solana.clock_timestamp().await; + solana.set_clock_timestamp(last_time + 9 * hour).await; + + // send it twice, because the first time will never charge anything + send_tx(solana, TokenChargeCollateralFeesInstruction { account }) + .await + .unwrap(); + last_time = solana.clock_timestamp().await; + + // no effect + assert_eq!( + account_position(solana, account, tokens[0].bank).await, + 1_500 + ); + + // + // TEST: With borrows, there's an effect depending on the time that has passed + // + + send_tx( + solana, + TokenWithdrawInstruction { + amount: 500, // maint: -1.2 * 500 = -600 (half of 1200) + allow_borrow: true, + account, + owner, + token_account: context.users[1].token_accounts[1], + bank_index: 0, + }, + ) + .await + .unwrap(); + + solana.set_clock_timestamp(last_time + 9 * hour).await; + + send_tx(solana, TokenChargeCollateralFeesInstruction { account }) + .await + .unwrap(); + last_time = solana.clock_timestamp().await; + assert!(assert_equal_f64_f64( + account_position_f64(solana, account, tokens[0].bank).await, + 1500.0 * (1.0 - 0.1 * (9.0 / 24.0) * (600.0 / 1200.0)), + 0.01 + )); + let last_balance = account_position_f64(solana, account, tokens[0].bank).await; + + // + // TEST: More borrows + // + + send_tx( + solana, + TokenWithdrawInstruction { + amount: 100, // maint: -1.2 * 600 = -720 + allow_borrow: true, + account, + owner, + token_account: context.users[1].token_accounts[1], + bank_index: 0, + }, + ) + .await + .unwrap(); + + solana.set_clock_timestamp(last_time + 7 * hour).await; + + send_tx(solana, TokenChargeCollateralFeesInstruction { account }) + .await + .unwrap(); + //last_time = solana.clock_timestamp().await; + assert!(assert_equal_f64_f64( + account_position_f64(solana, account, tokens[0].bank).await, + last_balance * (1.0 - 0.1 * (7.0 / 24.0) * (720.0 / (last_balance * 0.8))), + 0.01 + )); + + Ok(()) +} diff --git a/programs/mango-v4/tests/cases/test_force_close.rs b/programs/mango-v4/tests/cases/test_force_close.rs index d9f9ddd11..9f7e4d1fe 100644 --- a/programs/mango-v4/tests/cases/test_force_close.rs +++ b/programs/mango-v4/tests/cases/test_force_close.rs @@ -438,3 +438,114 @@ async fn test_force_close_perp() -> Result<(), TransportError> { Ok(()) } + +#[tokio::test] +async fn test_force_withdraw_token() -> Result<(), TransportError> { + let test_builder = TestContextBuilder::new(); + let context = test_builder.start_default().await; + let solana = &context.solana.clone(); + + let admin = TestKeypair::new(); + let owner = context.users[0].key; + let payer = context.users[1].key; + let mints = &context.mints[0..1]; + + // + // SETUP: Create a group and an account to fill the vaults + // + + let mango_setup::GroupWithTokens { group, tokens, .. } = mango_setup::GroupWithTokensConfig { + admin, + payer, + mints: mints.to_vec(), + ..GroupWithTokensConfig::default() + } + .create(solana) + .await; + let token = &tokens[0]; + + let deposit_amount = 100; + + let account = create_funded_account( + &solana, + group, + owner, + 0, + &context.users[0], + mints, + deposit_amount, + 0, + ) + .await; + + // + // TEST: fails when force withdraw isn't enabled + // + assert!(send_tx( + solana, + TokenForceWithdrawInstruction { + account, + bank: token.bank, + target: context.users[0].token_accounts[0], + }, + ) + .await + .is_err()); + + // set force withdraw to enabled + send_tx( + solana, + TokenEdit { + admin, + group, + mint: token.mint.pubkey, + fallback_oracle: Pubkey::default(), + options: mango_v4::instruction::TokenEdit { + init_asset_weight_opt: Some(0.0), + maint_asset_weight_opt: Some(0.0), + reduce_only_opt: Some(1), + disable_asset_liquidation_opt: Some(true), + force_withdraw_opt: Some(true), + ..token_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); + + // + // TEST: can't withdraw to foreign address + // + assert!(send_tx( + solana, + TokenForceWithdrawInstruction { + account, + bank: token.bank, + target: context.users[1].token_accounts[0], // bad address/owner + }, + ) + .await + .is_err()); + + // + // TEST: passes and withdraws tokens + // + let token_account = context.users[0].token_accounts[0]; + let before_balance = solana.token_account_balance(token_account).await; + send_tx( + solana, + TokenForceWithdrawInstruction { + account, + bank: token.bank, + target: token_account, + }, + ) + .await + .unwrap(); + + let after_balance = solana.token_account_balance(token_account).await; + assert_eq!(after_balance, before_balance + deposit_amount); + assert!(account_position_closed(solana, account, token.bank).await); + + Ok(()) +} diff --git a/programs/mango-v4/tests/cases/test_health_compute.rs b/programs/mango-v4/tests/cases/test_health_compute.rs index 68e6c9d38..091205042 100644 --- a/programs/mango-v4/tests/cases/test_health_compute.rs +++ b/programs/mango-v4/tests/cases/test_health_compute.rs @@ -335,7 +335,7 @@ async fn test_health_compute_tokens_fallback_oracles() -> Result<(), TransportEr println!("average success increase: {avg_success_increase}"); println!("average failure increase: {avg_failure_increase}"); assert!(avg_success_increase < 2_050); - assert!(avg_success_increase < 18_500); + assert!(avg_failure_increase < 19_500); Ok(()) } diff --git a/programs/mango-v4/tests/cases/test_liq_tokens.rs b/programs/mango-v4/tests/cases/test_liq_tokens.rs index 56faf7cca..db049681d 100644 --- a/programs/mango-v4/tests/cases/test_liq_tokens.rs +++ b/programs/mango-v4/tests/cases/test_liq_tokens.rs @@ -324,6 +324,66 @@ async fn test_liq_tokens_with_token() -> Result<(), TransportError> { // set_bank_stub_oracle_price(solana, group, borrow_token1, admin, 2.0).await; + // + // TEST: can't liquidate if token has no asset weight + // + + send_tx( + solana, + TokenEdit { + group, + admin, + mint: collateral_token2.mint.pubkey, + fallback_oracle: Pubkey::default(), + options: mango_v4::instruction::TokenEdit { + maint_asset_weight_opt: Some(0.0), + init_asset_weight_opt: Some(0.0), + disable_asset_liquidation_opt: Some(true), + reduce_only_opt: Some(1), + ..token_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); + let res = send_tx( + solana, + TokenLiqWithTokenInstruction { + liqee: account, + liqor: vault_account, + liqor_owner: owner, + asset_token_index: collateral_token2.index, + liab_token_index: borrow_token2.index, + asset_bank_index: 0, + liab_bank_index: 0, + max_liab_transfer: I80F48::from_num(10000.0), + }, + ) + .await; + assert_mango_error( + &res, + MangoError::TokenAssetLiquidationDisabled.into(), + "liquidation disabled".to_string(), + ); + send_tx( + solana, + TokenEdit { + group, + admin, + mint: collateral_token2.mint.pubkey, + fallback_oracle: Pubkey::default(), + options: mango_v4::instruction::TokenEdit { + maint_asset_weight_opt: Some(0.8), + init_asset_weight_opt: Some(0.6), + disable_asset_liquidation_opt: Some(false), + reduce_only_opt: Some(0), + ..token_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); + // // TEST: liquidate borrow2 against too little collateral2 // diff --git a/programs/mango-v4/tests/program_test/mango_client.rs b/programs/mango-v4/tests/program_test/mango_client.rs index e0b5bcd13..a0e28b5a2 100644 --- a/programs/mango-v4/tests/program_test/mango_client.rs +++ b/programs/mango-v4/tests/program_test/mango_client.rs @@ -1077,6 +1077,8 @@ impl ClientInstruction for TokenRegisterInstruction { deposit_limit: 0, zero_util_rate: 0.0, platform_liquidation_fee: self.platform_liquidation_fee, + disable_asset_liquidation: false, + collateral_fee_per_day: 0.0, }; let bank = Pubkey::find_program_address( @@ -1324,6 +1326,9 @@ pub fn token_edit_instruction_default() -> mango_v4::instruction::TokenEdit { deposit_limit_opt: None, zero_util_rate_opt: None, platform_liquidation_fee_opt: None, + disable_asset_liquidation_opt: None, + collateral_fee_per_day_opt: None, + force_withdraw_opt: None, } } @@ -1842,6 +1847,7 @@ pub fn group_edit_instruction_default() -> mango_v4::instruction::GroupEdit { mngo_token_index_opt: None, buyback_fees_expiry_interval_opt: None, allowed_fast_listings_per_interval_opt: None, + collateral_fee_interval_opt: None, } } @@ -3107,6 +3113,58 @@ impl ClientInstruction for TokenForceCloseBorrowsWithTokenInstruction { } } +pub struct TokenForceWithdrawInstruction { + pub account: Pubkey, + pub bank: Pubkey, + pub target: Pubkey, +} +#[async_trait::async_trait(?Send)] +impl ClientInstruction for TokenForceWithdrawInstruction { + type Accounts = mango_v4::accounts::TokenForceWithdraw; + type Instruction = mango_v4::instruction::TokenForceWithdraw; + async fn to_instruction( + &self, + account_loader: impl ClientAccountLoader + 'async_trait, + ) -> (Self::Accounts, instruction::Instruction) { + let program_id = mango_v4::id(); + let instruction = Self::Instruction {}; + + let account = account_loader + .load_mango_account(&self.account) + .await + .unwrap(); + let bank = account_loader.load::(&self.bank).await.unwrap(); + let health_check_metas = derive_health_check_remaining_account_metas( + &account_loader, + &account, + None, + false, + None, + ) + .await; + + let accounts = Self::Accounts { + group: account.fixed.group, + account: self.account, + bank: self.bank, + vault: bank.vault, + oracle: bank.oracle, + owner_ata_token_account: self.target, + alternate_owner_token_account: self.target, + token_program: Token::id(), + }; + + let mut instruction = make_instruction(program_id, &accounts, &instruction); + instruction.accounts.extend(health_check_metas.into_iter()); + + (accounts, instruction) + } + + fn signers(&self) -> Vec { + vec![] + } +} + pub struct TokenLiqWithTokenInstruction { pub liqee: Pubkey, pub liqor: Pubkey, @@ -5036,3 +5094,48 @@ impl ClientInstruction for TokenConditionalSwapStartInstruction { vec![self.liqor_owner] } } + +#[derive(Clone)] +pub struct TokenChargeCollateralFeesInstruction { + pub account: Pubkey, +} +#[async_trait::async_trait(?Send)] +impl ClientInstruction for TokenChargeCollateralFeesInstruction { + type Accounts = mango_v4::accounts::TokenChargeCollateralFees; + type Instruction = mango_v4::instruction::TokenChargeCollateralFees; + async fn to_instruction( + &self, + account_loader: impl ClientAccountLoader + 'async_trait, + ) -> (Self::Accounts, instruction::Instruction) { + let program_id = mango_v4::id(); + + let account = account_loader + .load_mango_account(&self.account) + .await + .unwrap(); + + let instruction = Self::Instruction {}; + + let health_check_metas = derive_health_check_remaining_account_metas( + &account_loader, + &account, + None, + true, + None, + ) + .await; + + let accounts = Self::Accounts { + group: account.fixed.group, + account: self.account, + }; + + let mut instruction = make_instruction(program_id, &accounts, &instruction); + instruction.accounts.extend(health_check_metas.into_iter()); + (accounts, instruction) + } + + fn signers(&self) -> Vec { + vec![] + } +} diff --git a/ts/client/scripts/archive/account-shrink/admin.ts b/ts/client/scripts/archive/account-shrink/admin.ts index ea832ced6..ac2d537ee 100644 --- a/ts/client/scripts/archive/account-shrink/admin.ts +++ b/ts/client/scripts/archive/account-shrink/admin.ts @@ -73,6 +73,7 @@ async function main(): Promise { group, usdcDevnetMint, usdcDevnetOracle.publicKey, + PublicKey.default, 0, // tokenIndex 'USDC', { @@ -101,6 +102,7 @@ async function main(): Promise { group, solDevnetMint, solDevnetOracle, + PublicKey.default, 4, // tokenIndex 'SOL', { @@ -130,6 +132,7 @@ async function main(): Promise { group, usdtDevnetMint, usdcDevnetOracle.publicKey, + PublicKey.default, 5, // tokenIndex 'USDT', { diff --git a/ts/client/scripts/archive/devnet-admin.ts b/ts/client/scripts/archive/devnet-admin.ts index ff32a263c..8b0f4270a 100644 --- a/ts/client/scripts/archive/devnet-admin.ts +++ b/ts/client/scripts/archive/devnet-admin.ts @@ -94,6 +94,7 @@ async function main() { group, usdcDevnetMint, usdcDevnetOracle.publicKey, + PublicKey.default, 0, // tokenIndex 'USDC', { @@ -124,6 +125,7 @@ async function main() { group, solDevnetMint, solDevnetOracle, + PublicKey.default, 1, // tokenIndex 'SOL', { diff --git a/ts/client/scripts/archive/mb-admin.ts b/ts/client/scripts/archive/mb-admin.ts index bb55fd054..a1523f907 100644 --- a/ts/client/scripts/archive/mb-admin.ts +++ b/ts/client/scripts/archive/mb-admin.ts @@ -206,6 +206,7 @@ async function registerTokens() { group, usdcMainnetMint, usdcMainnetOracle.publicKey, + PublicKey.default, 0, 'USDC', { @@ -226,6 +227,7 @@ async function registerTokens() { group, usdtMainnetMint, usdtMainnetOracle, + PublicKey.default, 1, 'USDT', { @@ -246,6 +248,7 @@ async function registerTokens() { group, daiMainnetMint, daiMainnetOracle, + PublicKey.default, 2, 'DAI', { @@ -266,6 +269,7 @@ async function registerTokens() { group, ethMainnetMint, ethMainnetOracle, + PublicKey.default, 3, 'ETH', { @@ -286,6 +290,7 @@ async function registerTokens() { group, solMainnetMint, solMainnetOracle, + PublicKey.default, 4, 'SOL', { @@ -306,6 +311,7 @@ async function registerTokens() { group, msolMainnetMint, msolMainnetOracle, + PublicKey.default, 5, 'MSOL', { diff --git a/ts/client/scripts/create-gov-ix.ts b/ts/client/scripts/create-gov-ix.ts index 8b021961e..dbcc2fdb1 100644 --- a/ts/client/scripts/create-gov-ix.ts +++ b/ts/client/scripts/create-gov-ix.ts @@ -60,31 +60,31 @@ async function buildClient(): Promise { ); } -async function groupEdit(): Promise { - const client = await buildClient(); - const group = await client.getGroup(new PublicKey(GROUP_PK)); - const ix = await client.program.methods - .groupEdit( - null, // admin - null, // fastListingAdmin - null, // securityAdmin - null, // testing - null, // version - null, // depositLimitQuote - null, // feesPayWithMngo - null, // feesMngoBonusRate - null, // feesSwapMangoAccount - 6, // feesMngoTokenIndex - null, // feesExpiryInterval - 5, // allowedFastListingsPerInterval - ) - .accounts({ - group: group.publicKey, - admin: group.admin, - }) - .instruction(); - console.log(serializeInstructionToBase64(ix)); -} +// async function groupEdit(): Promise { +// const client = await buildClient(); +// const group = await client.getGroup(new PublicKey(GROUP_PK)); +// const ix = await client.program.methods +// .groupEdit( +// null, // admin +// null, // fastListingAdmin +// null, // securityAdmin +// null, // testing +// null, // version +// null, // depositLimitQuote +// null, // feesPayWithMngo +// null, // feesMngoBonusRate +// null, // feesSwapMangoAccount +// 6, // feesMngoTokenIndex +// null, // feesExpiryInterval +// 5, // allowedFastListingsPerInterval +// ) +// .accounts({ +// group: group.publicKey, +// admin: group.admin, +// }) +// .instruction(); +// console.log(serializeInstructionToBase64(ix)); +// } // async function tokenRegister(): Promise { // const client = await buildClient(); @@ -468,7 +468,7 @@ async function idlSetAuthority(): Promise { async function main(): Promise { try { - await groupEdit(); + // await groupEdit(); // await tokenRegister(); // await tokenEdit(); // await perpCreate(); diff --git a/ts/client/scripts/force-withdraw-token.ts b/ts/client/scripts/force-withdraw-token.ts new file mode 100644 index 000000000..3a9b9b32c --- /dev/null +++ b/ts/client/scripts/force-withdraw-token.ts @@ -0,0 +1,73 @@ +import { AnchorProvider, Wallet } from '@coral-xyz/anchor'; +import { Cluster, Connection, Keypair, PublicKey } from '@solana/web3.js'; +import fs from 'fs'; +import { TokenIndex } from '../src/accounts/bank'; +import { MangoClient } from '../src/client'; +import { MANGO_V4_ID } from '../src/constants'; + +const CLUSTER: Cluster = + (process.env.CLUSTER_OVERRIDE as Cluster) || 'mainnet-beta'; +const CLUSTER_URL = + process.env.CLUSTER_URL_OVERRIDE || process.env.MB_CLUSTER_URL; +const USER_KEYPAIR = + process.env.USER_KEYPAIR_OVERRIDE || process.env.MB_PAYER_KEYPAIR; +const GROUP_PK = + process.env.GROUP_PK || '78b8f4cGCwmZ9ysPFMWLaLTkkaYnUjwMJYStWe5RTSSX'; +const TOKEN_INDEX = Number(process.env.TOKEN_INDEX) as TokenIndex; + +async function forceWithdrawTokens(): Promise { + const options = AnchorProvider.defaultOptions(); + const connection = new Connection(CLUSTER_URL!, options); + const user = Keypair.fromSecretKey( + Buffer.from( + JSON.parse( + process.env.KEYPAIR || fs.readFileSync(USER_KEYPAIR!, 'utf-8'), + ), + ), + ); + const userWallet = new Wallet(user); + const userProvider = new AnchorProvider(connection, userWallet, options); + const client = await MangoClient.connect( + userProvider, + CLUSTER, + MANGO_V4_ID[CLUSTER], + { + idsSource: 'get-program-accounts', + }, + ); + + const group = await client.getGroup(new PublicKey(GROUP_PK)); + const forceWithdrawBank = group.getFirstBankByTokenIndex(TOKEN_INDEX); + if (forceWithdrawBank.reduceOnly != 2) { + throw new Error( + `Unexpected reduce only state ${forceWithdrawBank.reduceOnly}`, + ); + } + if (!forceWithdrawBank.forceWithdraw) { + throw new Error( + `Unexpected force withdraw state ${forceWithdrawBank.forceWithdraw}`, + ); + } + + // Get all mango accounts with deposits for given token + const mangoAccountsWithDeposits = ( + await client.getAllMangoAccounts(group) + ).filter((a) => a.getTokenBalanceUi(forceWithdrawBank) > 0); + + for (const mangoAccount of mangoAccountsWithDeposits) { + const sig = await client.tokenForceWithdraw( + group, + mangoAccount, + TOKEN_INDEX, + ); + console.log( + ` tokenForceWithdraw for ${mangoAccount.publicKey}, owner ${ + mangoAccount.owner + }, sig https://explorer.solana.com/tx/${sig}?cluster=${ + CLUSTER == 'devnet' ? 'devnet' : '' + }`, + ); + } +} + +forceWithdrawTokens(); diff --git a/ts/client/scripts/liqtest/liqtest-create-group.ts b/ts/client/scripts/liqtest/liqtest-create-group.ts index a172b12e7..e574d0226 100644 --- a/ts/client/scripts/liqtest/liqtest-create-group.ts +++ b/ts/client/scripts/liqtest/liqtest-create-group.ts @@ -29,6 +29,7 @@ const MAINNET_MINTS = new Map([ ['ETH', MINTS[1]], ['SOL', MINTS[2]], ['MNGO', MINTS[3]], + ['MSOL', MINTS[4]], ]); const STUB_PRICES = new Map([ @@ -36,13 +37,7 @@ const STUB_PRICES = new Map([ ['ETH', 1200.0], // eth and usdc both have 6 decimals ['SOL', 0.015], // sol has 9 decimals, equivalent to $15 per SOL ['MNGO', 0.02], -]); - -// External markets are matched with those in https://github.com/blockworks-foundation/mango-client-v3/blob/main/src/ids.json -// and verified to have best liquidity for pair on https://openserum.io/ -const MAINNET_SERUM3_MARKETS = new Map([ - ['ETH/USDC', SERUM_MARKETS[0]], - ['SOL/USDC', SERUM_MARKETS[1]], + ['MSOL', 0.017], ]); const MIN_VAULT_TO_DEPOSITS_RATIO = 0.2; @@ -90,11 +85,13 @@ async function main(): Promise { for (const [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); + if ((await client.getStubOracle(group, mintPk)).length == 0) { + 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}`); @@ -114,22 +111,32 @@ async function main(): Promise { maxRate: 1.5, }; + const noFallbackOracle = PublicKey.default; + // register token 0 console.log(`Registering USDC...`); const usdcMint = new PublicKey(MAINNET_MINTS.get('USDC')!); const usdcOracle = oracles.get('USDC'); try { - await client.tokenRegister(group, usdcMint, usdcOracle, 0, 'USDC', { - ...DefaultTokenRegisterParams, - loanOriginationFeeRate: 0, - loanFeeRate: 0.0001, - initAssetWeight: 1, - maintAssetWeight: 1, - initLiabWeight: 1, - maintLiabWeight: 1, - liquidationFee: 0, - netBorrowLimitPerWindowQuote: NET_BORROWS_LIMIT_NATIVE, - }); + await client.tokenRegister( + group, + usdcMint, + usdcOracle, + noFallbackOracle, + 0, + 'USDC', + { + ...DefaultTokenRegisterParams, + loanOriginationFeeRate: 0, + loanFeeRate: 0.0001, + initAssetWeight: 1, + maintAssetWeight: 1, + initLiabWeight: 1, + maintLiabWeight: 1, + liquidationFee: 0, + netBorrowLimitPerWindowQuote: NET_BORROWS_LIMIT_NATIVE, + }, + ); await group.reloadAll(client); } catch (error) { console.log(error); @@ -140,17 +147,25 @@ async function main(): Promise { const ethMint = new PublicKey(MAINNET_MINTS.get('ETH')!); const ethOracle = oracles.get('ETH'); try { - await client.tokenRegister(group, ethMint, ethOracle, 1, 'ETH', { - ...DefaultTokenRegisterParams, - loanOriginationFeeRate: 0, - loanFeeRate: 0.0001, - maintAssetWeight: 0.9, - initAssetWeight: 0.8, - maintLiabWeight: 1.1, - initLiabWeight: 1.2, - liquidationFee: 0.05, - netBorrowLimitPerWindowQuote: NET_BORROWS_LIMIT_NATIVE, - }); + await client.tokenRegister( + group, + ethMint, + ethOracle, + noFallbackOracle, + 1, + 'ETH', + { + ...DefaultTokenRegisterParams, + loanOriginationFeeRate: 0, + loanFeeRate: 0.0001, + maintAssetWeight: 0.9, + initAssetWeight: 0.8, + maintLiabWeight: 1.1, + initLiabWeight: 1.2, + liquidationFee: 0.05, + netBorrowLimitPerWindowQuote: NET_BORROWS_LIMIT_NATIVE, + }, + ); await group.reloadAll(client); } catch (error) { console.log(error); @@ -165,6 +180,7 @@ async function main(): Promise { group, solMint, solOracle, + noFallbackOracle, 2, // tokenIndex 'SOL', { @@ -184,27 +200,72 @@ async function main(): Promise { console.log(error); } + const genericBanks = ['MNGO', 'MSOL']; + let nextTokenIndex = 3; + for (const name of genericBanks) { + console.log(`Registering ${name}...`); + const mint = new PublicKey(MAINNET_MINTS.get(name)!); + const oracle = oracles.get(name); + try { + await client.tokenRegister( + group, + mint, + oracle, + noFallbackOracle, + nextTokenIndex, + name, + { + ...DefaultTokenRegisterParams, + loanOriginationFeeRate: 0, + loanFeeRate: 0.0001, + maintAssetWeight: 0.9, + initAssetWeight: 0.8, + maintLiabWeight: 1.1, + initLiabWeight: 1.2, + liquidationFee: 0.05, + netBorrowLimitPerWindowQuote: NET_BORROWS_LIMIT_NATIVE, + }, + ); + nextTokenIndex += 1; + await group.reloadAll(client); + } catch (error) { + console.log(error); + } + } + // log tokens/banks for (const bank of await group.banksMapByMint.values()) { console.log(`${bank.toString()}`); } - console.log('Registering SOL/USDC serum market...'); - try { - await client.serum3RegisterMarket( - group, - new PublicKey(MAINNET_SERUM3_MARKETS.get('SOL/USDC')!), - group.getFirstBankByMint(new PublicKey(MAINNET_MINTS.get('SOL')!)), - group.getFirstBankByMint(new PublicKey(MAINNET_MINTS.get('USDC')!)), - 1, - 'SOL/USDC', - 0, - ); - } catch (error) { - console.log(error); + let nextSerumMarketIndex = 0; + for (const [name, mint] of MAINNET_MINTS) { + if (name == 'USDC') { + continue; + } + + console.log(`Registering ${name}/USDC serum market...`); + try { + await client.serum3RegisterMarket( + group, + new PublicKey(SERUM_MARKETS[nextSerumMarketIndex]), + group.getFirstBankByMint(new PublicKey(mint)), + group.getFirstBankByMint(usdcMint), + nextSerumMarketIndex, + `${name}/USDC`, + 0, + ); + nextSerumMarketIndex += 1; + } catch (error) { + console.log(error); + } } console.log('Registering MNGO-PERP market...'); + if (!group.banksMapByMint.get(usdcMint.toString())) { + console.log('stopping, no USDC bank'); + return; + } const mngoOracle = oracles.get('MNGO'); try { await client.perpCreateMarket( @@ -237,7 +298,7 @@ async function main(): Promise { -1.0, 2 * 60 * 60, 0.025, - 0, + 0.0, ); } catch (error) { console.log(error); diff --git a/ts/client/scripts/liqtest/liqtest-create-tokens-and-markets.ts b/ts/client/scripts/liqtest/liqtest-create-tokens-and-markets.ts index d78e5e436..6f01e7237 100644 --- a/ts/client/scripts/liqtest/liqtest-create-tokens-and-markets.ts +++ b/ts/client/scripts/liqtest/liqtest-create-tokens-and-markets.ts @@ -20,6 +20,9 @@ import { generateSerum3MarketExternalVaultSignerAddress } from '../../src/accoun // Script which creates three mints and two serum3 markets relating them // +const MINT_COUNT = 5; +const SERUM_MARKET_COUNT = 4; + function getVaultOwnerAndNonce( market: PublicKey, programId: PublicKey, @@ -56,7 +59,7 @@ async function main(): Promise { // Make mints const mints = await Promise.all( - Array(4) + Array(MINT_COUNT) .fill(null) .map(() => splToken.createMint(connection, admin, admin.publicKey, null, 6), @@ -78,11 +81,11 @@ async function main(): Promise { // Make serum markets const serumMarkets: PublicKey[] = []; const quoteMint = mints[0]; - for (const baseMint of mints.slice(1, 3)) { + for (const baseMint of mints.slice(1, 1 + SERUM_MARKET_COUNT)) { const feeRateBps = 0.25; // don't think this does anything const quoteDustThreshold = 100; const baseLotSize = 1000; - const quoteLotSize = 1000; + const quoteLotSize = 1; // makes prices be in 1000ths const openbookProgramId = OPENBOOK_PROGRAM_ID.devnet; const market = Keypair.generate(); diff --git a/ts/client/scripts/liqtest/liqtest-make-candidates.ts b/ts/client/scripts/liqtest/liqtest-make-candidates.ts index b64220520..c1d04e436 100644 --- a/ts/client/scripts/liqtest/liqtest-make-candidates.ts +++ b/ts/client/scripts/liqtest/liqtest-make-candidates.ts @@ -31,7 +31,7 @@ const CLUSTER = process.env.CLUSTER || 'mainnet-beta'; // native prices const PRICES = { ETH: 1200.0, - SOL: 0.015, + SOL: 0.015, // not updated for the fact that the new mints we use have 6 decimals! USDC: 1, MNGO: 0.02, }; @@ -100,7 +100,7 @@ async function main() { async function createMangoAccount(name: string): Promise { const accountNum = maxAccountNum + 1; maxAccountNum = maxAccountNum + 1; - await client.createMangoAccount(group, accountNum, name, 4, 4, 4, 4); + await client.createMangoAccount(group, accountNum, name, 5, 4, 4, 4); return (await client.getMangoAccountForOwner( group, admin.publicKey, @@ -202,7 +202,7 @@ async function main() { group, mangoAccount, sellMint, - new BN(100000), + new BN(150000), ); await mangoAccount.reload(client); @@ -217,20 +217,40 @@ async function main() { .build(), ); try { - // At a price of $1/ui-SOL we can buy 0.1 ui-SOL for the 100k native-USDC we have. - // With maint weight of 0.9 we have 10x main-leverage. Buying 12x as much causes liquidation. + // At a price of $0.015/ui-SOL we can buy 10 ui-SOL for the 0.15 USDC (150k native-USDC) we have. + // With maint weight of 0.9 we have 10x main-leverage. Buying 11x as much causes liquidation. await client.serum3PlaceOrder( group, mangoAccount, market.serumMarketExternal, Serum3Side.bid, - 1, - 12 * 0.1, + 0.015, + 11 * 10, Serum3SelfTradeBehavior.abortTransaction, Serum3OrderType.limit, 0, 5, ); + await mangoAccount.reload(client); + + for (let market of group.serum3MarketsMapByMarketIndex.values()) { + if (market.name == 'SOL/USDC') { + continue; + } + await client.serum3PlaceOrder( + group, + mangoAccount, + market.serumMarketExternal, + Serum3Side.bid, + 0.001, + 1, + Serum3SelfTradeBehavior.abortTransaction, + Serum3OrderType.limit, + 0, + 5, + ); + await mangoAccount.reload(client); + } } finally { // restore the weights await client.tokenEdit( diff --git a/ts/client/scripts/liqtest/liqtest-make-tcs-candidates.ts b/ts/client/scripts/liqtest/liqtest-make-tcs-candidates.ts index 653922b96..b40022412 100644 --- a/ts/client/scripts/liqtest/liqtest-make-tcs-candidates.ts +++ b/ts/client/scripts/liqtest/liqtest-make-tcs-candidates.ts @@ -1,6 +1,6 @@ import { AnchorProvider, BN, Wallet } from '@coral-xyz/anchor'; -import { Cluster, Connection, Keypair, PublicKey } from '@solana/web3.js'; import * as splToken from '@solana/spl-token'; +import { Cluster, Connection, Keypair, PublicKey } from '@solana/web3.js'; import fs from 'fs'; import { Bank } from '../../src/accounts/bank'; import { @@ -280,6 +280,7 @@ async function main() { group, newMint, newOracle.publicKey, + PublicKey.default, newTokenIndex, 'TMP', { diff --git a/ts/client/scripts/liqtest/liqtest-settle-and-close-all.ts b/ts/client/scripts/liqtest/liqtest-settle-and-close-all.ts index 76ddc26f7..406484daa 100644 --- a/ts/client/scripts/liqtest/liqtest-settle-and-close-all.ts +++ b/ts/client/scripts/liqtest/liqtest-settle-and-close-all.ts @@ -57,6 +57,9 @@ async function main() { `closing serum orders on: ${account} for market ${serumMarket.name}`, ); await client.serum3CancelAllOrders(group, account, serumExternal, 10); + try { + await client.serum3ConsumeEvents(group, serumExternal); + } catch (e) {} await client.serum3SettleFunds(group, account, serumExternal); await client.serum3CloseOpenOrders(group, account, serumExternal); } diff --git a/ts/client/scripts/mainnet-no-margin-group.ts b/ts/client/scripts/mainnet-no-margin-group.ts index b478700a5..513a37836 100644 --- a/ts/client/scripts/mainnet-no-margin-group.ts +++ b/ts/client/scripts/mainnet-no-margin-group.ts @@ -143,6 +143,7 @@ async function registerTokens(): Promise { group, usdcMainnetMint, usdcMainnetOracle, + PublicKey.default, 0, 'USDC', defaultTokenParams, diff --git a/ts/client/scripts/maintain-alts.ts b/ts/client/scripts/maintain-alts.ts index 338e8fec2..8ced8a441 100644 --- a/ts/client/scripts/maintain-alts.ts +++ b/ts/client/scripts/maintain-alts.ts @@ -224,7 +224,7 @@ async function populateExistingAlts(): Promise { .map((perpMarket) => [perpMarket.publicKey, perpMarket.oracle]) .flat(), ); - // Well known addressess + // Well known addresses await extendTable( client, group, diff --git a/ts/client/scripts/update-risk-params.ts b/ts/client/scripts/update-risk-params.ts index 6914d6343..fef2504aa 100644 --- a/ts/client/scripts/update-risk-params.ts +++ b/ts/client/scripts/update-risk-params.ts @@ -398,6 +398,9 @@ async function updateTokenParams(): Promise { params.depositLimit, params.zeroUtilRate, params.platformLiquidationFee, + params.disableAssetLiquidation, + params.collateralFeePerDay, + params.forceWithdraw, ) .accounts({ group: group.publicKey, diff --git a/ts/client/src/accounts/bank.ts b/ts/client/src/accounts/bank.ts index 8859974ca..d4c8c4437 100644 --- a/ts/client/src/accounts/bank.ts +++ b/ts/client/src/accounts/bank.ts @@ -82,6 +82,7 @@ export class Bank implements BankForHealth { public zeroUtilRate: I80F48; public platformLiquidationFee: I80F48; public collectedLiquidationFees: I80F48; + public collectedCollateralFees: I80F48; static from( publicKey: PublicKey, @@ -129,6 +130,8 @@ export class Bank implements BankForHealth { depositWeightScaleStartQuote: number; reduceOnly: number; forceClose: number; + disableAssetLiquidation: number; + forceWithdraw: number; feesWithdrawn: BN; tokenConditionalSwapTakerFeeRate: number; tokenConditionalSwapMakerFeeRate: number; @@ -146,6 +149,8 @@ export class Bank implements BankForHealth { zeroUtilRate: I80F48Dto; platformLiquidationFee: I80F48Dto; collectedLiquidationFees: I80F48Dto; + collectedCollateralFees: I80F48Dto; + collateralFeePerDay: number; }, ): Bank { return new Bank( @@ -210,6 +215,10 @@ export class Bank implements BankForHealth { obj.zeroUtilRate, obj.platformLiquidationFee, obj.collectedLiquidationFees, + obj.disableAssetLiquidation == 0, + obj.collectedCollateralFees, + obj.collateralFeePerDay, + obj.forceWithdraw == 1, ); } @@ -275,6 +284,10 @@ export class Bank implements BankForHealth { zeroUtilRate: I80F48Dto, platformLiquidationFee: I80F48Dto, collectedLiquidationFees: I80F48Dto, + public allowAssetLiquidation: boolean, + collectedCollateralFees: I80F48Dto, + public collateralFeePerDay: number, + public forceWithdraw: boolean, ) { this.name = utf8.decode(new Uint8Array(name)).split('\x00')[0]; this.oracleConfig = { @@ -307,6 +320,7 @@ export class Bank implements BankForHealth { this.zeroUtilRate = I80F48.from(zeroUtilRate); this.platformLiquidationFee = I80F48.from(platformLiquidationFee); this.collectedLiquidationFees = I80F48.from(collectedLiquidationFees); + this.collectedCollateralFees = I80F48.from(collectedCollateralFees); this._price = undefined; this._uiPrice = undefined; this._oracleLastUpdatedSlot = undefined; diff --git a/ts/client/src/accounts/group.ts b/ts/client/src/accounts/group.ts index f34598bb4..b486d1432 100644 --- a/ts/client/src/accounts/group.ts +++ b/ts/client/src/accounts/group.ts @@ -55,6 +55,7 @@ export class Group { fastListingIntervalStart: BN; fastListingsInInterval: number; allowedFastListingsPerInterval: number; + collateralFeeInterval: BN; }, ): Group { return new Group( @@ -79,6 +80,7 @@ export class Group { obj.fastListingIntervalStart, obj.fastListingsInInterval, obj.allowedFastListingsPerInterval, + obj.collateralFeeInterval, [], // addressLookupTablesList new Map(), // banksMapByName new Map(), // banksMapByMint @@ -118,6 +120,7 @@ export class Group { public fastListingIntervalStart: BN, public fastListingsInInterval: number, public allowedFastListingsPerInterval: number, + public collateralFeeInterval: BN, public addressLookupTablesList: AddressLookupTableAccount[], public banksMapByName: Map, public banksMapByMint: Map, diff --git a/ts/client/src/accounts/perp.ts b/ts/client/src/accounts/perp.ts index 3c9c0fe5f..a3c8217a0 100644 --- a/ts/client/src/accounts/perp.ts +++ b/ts/client/src/accounts/perp.ts @@ -443,7 +443,7 @@ export class PerpMarket { /** * * Returns instantaneous funding rate for the day. How is it actually applied - funding is - * continously applied on every interaction to a perp position. The rate is further multiplied + * continuously applied on every interaction to a perp position. The rate is further multiplied * by the time elapsed since it was last applied (capped to max. 1hr). * * @param bids diff --git a/ts/client/src/client.ts b/ts/client/src/client.ts index cd57e2603..c0d7be0c3 100644 --- a/ts/client/src/client.ts +++ b/ts/client/src/client.ts @@ -7,8 +7,10 @@ import { } from '@coral-xyz/anchor'; import { OpenOrders, decodeEventQueue } from '@project-serum/serum'; import { + createAccount, createCloseAccountInstruction, createInitializeAccount3Instruction, + unpackAccount, } from '@solana/spl-token'; import { AccountInfo, @@ -24,6 +26,7 @@ import { RecentPrioritizationFees, SYSVAR_INSTRUCTIONS_PUBKEY, SYSVAR_RENT_PUBKEY, + Signer, SystemProgram, TransactionInstruction, } from '@solana/web3.js'; @@ -322,6 +325,7 @@ export class MangoClient { feesMngoTokenIndex?: TokenIndex, feesExpiryInterval?: BN, allowedFastListingsPerInterval?: number, + collateralFeeInterval?: BN, ): Promise { const ix = await this.program.methods .groupEdit( @@ -337,6 +341,7 @@ export class MangoClient { feesMngoTokenIndex ?? null, feesExpiryInterval ?? null, allowedFastListingsPerInterval ?? null, + collateralFeeInterval ?? null, ) .accounts({ group: group.publicKey, @@ -443,6 +448,7 @@ export class MangoClient { group: Group, mintPk: PublicKey, oraclePk: PublicKey, + fallbackOraclePk: PublicKey, tokenIndex: number, name: string, params: TokenRegisterParams, @@ -478,12 +484,15 @@ export class MangoClient { params.depositLimit, params.zeroUtilRate, params.platformLiquidationFee, + params.disableAssetLiquidation, + params.collateralFeePerDay, ) .accounts({ group: group.publicKey, admin: (this.program.provider as AnchorProvider).wallet.publicKey, mint: mintPk, oracle: oraclePk, + fallbackOracle: fallbackOraclePk, payer: (this.program.provider as AnchorProvider).wallet.publicKey, rent: SYSVAR_RENT_PUBKEY, }) @@ -560,14 +569,18 @@ export class MangoClient { params.maintWeightShiftAssetTarget, params.maintWeightShiftLiabTarget, params.maintWeightShiftAbort ?? false, - params.setFallbackOracle ?? false, + params.fallbackOracle !== null, // setFallbackOracle params.depositLimit, params.zeroUtilRate, params.platformLiquidationFee, + params.disableAssetLiquidation, + params.collateralFeePerDay, + params.forceWithdraw, ) .accounts({ group: group.publicKey, oracle: params.oracle ?? bank.oracle, + fallbackOracle: params.fallbackOracle ?? bank.fallbackOracle, admin: (this.program.provider as AnchorProvider).wallet.publicKey, mintInfo: mintInfo.publicKey, }) @@ -629,6 +642,94 @@ export class MangoClient { return await this.sendAndConfirmTransactionForGroup(group, [ix]); } + public async tokenForceWithdraw( + group: Group, + mangoAccount: MangoAccount, + tokenIndex: TokenIndex, + ): Promise { + const bank = group.getFirstBankByTokenIndex(tokenIndex); + if (!bank.forceWithdraw) { + throw new Error('Bank is not in force-withdraw mode'); + } + + const ownerAtaTokenAccount = await getAssociatedTokenAddress( + bank.mint, + mangoAccount.owner, + true, + ); + let alternateOwnerTokenAccount = PublicKey.default; + const preInstructions: TransactionInstruction[] = []; + const postInstructions: TransactionInstruction[] = []; + + const ai = await this.connection.getAccountInfo(ownerAtaTokenAccount); + + // ensure withdraws don't fail with missing ATAs + if (ai == null) { + preInstructions.push( + await createAssociatedTokenAccountIdempotentInstruction( + (this.program.provider as AnchorProvider).wallet.publicKey, + mangoAccount.owner, + bank.mint, + ), + ); + + // wsol case + if (bank.mint.equals(NATIVE_MINT)) { + postInstructions.push( + createCloseAccountInstruction( + ownerAtaTokenAccount, + mangoAccount.owner, + mangoAccount.owner, + ), + ); + } + } else { + const account = await unpackAccount(ownerAtaTokenAccount, ai); + // if owner is not same as mango account's owner on the ATA (for whatever reason) + // then create another token account + if (!account.owner.equals(mangoAccount.owner)) { + const kp = Keypair.generate(); + alternateOwnerTokenAccount = kp.publicKey; + await createAccount( + this.connection, + (this.program.provider as AnchorProvider).wallet as any as Signer, + bank.mint, + mangoAccount.owner, + kp, + ); + + // wsol case + if (bank.mint.equals(NATIVE_MINT)) { + postInstructions.push( + createCloseAccountInstruction( + alternateOwnerTokenAccount, + mangoAccount.owner, + mangoAccount.owner, + ), + ); + } + } + } + + const ix = await this.program.methods + .tokenForceWithdraw() + .accounts({ + group: group.publicKey, + account: mangoAccount.publicKey, + bank: bank.publicKey, + vault: bank.vault, + oracle: bank.oracle, + ownerAtaTokenAccount, + alternateOwnerTokenAccount, + }) + .instruction(); + return await this.sendAndConfirmTransactionForGroup(group, [ + ...preInstructions, + ix, + ...postInstructions, + ]); + } + public async tokenDeregister( group: Group, mintPk: PublicKey, @@ -737,16 +838,20 @@ export class MangoClient { mintPk: PublicKey, price: number, ): Promise { + const stubOracle = Keypair.generate(); const ix = await this.program.methods .stubOracleCreate({ val: I80F48.fromNumber(price).getData() }) .accounts({ group: group.publicKey, admin: (this.program.provider as AnchorProvider).wallet.publicKey, + oracle: stubOracle.publicKey, mint: mintPk, payer: (this.program.provider as AnchorProvider).wallet.publicKey, }) .instruction(); - return await this.sendAndConfirmTransactionForGroup(group, [ix]); + return await this.sendAndConfirmTransactionForGroup(group, [ix], { + additionalSigners: [stubOracle], + }); } public async stubOracleClose( diff --git a/ts/client/src/clientIxParamBuilder.ts b/ts/client/src/clientIxParamBuilder.ts index 83a92323d..0074f7aa6 100644 --- a/ts/client/src/clientIxParamBuilder.ts +++ b/ts/client/src/clientIxParamBuilder.ts @@ -30,6 +30,8 @@ export interface TokenRegisterParams { depositLimit: BN; zeroUtilRate: number; platformLiquidationFee: number; + disableAssetLiquidation: boolean; + collateralFeePerDay: number; } export const DefaultTokenRegisterParams: TokenRegisterParams = { @@ -70,6 +72,8 @@ export const DefaultTokenRegisterParams: TokenRegisterParams = { depositLimit: new BN(0), zeroUtilRate: 0.0, platformLiquidationFee: 0.0, + disableAssetLiquidation: false, + collateralFeePerDay: 0.0, }; export interface TokenEditParams { @@ -107,10 +111,13 @@ export interface TokenEditParams { maintWeightShiftAssetTarget: number | null; maintWeightShiftLiabTarget: number | null; maintWeightShiftAbort: boolean | null; - setFallbackOracle: boolean | null; + fallbackOracle: PublicKey | null; depositLimit: BN | null; zeroUtilRate: number | null; platformLiquidationFee: number | null; + disableAssetLiquidation: boolean | null; + collateralFeePerDay: number | null; + forceWithdraw: boolean | null; } export const NullTokenEditParams: TokenEditParams = { @@ -148,10 +155,13 @@ export const NullTokenEditParams: TokenEditParams = { maintWeightShiftAssetTarget: null, maintWeightShiftLiabTarget: null, maintWeightShiftAbort: null, - setFallbackOracle: null, + fallbackOracle: null, depositLimit: null, zeroUtilRate: null, platformLiquidationFee: null, + disableAssetLiquidation: null, + collateralFeePerDay: null, + forceWithdraw: null, }; export interface PerpEditParams { @@ -299,6 +309,7 @@ export interface IxGateParams { TokenConditionalSwapCreatePremiumAuction: boolean; TokenConditionalSwapCreateLinearAuction: boolean; Serum3PlaceOrderV2: boolean; + TokenForceWithdraw: boolean; } // Default with all ixs enabled, use with buildIxGate @@ -378,6 +389,7 @@ export const TrueIxGateParams: IxGateParams = { TokenConditionalSwapCreatePremiumAuction: true, TokenConditionalSwapCreateLinearAuction: true, Serum3PlaceOrderV2: true, + TokenForceWithdraw: true, }; // build ix gate e.g. buildIxGate(Builder(TrueIxGateParams).TokenDeposit(false).build()).toNumber(), @@ -467,6 +479,7 @@ export function buildIxGate(p: IxGateParams): BN { toggleIx(ixGate, p, 'TokenConditionalSwapCreatePremiumAuction', 69); toggleIx(ixGate, p, 'TokenConditionalSwapCreateLinearAuction', 70); toggleIx(ixGate, p, 'Serum3PlaceOrderV2', 71); + toggleIx(ixGate, p, 'TokenForceWithdraw', 72); return ixGate; } diff --git a/ts/client/src/mango_v4.ts b/ts/client/src/mango_v4.ts index 25111aa8f..44fce5b9a 100644 --- a/ts/client/src/mango_v4.ts +++ b/ts/client/src/mango_v4.ts @@ -1,5 +1,5 @@ export type MangoV4 = { - "version": "0.22.0", + "version": "0.23.0", "name": "mango_v4", "instructions": [ { @@ -277,6 +277,12 @@ export type MangoV4 = { "type": { "option": "u16" } + }, + { + "name": "collateralFeeIntervalOpt", + "type": { + "option": "u64" + } } ] }, @@ -631,6 +637,14 @@ export type MangoV4 = { { "name": "platformLiquidationFee", "type": "f32" + }, + { + "name": "disableAssetLiquidation", + "type": "bool" + }, + { + "name": "collateralFeePerDay", + "type": "f32" } ] }, @@ -1041,6 +1055,24 @@ export type MangoV4 = { "type": { "option": "f32" } + }, + { + "name": "disableAssetLiquidationOpt", + "type": { + "option": "bool" + } + }, + { + "name": "collateralFeePerDayOpt", + "type": { + "option": "f32" + } + }, + { + "name": "forceWithdrawOpt", + "type": { + "option": "bool" + } } ] }, @@ -3763,6 +3795,63 @@ export type MangoV4 = { } ] }, + { + "name": "tokenForceWithdraw", + "accounts": [ + { + "name": "group", + "isMut": false, + "isSigner": false + }, + { + "name": "account", + "isMut": true, + "isSigner": false, + "relations": [ + "group" + ] + }, + { + "name": "bank", + "isMut": true, + "isSigner": false, + "relations": [ + "group", + "vault", + "oracle" + ] + }, + { + "name": "vault", + "isMut": true, + "isSigner": false + }, + { + "name": "oracle", + "isMut": false, + "isSigner": false + }, + { + "name": "ownerAtaTokenAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "alternateOwnerTokenAccount", + "isMut": true, + "isSigner": false, + "docs": [ + "Only for the unusual case where the owner_ata account is not owned by account.owner" + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, { "name": "perpCreateMarket", "docs": [ @@ -5953,6 +6042,25 @@ export type MangoV4 = { } ] }, + { + "name": "tokenChargeCollateralFees", + "accounts": [ + { + "name": "group", + "isMut": false, + "isSigner": false + }, + { + "name": "account", + "isMut": true, + "isSigner": false, + "relations": [ + "group" + ] + } + ], + "args": [] + }, { "name": "altSet", "accounts": [ @@ -7373,12 +7481,24 @@ export type MangoV4 = { "name": "forceClose", "type": "u8" }, + { + "name": "disableAssetLiquidation", + "docs": [ + "If set to 1, deposits cannot be liquidated when an account is liquidatable.", + "That means bankrupt accounts may still have assets of this type deposited." + ], + "type": "u8" + }, + { + "name": "forceWithdraw", + "type": "u8" + }, { "name": "padding", "type": { "array": [ "u8", - 6 + 4 ] } }, @@ -7513,12 +7633,30 @@ export type MangoV4 = { "defined": "I80F48" } }, + { + "name": "collectedCollateralFees", + "docs": [ + "Collateral fees that have been collected (in native tokens)", + "", + "See also collected_fees_native and fees_withdrawn." + ], + "type": { + "defined": "I80F48" + } + }, + { + "name": "collateralFeePerDay", + "docs": [ + "The daily collateral fees rate for fully utilized collateral." + ], + "type": "f32" + }, { "name": "reserved", "type": { "array": [ "u8", - 1920 + 1900 ] } } @@ -7646,12 +7784,28 @@ export type MangoV4 = { ], "type": "u16" }, + { + "name": "padding2", + "type": { + "array": [ + "u8", + 4 + ] + } + }, + { + "name": "collateralFeeInterval", + "docs": [ + "Intervals in which collateral fee is applied" + ], + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 1812 + 1800 ] } } @@ -7773,12 +7927,27 @@ export type MangoV4 = { ], "type": "u64" }, + { + "name": "temporaryDelegate", + "type": "publicKey" + }, + { + "name": "temporaryDelegateExpiry", + "type": "u64" + }, + { + "name": "lastCollateralFeeCharge", + "docs": [ + "Time at which the last collateral fee was charged" + ], + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 200 + 152 ] } }, @@ -9548,12 +9717,16 @@ export type MangoV4 = { "name": "temporaryDelegateExpiry", "type": "u64" }, + { + "name": "lastCollateralFeeCharge", + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 160 + 152 ] } } @@ -10474,6 +10647,9 @@ export type MangoV4 = { }, { "name": "Swap" + }, + { + "name": "SwapWithoutFee" } ] } @@ -10829,6 +11005,9 @@ export type MangoV4 = { }, { "name": "Serum3PlaceOrderV2" + }, + { + "name": "TokenForceWithdraw" } ] } @@ -13746,6 +13925,76 @@ export type MangoV4 = { "index": false } ] + }, + { + "name": "TokenCollateralFeeLog", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "mangoAccount", + "type": "publicKey", + "index": false + }, + { + "name": "tokenIndex", + "type": "u16", + "index": false + }, + { + "name": "assetUsageFraction", + "type": "i128", + "index": false + }, + { + "name": "fee", + "type": "i128", + "index": false + }, + { + "name": "price", + "type": "i128", + "index": false + } + ] + }, + { + "name": "ForceWithdrawLog", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "mangoAccount", + "type": "publicKey", + "index": false + }, + { + "name": "tokenIndex", + "type": "u16", + "index": false + }, + { + "name": "quantity", + "type": "u64", + "index": false + }, + { + "name": "price", + "type": "i128", + "index": false + }, + { + "name": "toTokenAccount", + "type": "publicKey", + "index": false + } + ] } ], "errors": [ @@ -14093,12 +14342,17 @@ export type MangoV4 = { "code": 6068, "name": "MissingFeedForCLMMOracle", "msg": "Pyth USDC/USD or SOL/USD feed not found (required by CLMM oracle)" + }, + { + "code": 6069, + "name": "TokenAssetLiquidationDisabled", + "msg": "the asset does not allow liquidation" } ] }; export const IDL: MangoV4 = { - "version": "0.22.0", + "version": "0.23.0", "name": "mango_v4", "instructions": [ { @@ -14376,6 +14630,12 @@ export const IDL: MangoV4 = { "type": { "option": "u16" } + }, + { + "name": "collateralFeeIntervalOpt", + "type": { + "option": "u64" + } } ] }, @@ -14730,6 +14990,14 @@ export const IDL: MangoV4 = { { "name": "platformLiquidationFee", "type": "f32" + }, + { + "name": "disableAssetLiquidation", + "type": "bool" + }, + { + "name": "collateralFeePerDay", + "type": "f32" } ] }, @@ -15140,6 +15408,24 @@ export const IDL: MangoV4 = { "type": { "option": "f32" } + }, + { + "name": "disableAssetLiquidationOpt", + "type": { + "option": "bool" + } + }, + { + "name": "collateralFeePerDayOpt", + "type": { + "option": "f32" + } + }, + { + "name": "forceWithdrawOpt", + "type": { + "option": "bool" + } } ] }, @@ -17862,6 +18148,63 @@ export const IDL: MangoV4 = { } ] }, + { + "name": "tokenForceWithdraw", + "accounts": [ + { + "name": "group", + "isMut": false, + "isSigner": false + }, + { + "name": "account", + "isMut": true, + "isSigner": false, + "relations": [ + "group" + ] + }, + { + "name": "bank", + "isMut": true, + "isSigner": false, + "relations": [ + "group", + "vault", + "oracle" + ] + }, + { + "name": "vault", + "isMut": true, + "isSigner": false + }, + { + "name": "oracle", + "isMut": false, + "isSigner": false + }, + { + "name": "ownerAtaTokenAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "alternateOwnerTokenAccount", + "isMut": true, + "isSigner": false, + "docs": [ + "Only for the unusual case where the owner_ata account is not owned by account.owner" + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, { "name": "perpCreateMarket", "docs": [ @@ -20052,6 +20395,25 @@ export const IDL: MangoV4 = { } ] }, + { + "name": "tokenChargeCollateralFees", + "accounts": [ + { + "name": "group", + "isMut": false, + "isSigner": false + }, + { + "name": "account", + "isMut": true, + "isSigner": false, + "relations": [ + "group" + ] + } + ], + "args": [] + }, { "name": "altSet", "accounts": [ @@ -21472,12 +21834,24 @@ export const IDL: MangoV4 = { "name": "forceClose", "type": "u8" }, + { + "name": "disableAssetLiquidation", + "docs": [ + "If set to 1, deposits cannot be liquidated when an account is liquidatable.", + "That means bankrupt accounts may still have assets of this type deposited." + ], + "type": "u8" + }, + { + "name": "forceWithdraw", + "type": "u8" + }, { "name": "padding", "type": { "array": [ "u8", - 6 + 4 ] } }, @@ -21612,12 +21986,30 @@ export const IDL: MangoV4 = { "defined": "I80F48" } }, + { + "name": "collectedCollateralFees", + "docs": [ + "Collateral fees that have been collected (in native tokens)", + "", + "See also collected_fees_native and fees_withdrawn." + ], + "type": { + "defined": "I80F48" + } + }, + { + "name": "collateralFeePerDay", + "docs": [ + "The daily collateral fees rate for fully utilized collateral." + ], + "type": "f32" + }, { "name": "reserved", "type": { "array": [ "u8", - 1920 + 1900 ] } } @@ -21745,12 +22137,28 @@ export const IDL: MangoV4 = { ], "type": "u16" }, + { + "name": "padding2", + "type": { + "array": [ + "u8", + 4 + ] + } + }, + { + "name": "collateralFeeInterval", + "docs": [ + "Intervals in which collateral fee is applied" + ], + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 1812 + 1800 ] } } @@ -21872,12 +22280,27 @@ export const IDL: MangoV4 = { ], "type": "u64" }, + { + "name": "temporaryDelegate", + "type": "publicKey" + }, + { + "name": "temporaryDelegateExpiry", + "type": "u64" + }, + { + "name": "lastCollateralFeeCharge", + "docs": [ + "Time at which the last collateral fee was charged" + ], + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 200 + 152 ] } }, @@ -23647,12 +24070,16 @@ export const IDL: MangoV4 = { "name": "temporaryDelegateExpiry", "type": "u64" }, + { + "name": "lastCollateralFeeCharge", + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 160 + 152 ] } } @@ -24573,6 +25000,9 @@ export const IDL: MangoV4 = { }, { "name": "Swap" + }, + { + "name": "SwapWithoutFee" } ] } @@ -24928,6 +25358,9 @@ export const IDL: MangoV4 = { }, { "name": "Serum3PlaceOrderV2" + }, + { + "name": "TokenForceWithdraw" } ] } @@ -27845,6 +28278,76 @@ export const IDL: MangoV4 = { "index": false } ] + }, + { + "name": "TokenCollateralFeeLog", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "mangoAccount", + "type": "publicKey", + "index": false + }, + { + "name": "tokenIndex", + "type": "u16", + "index": false + }, + { + "name": "assetUsageFraction", + "type": "i128", + "index": false + }, + { + "name": "fee", + "type": "i128", + "index": false + }, + { + "name": "price", + "type": "i128", + "index": false + } + ] + }, + { + "name": "ForceWithdrawLog", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "mangoAccount", + "type": "publicKey", + "index": false + }, + { + "name": "tokenIndex", + "type": "u16", + "index": false + }, + { + "name": "quantity", + "type": "u64", + "index": false + }, + { + "name": "price", + "type": "i128", + "index": false + }, + { + "name": "toTokenAccount", + "type": "publicKey", + "index": false + } + ] } ], "errors": [ @@ -28192,6 +28695,11 @@ export const IDL: MangoV4 = { "code": 6068, "name": "MissingFeedForCLMMOracle", "msg": "Pyth USDC/USD or SOL/USD feed not found (required by CLMM oracle)" + }, + { + "code": 6069, + "name": "TokenAssetLiquidationDisabled", + "msg": "the asset does not allow liquidation" } ] }; diff --git a/ts/client/src/types.ts b/ts/client/src/types.ts index 09d1b97f5..47d378477 100644 --- a/ts/client/src/types.ts +++ b/ts/client/src/types.ts @@ -9,11 +9,13 @@ export class FlashLoanWithdraw { export type FlashLoanType = | { unknown: Record } - | { swap: Record }; + | { swap: Record } + | { swapWithoutFee: Record }; // eslint-disable-next-line @typescript-eslint/no-namespace export namespace FlashLoanType { export const unknown = { unknown: {} }; export const swap = { swap: {} }; + export const swapWithoutFee = { swapWithoutFee: {} }; } export class InterestRateParams {