diff --git a/CHANGELOG.md b/CHANGELOG.md index 059ff79d3..02dfd0a4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,33 @@ Update this for each program release and mainnet deployment. ## not on mainnet -### v0.23.0, 2024-3- +### v0.24.0, 2024-4- + +- Allow skipping banks and invalid oracles when computing health (#891) + + This is only possible when we know for sure that the operation would not put the account into negative health zone. + +- Add support for Raydium CLMM as oracle fallback (#856) + +- Add a `TokenBalanceLog` when charging collateral fees (#894) + +- Withdraw instruction: remove overflow error and return appropriate error message instead (#910) + +- Banks: add more safety checks (#895) + +- Add a health check instruction (#913) + + Assert in a transaction that operation run on a mango account does not reduce it's health below a specified amount. + +- Add a sequence check instruction (#909) + + Assert that a transaction was emitted and run with a correct view of the current mango state. + +## mainnet + +### v0.23.0, 2024-3-8 + +Deployment: Mar 8, 2024 at 12:10:52 Central European Standard Time, https://explorer.solana.com/tx/6MXGookZoYGMYb7tWrrmgZzVA13HJimHNqwHRVFeqL9YpQD7YasH1pQn4MSQTK1o13ixKTGFxwZsviUzmHzzP9m - Allow disabling asset liquidations for tokens (#867) @@ -26,8 +52,6 @@ Update this for each program release and mainnet deployment. - 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 diff --git a/Cargo.lock b/Cargo.lock index 85ab8bd2e..a54abdb97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2455,6 +2455,20 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a" +[[package]] +name = "hdrhistogram" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d" +dependencies = [ + "base64 0.21.4", + "byteorder", + "crossbeam-channel", + "flate2 1.0.27", + "nom 7.1.3", + "num-traits", +] + [[package]] name = "headers" version = "0.3.9" @@ -3367,15 +3381,17 @@ dependencies = [ [[package]] name = "mango-v4" -version = "0.23.0" +version = "0.24.0" dependencies = [ "anchor-lang", "anchor-spl", + "anyhow", "arrayref", "async-trait", "base64 0.13.1", "bincode", "borsh 0.10.3", + "bs58 0.5.0", "bytemuck", "default-env", "derivative", @@ -3391,6 +3407,7 @@ dependencies = [ "rand 0.8.5", "regex", "serde", + "serde_json", "serum_dex 0.5.10 (git+https://github.com/openbook-dex/program.git)", "solana-address-lookup-table-program", "solana-logger", @@ -3522,6 +3539,7 @@ dependencies = [ "futures 0.3.28", "futures-core", "futures-util", + "hdrhistogram", "itertools", "jemallocator", "jsonrpc-core 18.0.0 (registry+https://github.com/rust-lang/crates.io-index)", diff --git a/RELEASING.md b/RELEASING.md index 01496df95..74703dd03 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -7,19 +7,18 @@ - 4MangoMjqJ2firMokCjjGgoK8d4MXcrgL7XJaL3w6fVg is the address of the Mango v4 Program - FP4PxqHTVzeG2c6eZd7974F9WvKUSdBeduUK3rjYyvBw is the address of the Mango v4 Program Governance -- Check out the latest version of the `dev` branch +- Assuming there's a release branch (like release/program-v0.22.0) + with a completed audit and an updated changelog. -- Update the changelog - - git log program-v0.11.0..HEAD -- programs/mango-v4/ +- Check out the release branch - Make sure the version is bumped in programs/mango-v4/Cargo.toml -- Update the idl ./update-local-idl.sh +- Update the idl ./update-local-idl.sh and verify that there's no difference -- Run the tests to double check +- Run the tests to double check there are no failures -- Tag and push +- Tag (`git tag program-v0.xy.z HEAD`) and push it (`git push `) - Do a verifiable build diff --git a/audits/Audit_OtterSec_Mango_v0.22.0.pdf b/audits/Audit_OtterSec_Mango_v0.22.0.pdf new file mode 100644 index 000000000..6a17d2d8a Binary files /dev/null and b/audits/Audit_OtterSec_Mango_v0.22.0.pdf differ diff --git a/audits/Audit_OtterSec_Mango_v0.23.0.pdf b/audits/Audit_OtterSec_Mango_v0.23.0.pdf new file mode 100644 index 000000000..0f76ab97d Binary files /dev/null and b/audits/Audit_OtterSec_Mango_v0.23.0.pdf differ diff --git a/audits/Audit_OtterSec_Mango_v0.24.0.pdf b/audits/Audit_OtterSec_Mango_v0.24.0.pdf new file mode 100644 index 000000000..ed32f6018 Binary files /dev/null and b/audits/Audit_OtterSec_Mango_v0.24.0.pdf differ diff --git a/bin/cli/src/save_snapshot.rs b/bin/cli/src/save_snapshot.rs index b65524ca7..edd6e63e5 100644 --- a/bin/cli/src/save_snapshot.rs +++ b/bin/cli/src/save_snapshot.rs @@ -28,12 +28,13 @@ pub async fn save_snapshot( let group_context = MangoGroupContext::new_from_rpc(client.rpc_async(), mango_group).await?; - let oracles_and_vaults = group_context + let extra_accounts = group_context .tokens .values() .map(|value| value.oracle) .chain(group_context.perp_markets.values().map(|p| p.oracle)) .chain(group_context.tokens.values().flat_map(|value| value.vaults)) + .chain(group_context.address_lookup_tables.iter().copied()) .unique() .filter(|pk| *pk != Pubkey::default()) .collect::>(); @@ -55,7 +56,7 @@ pub async fn save_snapshot( serum_programs, open_orders_authority: mango_group, }, - oracles_and_vaults.clone(), + extra_accounts.clone(), account_update_sender.clone(), ); @@ -75,7 +76,7 @@ pub async fn save_snapshot( snapshot_interval: Duration::from_secs(6000), min_slot: first_websocket_slot + 10, }, - oracles_and_vaults, + extra_accounts, account_update_sender, ); tokio::spawn(async move { diff --git a/bin/liquidator/Cargo.toml b/bin/liquidator/Cargo.toml index 846aaaf7f..d591bd37b 100644 --- a/bin/liquidator/Cargo.toml +++ b/bin/liquidator/Cargo.toml @@ -49,3 +49,4 @@ tokio-stream = { version = "0.1.9"} tokio-tungstenite = "0.16.1" tracing = "0.1" regex = "1.9.5" +hdrhistogram = "7.5.4" \ No newline at end of file diff --git a/bin/liquidator/src/main.rs b/bin/liquidator/src/main.rs index eb79f14f4..cf6814b61 100644 --- a/bin/liquidator/src/main.rs +++ b/bin/liquidator/src/main.rs @@ -287,6 +287,8 @@ async fn main() -> anyhow::Result<()> { let mut metric_account_update_queue_len = metrics.register_u64("account_update_queue_length".into()); + let mut metric_chain_update_latency = + metrics.register_latency("in-memory chain update".into()); let mut metric_mango_accounts = metrics.register_u64("mango_accounts".into()); let mut mint_infos = HashMap::::new(); @@ -299,6 +301,7 @@ async fn main() -> anyhow::Result<()> { .recv() .await .expect("channel not closed"); + let current_time = Instant::now(); metric_account_update_queue_len.set(account_update_receiver.len() as u64); message.update_chain_data(&mut chain_data.write().unwrap()); @@ -306,6 +309,15 @@ async fn main() -> anyhow::Result<()> { match message { Message::Account(account_write) => { let mut state = shared_state.write().unwrap(); + let reception_time = account_write.reception_time; + state.oldest_chain_event_reception_time = Some( + state + .oldest_chain_event_reception_time + .unwrap_or(reception_time), + ); + + metric_chain_update_latency.push(current_time - reception_time); + if is_mango_account(&account_write.account, &mango_group).is_some() { // e.g. to render debug logs RUST_LOG="liquidator=debug" debug!( @@ -320,8 +332,21 @@ async fn main() -> anyhow::Result<()> { } Message::Snapshot(snapshot) => { let mut state = shared_state.write().unwrap(); + let mut reception_time = None; + // Track all mango account pubkeys for update in snapshot.iter() { + reception_time = Some( + update + .reception_time + .min(reception_time.unwrap_or(update.reception_time)), + ); + state.oldest_chain_event_reception_time = Some( + state + .oldest_chain_event_reception_time + .unwrap_or(update.reception_time), + ); + if is_mango_account(&update.account, &mango_group).is_some() { state.mango_accounts.insert(update.pubkey); } @@ -335,6 +360,11 @@ async fn main() -> anyhow::Result<()> { oracles.insert(perp_market.oracle); } } + + if reception_time.is_some() { + metric_chain_update_latency + .push(current_time - reception_time.unwrap()); + } metric_mango_accounts.set(state.mango_accounts.len() as u64); state.one_snapshot_done = true; @@ -374,35 +404,82 @@ async fn main() -> anyhow::Result<()> { let liquidation_job = tokio::spawn({ let mut interval = mango_v4_client::delay_interval(Duration::from_millis(cli.check_interval_ms)); + let mut metric_liquidation_check = metrics.register_latency("liquidation_check".into()); + let mut metric_liquidation_start_end = + metrics.register_latency("liquidation_start_end".into()); + + let mut liquidation_start_time = None; + let mut tcs_start_time = None; + let shared_state = shared_state.clone(); async move { loop { interval.tick().await; let account_addresses = { - let state = shared_state.write().unwrap(); + let mut state = shared_state.write().unwrap(); if !state.one_snapshot_done { + // discard first latency info as it will skew data too much + state.oldest_chain_event_reception_time = None; continue; } + if state.oldest_chain_event_reception_time.is_none() + && liquidation_start_time.is_none() + { + // no new update, skip computing + continue; + } + state.mango_accounts.iter().cloned().collect_vec() }; liquidation.errors.update(); liquidation.oracle_errors.update(); + if liquidation_start_time.is_none() { + liquidation_start_time = Some(Instant::now()); + } + let liquidated = liquidation .maybe_liquidate_one(account_addresses.iter()) .await; + if !liquidated { + // This will be incorrect if we liquidate the last checked account + // (We will wait for next full run, skewing latency metrics) + // Probability is very low, might not need to be fixed + + let mut state = shared_state.write().unwrap(); + let reception_time = state.oldest_chain_event_reception_time.unwrap(); + let current_time = Instant::now(); + + state.oldest_chain_event_reception_time = None; + + metric_liquidation_check.push(current_time - reception_time); + metric_liquidation_start_end + .push(current_time - liquidation_start_time.unwrap()); + liquidation_start_time = None; + } + let mut took_tcs = false; if !liquidated && cli.take_tcs == BoolArg::True { + tcs_start_time = Some(tcs_start_time.unwrap_or(Instant::now())); + took_tcs = liquidation .maybe_take_token_conditional_swap(account_addresses.iter()) .await .unwrap_or_else(|err| { error!("error during maybe_take_token_conditional_swap: {err}"); false - }) + }); + + if !took_tcs { + let current_time = Instant::now(); + let mut metric_tcs_start_end = + metrics.register_latency("tcs_start_end".into()); + metric_tcs_start_end.push(current_time - tcs_start_time.unwrap()); + tcs_start_time = None; + } } if liquidated || took_tcs { @@ -483,6 +560,9 @@ struct SharedState { /// Is the first snapshot done? Only start checking account health when it is. one_snapshot_done: bool, + + /// Oldest chain event not processed yet + oldest_chain_event_reception_time: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/bin/liquidator/src/metrics.rs b/bin/liquidator/src/metrics.rs index 526587576..68291b08f 100644 --- a/bin/liquidator/src/metrics.rs +++ b/bin/liquidator/src/metrics.rs @@ -1,3 +1,5 @@ +use hdrhistogram::Histogram; +use std::time::Duration; use { std::collections::HashMap, std::sync::{atomic, Arc, Mutex, RwLock}, @@ -10,6 +12,7 @@ enum Value { U64(Arc), I64(Arc), String(Arc>), + Latency(Arc>>), } #[derive(Debug)] @@ -49,6 +52,18 @@ impl MetricU64 { } } +#[derive(Clone)] +pub struct MetricLatency { + value: Arc>>, +} +impl MetricLatency { + pub fn push(&mut self, duration: std::time::Duration) { + let mut guard = self.value.lock().unwrap(); + let ns: u64 = duration.as_nanos().try_into().unwrap(); + guard.record(ns).expect("latency error"); + } +} + #[derive(Clone)] pub struct MetricI64 { value: Arc, @@ -110,6 +125,19 @@ impl Metrics { } } + pub fn register_latency(&self, name: String) -> MetricLatency { + let mut registry = self.registry.write().unwrap(); + let value = registry.entry(name).or_insert_with(|| { + Value::Latency(Arc::new(Mutex::new(Histogram::::new(3).unwrap()))) + }); + MetricLatency { + value: match value { + Value::Latency(v) => v.clone(), + _ => panic!("bad metric type"), + }, + } + } + pub fn register_string(&self, name: String) -> MetricString { let mut registry = self.registry.write().unwrap(); let value = registry @@ -187,6 +215,16 @@ pub fn start() -> Metrics { ); } } + Value::Latency(v) => { + let hist = v.lock().unwrap(); + + info!( + "metric: {}: 99'th percentile: {:?}, 99,9'th percentile: {:?}", + name, + Duration::from_nanos(hist.value_at_quantile(0.99)), + Duration::from_nanos(hist.value_at_quantile(0.999)) + ); + } } } } diff --git a/lib/client/src/account_update_stream.rs b/lib/client/src/account_update_stream.rs index d73987e2f..ec148d996 100644 --- a/lib/client/src/account_update_stream.rs +++ b/lib/client/src/account_update_stream.rs @@ -1,6 +1,7 @@ use solana_client::rpc_response::{Response, RpcKeyedAccount}; use solana_sdk::{account::AccountSharedData, pubkey::Pubkey}; +use std::time::Instant; use std::{str::FromStr, sync::Arc}; use tracing::*; @@ -11,6 +12,7 @@ pub struct AccountUpdate { pub pubkey: Pubkey, pub slot: u64, pub account: AccountSharedData, + pub reception_time: Instant, } impl AccountUpdate { @@ -25,15 +27,22 @@ impl AccountUpdate { pubkey, slot: rpc.context.slot, account, + reception_time: Instant::now(), }) } } +#[derive(Clone)] +pub struct ChainSlotUpdate { + pub slot_update: Arc, + pub reception_time: Instant, +} + #[derive(Clone)] pub enum Message { Account(AccountUpdate), Snapshot(Vec), - Slot(Arc), + Slot(ChainSlotUpdate), } impl Message { @@ -65,7 +74,7 @@ impl Message { } Message::Slot(slot_update) => { trace!("websocket slot message"); - let slot_update = match **slot_update { + let slot_update = match *(slot_update.slot_update) { solana_client::rpc_response::SlotUpdate::CreatedBank { slot, parent, .. } => Some(SlotData { diff --git a/lib/client/src/context.rs b/lib/client/src/context.rs index 73e31018c..365dc47f7 100644 --- a/lib/client/src/context.rs +++ b/lib/client/src/context.rs @@ -7,9 +7,9 @@ use anchor_lang::__private::bytemuck; 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, + determine_oracle_type, load_orca_pool_state, load_raydium_pool_state, + oracle_state_unchecked, Group, MangoAccountValue, OracleAccountInfos, OracleConfig, + OracleConfigParams, OracleType, PerpMarketIndex, Serum3MarketIndex, TokenIndex, MAX_BANKS, }, }; @@ -721,10 +721,14 @@ 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() { + OracleType::OrcaCLMM => match load_orca_pool_state(acc_info).ok() { Some(whirlpool) => whirlpool.get_quote_oracle().ok(), None => None, }, + OracleType::RaydiumCLMM => match load_raydium_pool_state(acc_info).ok() { + Some(pool) => pool.get_quote_oracle().ok(), + None => None, + }, _ => None, }, None => None, diff --git a/lib/client/src/snapshot_source.rs b/lib/client/src/snapshot_source.rs index 05c29f64c..07f6fee7b 100644 --- a/lib/client/src/snapshot_source.rs +++ b/lib/client/src/snapshot_source.rs @@ -15,8 +15,7 @@ use futures::{stream, StreamExt}; use solana_rpc::rpc::rpc_accounts::AccountsDataClient; use solana_rpc::rpc::rpc_accounts_scan::AccountsScanClient; use std::str::FromStr; -use std::time::Duration; -use tokio::task::JoinHandle; +use std::time::{Duration, Instant}; use tokio::time; use tracing::*; @@ -56,6 +55,7 @@ impl AccountSnapshot { .account .decode() .ok_or_else(|| anyhow::anyhow!("could not decode account"))?, + reception_time: Instant::now(), }); } Ok(()) @@ -75,6 +75,7 @@ impl AccountSnapshot { account: ui_account .decode() .ok_or_else(|| anyhow::anyhow!("could not decode account"))?, + reception_time: Instant::now(), }); } } diff --git a/lib/client/src/websocket_source.rs b/lib/client/src/websocket_source.rs index fa6a379b1..c3d9b89e7 100644 --- a/lib/client/src/websocket_source.rs +++ b/lib/client/src/websocket_source.rs @@ -11,11 +11,11 @@ use solana_rpc::rpc_pubsub::RpcSolPubSubClient; use solana_sdk::{commitment_config::CommitmentConfig, pubkey::Pubkey}; use anyhow::Context; -use std::time::Duration; +use std::time::{Duration, Instant}; use tokio_stream::StreamMap; use tracing::*; -use crate::account_update_stream::{AccountUpdate, Message}; +use crate::account_update_stream::{AccountUpdate, ChainSlotUpdate, Message}; use crate::AnyhowWrap; pub struct Config { @@ -143,7 +143,10 @@ async fn feed_data( }, message = slot_sub.next() => { if let Some(data) = message { - sender.send(Message::Slot(data.map_err_anyhow()?)).await.expect("sending must succeed"); + sender.send(Message::Slot(ChainSlotUpdate{ + slot_update: data.map_err_anyhow()?, + reception_time: Instant::now() + })).await.expect("sending must succeed"); } else { warn!("slot update stream closed"); return Ok(()); @@ -200,7 +203,7 @@ pub async fn get_next_create_bank_slot( match msg { Message::Slot(slot_update) => { if let solana_client::rpc_response::SlotUpdate::CreatedBank { slot, .. } = - *slot_update + *slot_update.slot_update { return Ok(slot); } diff --git a/mango_v4.json b/mango_v4.json index 97fce5d5d..cc44fefe8 100644 --- a/mango_v4.json +++ b/mango_v4.json @@ -1,5 +1,5 @@ { - "version": "0.23.0", + "version": "0.24.0", "name": "mango_v4", "instructions": [ { @@ -1760,6 +1760,66 @@ } ] }, + { + "name": "sequenceCheck", + "accounts": [ + { + "name": "group", + "isMut": false, + "isSigner": false + }, + { + "name": "account", + "isMut": true, + "isSigner": false, + "relations": [ + "group", + "owner" + ] + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + } + ], + "args": [ + { + "name": "expectedSequenceNumber", + "type": "u8" + } + ] + }, + { + "name": "healthCheck", + "accounts": [ + { + "name": "group", + "isMut": false, + "isSigner": false + }, + { + "name": "account", + "isMut": true, + "isSigner": false, + "relations": [ + "group" + ] + } + ], + "args": [ + { + "name": "minHealthValue", + "type": "f64" + }, + { + "name": "checkKind", + "type": { + "defined": "HealthCheckKind" + } + } + ] + }, { "name": "stubOracleCreate", "accounts": [ @@ -7871,13 +7931,8 @@ "type": "u8" }, { - "name": "padding", - "type": { - "array": [ - "u8", - 1 - ] - } + "name": "sequenceNumber", + "type": "u8" }, { "name": "netDeposits", @@ -9669,13 +9724,8 @@ "type": "u8" }, { - "name": "padding", - "type": { - "array": [ - "u8", - 1 - ] - } + "name": "sequenceNumber", + "type": "u8" }, { "name": "netDeposits", @@ -10654,6 +10704,32 @@ ] } }, + { + "name": "HealthCheckKind", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Maint" + }, + { + "name": "Init" + }, + { + "name": "LiquidationEnd" + }, + { + "name": "MaintRatio" + }, + { + "name": "InitRatio" + }, + { + "name": "LiquidationEndRatio" + } + ] + } + }, { "name": "Serum3SelfTradeBehavior", "docs": [ @@ -11008,6 +11084,12 @@ }, { "name": "TokenForceWithdraw" + }, + { + "name": "SequenceCheck" + }, + { + "name": "HealthCheck" } ] } @@ -11048,6 +11130,9 @@ }, { "name": "OrcaCLMM" + }, + { + "name": "RaydiumCLMM" } ] } @@ -14347,6 +14432,21 @@ "code": 6069, "name": "TokenAssetLiquidationDisabled", "msg": "the asset does not allow liquidation" + }, + { + "code": 6070, + "name": "BorrowsRequireHealthAccountBank", + "msg": "for borrows the bank must be in the health account list" + }, + { + "code": 6071, + "name": "InvalidSequenceNumber", + "msg": "invalid sequence number" + }, + { + "code": 6072, + "name": "InvalidHealth", + "msg": "invalid health" } ] } \ No newline at end of file diff --git a/programs/mango-v4/Cargo.toml b/programs/mango-v4/Cargo.toml index f9d5433bb..101d3bdbb 100644 --- a/programs/mango-v4/Cargo.toml +++ b/programs/mango-v4/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mango-v4" -version = "0.23.0" +version = "0.24.0" description = "Created with Anchor" edition = "2021" @@ -75,3 +75,6 @@ rand = "0.8.4" lazy_static = "1.4.0" num = "0.4.0" regex = "1" +serde_json = "1" +bs58 = "0.5" +anyhow = "1" diff --git a/programs/mango-v4/resources/test/Ds33rQ1d4AXwxqyeXX6Pc3G4pFNr6iWb3dd8YfBBQMPr.bin b/programs/mango-v4/resources/test/Ds33rQ1d4AXwxqyeXX6Pc3G4pFNr6iWb3dd8YfBBQMPr.bin new file mode 100644 index 000000000..a0c6541cb Binary files /dev/null and b/programs/mango-v4/resources/test/Ds33rQ1d4AXwxqyeXX6Pc3G4pFNr6iWb3dd8YfBBQMPr.bin differ diff --git a/programs/mango-v4/src/accounts_ix/health_check.rs b/programs/mango-v4/src/accounts_ix/health_check.rs new file mode 100644 index 000000000..677dd0d3d --- /dev/null +++ b/programs/mango-v4/src/accounts_ix/health_check.rs @@ -0,0 +1,30 @@ +use crate::error::*; +use crate::state::*; +use anchor_lang::prelude::*; +use num_enum::{IntoPrimitive, TryFromPrimitive}; + +#[derive(Clone, Copy, TryFromPrimitive, IntoPrimitive, AnchorSerialize, AnchorDeserialize)] +#[repr(u8)] +pub enum HealthCheckKind { + Maint = 0b0000, + Init = 0b0010, + LiquidationEnd = 0b0100, + MaintRatio = 0b0001, + InitRatio = 0b0011, + LiquidationEndRatio = 0b0101, +} + +#[derive(Accounts)] +pub struct HealthCheck<'info> { + #[account( + constraint = group.load()?.is_ix_enabled(IxGate::HealthCheck) @ MangoError::IxIsDisabled, + )] + 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/mod.rs b/programs/mango-v4/src/accounts_ix/mod.rs index df8ea1f30..4256824a8 100644 --- a/programs/mango-v4/src/accounts_ix/mod.rs +++ b/programs/mango-v4/src/accounts_ix/mod.rs @@ -16,6 +16,7 @@ pub use group_close::*; pub use group_create::*; pub use group_edit::*; pub use group_withdraw_insurance_fund::*; +pub use health_check::*; pub use health_region::*; pub use ix_gate_set::*; pub use openbook_v2_cancel_order::*; @@ -45,6 +46,7 @@ pub use perp_place_order::*; pub use perp_settle_fees::*; pub use perp_settle_pnl::*; pub use perp_update_funding::*; +pub use sequence_check::*; pub use serum3_cancel_all_orders::*; pub use serum3_cancel_order::*; pub use serum3_close_open_orders::*; @@ -94,6 +96,7 @@ mod group_close; mod group_create; mod group_edit; mod group_withdraw_insurance_fund; +mod health_check; mod health_region; mod ix_gate_set; mod openbook_v2_cancel_order; @@ -123,6 +126,7 @@ mod perp_place_order; mod perp_settle_fees; mod perp_settle_pnl; mod perp_update_funding; +mod sequence_check; mod serum3_cancel_all_orders; mod serum3_cancel_order; mod serum3_close_open_orders; diff --git a/programs/mango-v4/src/accounts_ix/sequence_check.rs b/programs/mango-v4/src/accounts_ix/sequence_check.rs new file mode 100644 index 000000000..ca4a6f3b9 --- /dev/null +++ b/programs/mango-v4/src/accounts_ix/sequence_check.rs @@ -0,0 +1,20 @@ +use crate::error::*; +use crate::state::*; +use anchor_lang::prelude::*; + +#[derive(Accounts)] +pub struct SequenceCheck<'info> { + #[account( + constraint = group.load()?.is_ix_enabled(IxGate::SequenceCheck) @ MangoError::IxIsDisabled, + )] + pub group: AccountLoader<'info, Group>, + + #[account( + mut, + has_one = group, + has_one = owner, + constraint = account.load()?.is_operational() @ MangoError::AccountIsFrozen + )] + pub account: AccountLoader<'info, MangoAccountFixed>, + pub owner: Signer<'info>, +} diff --git a/programs/mango-v4/src/accounts_zerocopy.rs b/programs/mango-v4/src/accounts_zerocopy.rs index 6ec30ae2d..39d4b0e70 100644 --- a/programs/mango-v4/src/accounts_zerocopy.rs +++ b/programs/mango-v4/src/accounts_zerocopy.rs @@ -13,6 +13,10 @@ pub trait AccountReader { fn data(&self) -> &[u8]; } +pub trait AccountDataWriter { + fn data_as_mut_slice(&mut self) -> &mut [u8]; +} + /// Like AccountReader, but can also get the account pubkey pub trait KeyedAccountReader: AccountReader { fn key(&self) -> &Pubkey; @@ -99,6 +103,12 @@ impl<'info, 'a> KeyedAccountReader for AccountInfoRefMut<'info, 'a> { } } +impl<'info, 'a> AccountDataWriter for AccountInfoRefMut<'info, 'a> { + fn data_as_mut_slice(&mut self) -> &mut [u8] { + &mut self.data + } +} + #[cfg(feature = "solana-sdk")] impl AccountReader for T { fn owner(&self) -> &Pubkey { @@ -110,6 +120,13 @@ impl AccountReader for T { } } +#[cfg(feature = "solana-sdk")] +impl AccountDataWriter for T { + fn data_as_mut_slice(&mut self) -> &mut [u8] { + self.data_as_mut_slice() + } +} + #[cfg(feature = "solana-sdk")] #[derive(Clone)] pub struct KeyedAccount { @@ -232,28 +249,29 @@ impl LoadZeroCopy for A { } } -impl<'info, 'a> LoadMutZeroCopy for AccountInfoRefMut<'info, 'a> { +impl LoadMutZeroCopy for A { fn load_mut(&mut self) -> Result<&mut T> { - if self.owner != &T::owner() { + if self.owner() != &T::owner() { return Err(ErrorCode::AccountOwnedByWrongProgram.into()); } - if self.data.len() < 8 { + let data = self.data_as_mut_slice(); + if data.len() < 8 { return Err(ErrorCode::AccountDiscriminatorNotFound.into()); } - let disc_bytes = array_ref![self.data, 0, 8]; + let disc_bytes = array_ref![data, 0, 8]; if disc_bytes != &T::discriminator() { return Err(ErrorCode::AccountDiscriminatorMismatch.into()); } Ok(bytemuck::from_bytes_mut( - &mut self.data[8..mem::size_of::() + 8], + &mut data[8..mem::size_of::() + 8], )) } fn load_mut_fully_unchecked(&mut self) -> Result<&mut T> { Ok(bytemuck::from_bytes_mut( - &mut self.data[8..mem::size_of::() + 8], + &mut self.data_as_mut_slice()[8..mem::size_of::() + 8], )) } } diff --git a/programs/mango-v4/src/error.rs b/programs/mango-v4/src/error.rs index 1859d26aa..bac49d63c 100644 --- a/programs/mango-v4/src/error.rs +++ b/programs/mango-v4/src/error.rs @@ -145,6 +145,12 @@ pub enum MangoError { MissingFeedForCLMMOracle, #[msg("the asset does not allow liquidation")] TokenAssetLiquidationDisabled, + #[msg("for borrows the bank must be in the health account list")] + BorrowsRequireHealthAccountBank, + #[msg("invalid sequence number")] + InvalidSequenceNumber, + #[msg("invalid health")] + InvalidHealth, } impl MangoError { diff --git a/programs/mango-v4/src/health/account_retriever.rs b/programs/mango-v4/src/health/account_retriever.rs index 27bc0f14d..c88764def 100644 --- a/programs/mango-v4/src/health/account_retriever.rs +++ b/programs/mango-v4/src/health/account_retriever.rs @@ -26,6 +26,9 @@ use crate::state::{Bank, MangoAccountRef, PerpMarket, PerpMarketIndex, TokenInde /// are passed because health needs to be computed for different baskets in /// one instruction (such as for liquidation instructions). pub trait AccountRetriever { + /// Returns the token indexes of the available banks. Unordered and may have duplicates. + fn available_banks(&self) -> Result>; + fn bank_and_oracle( &self, group: &Pubkey, @@ -45,11 +48,12 @@ pub trait AccountRetriever { /// Assumes the account infos needed for the health computation follow a strict order. /// -/// 1. n_banks Bank account, in the order of account.token_iter_active() +/// 1. n_banks Bank account, in the order of account.active_token_positions() although it's +/// allowed for some of the banks (and their oracles in 2.) to be skipped /// 2. n_banks oracle accounts, one for each bank in the same order -/// 3. PerpMarket accounts, in the order of account.perps.iter_active_accounts() +/// 3. PerpMarket accounts, in the order of account.perps.active_perp_positions() /// 4. PerpMarket oracle accounts, in the order of the perp market accounts -/// 5. serum3 OpenOrders accounts, in the order of account.serum3.iter_active() +/// 5. serum3 OpenOrders accounts, in the order of account.active_serum3_orders() /// 6. fallback oracle accounts, order and existence of accounts is not guaranteed pub struct FixedOrderAccountRetriever { pub ais: Vec, @@ -63,20 +67,67 @@ pub struct FixedOrderAccountRetriever { pub sol_oracle_index: Option, } +/// Creates a FixedOrderAccountRetriever where all banks are present +/// +/// Note that this does not eagerly validate that the right accounts were passed. That +/// validation happens only when banks, perps etc are requested. pub fn new_fixed_order_account_retriever<'a, 'info>( ais: &'a [AccountInfo<'info>], account: &MangoAccountRef, + now_slot: u64, ) -> Result>> { let active_token_len = account.active_token_positions().count(); + + // Load the banks early to verify them + for ai in &ais[0..active_token_len] { + ai.load::()?; + } + + new_fixed_order_account_retriever_inner(ais, account, now_slot, active_token_len) +} + +/// A FixedOrderAccountRetriever with n_banks <= active_token_positions().count(), +/// depending on which banks were passed. +/// +/// Note that this does not eagerly validate that the right accounts were passed. That +/// validation happens only when banks, perps etc are requested. +pub fn new_fixed_order_account_retriever_with_optional_banks<'a, 'info>( + ais: &'a [AccountInfo<'info>], + account: &MangoAccountRef, + now_slot: u64, +) -> Result>> { + // Scan for the number of banks provided + let mut n_banks = 0; + for ai in ais { + if let Some((_, bank_result)) = can_load_as::((0, ai)) { + bank_result?; + n_banks += 1; + } else { + break; + } + } + + let active_token_len = account.active_token_positions().count(); + require_gte!(active_token_len, n_banks); + + new_fixed_order_account_retriever_inner(ais, account, now_slot, n_banks) +} + +pub fn new_fixed_order_account_retriever_inner<'a, 'info>( + ais: &'a [AccountInfo<'info>], + account: &MangoAccountRef, + now_slot: u64, + n_banks: usize, +) -> Result>> { let active_serum3_len = account.active_serum3_orders().count(); let active_perp_len = account.active_perp_positions().count(); - let expected_ais = active_token_len * 2 // banks + oracles + let expected_ais = n_banks * 2 // banks + oracles + active_perp_len * 2 // PerpMarkets + Oracles + active_serum3_len; // open_orders require_msg_typed!(ais.len() >= expected_ais, MangoError::InvalidHealthAccountCount, "received {} accounts but expected {} ({} banks, {} bank oracles, {} perp markets, {} perp oracles, {} serum3 oos)", ais.len(), expected_ais, - active_token_len, active_token_len, active_perp_len, active_perp_len, active_serum3_len + n_banks, n_banks, active_perp_len, active_perp_len, active_serum3_len ); let usdc_oracle_index = ais[..] .iter() @@ -87,11 +138,11 @@ pub fn new_fixed_order_account_retriever<'a, 'info>( Ok(FixedOrderAccountRetriever { ais: AccountInfoRef::borrow_slice(ais)?, - n_banks: active_token_len, + n_banks, n_perps: active_perp_len, - begin_perp: active_token_len * 2, - begin_serum3: active_token_len * 2 + active_perp_len * 2, - staleness_slot: Some(Clock::get()?.slot), + begin_perp: n_banks * 2, + begin_serum3: n_banks * 2 + active_perp_len * 2, + staleness_slot: Some(now_slot), begin_fallback_oracles: expected_ais, usdc_oracle_index, sol_oracle_index, @@ -99,11 +150,28 @@ pub fn new_fixed_order_account_retriever<'a, 'info>( } impl FixedOrderAccountRetriever { - fn bank(&self, group: &Pubkey, account_index: usize, token_index: TokenIndex) -> Result<&Bank> { - let bank = self.ais[account_index].load::()?; - require_keys_eq!(bank.group, *group); - require_eq!(bank.token_index, token_index); - Ok(bank) + fn bank( + &self, + group: &Pubkey, + active_token_position_index: usize, + token_index: TokenIndex, + ) -> Result<(usize, &Bank)> { + // Maybe not all banks were passed: The desired bank must be at or + // to the left of account_index and left of n_banks. + let end_index = (active_token_position_index + 1).min(self.n_banks); + for i in (0..end_index).rev() { + let ai = &self.ais[i]; + let bank = ai.load_fully_unchecked::()?; + if bank.token_index == token_index { + require_keys_eq!(bank.group, *group); + return Ok((i, bank)); + } + } + Err(error_msg_typed!( + MangoError::InvalidHealthAccountCount, + "bank for token index {} not found", + token_index + )) } fn perp_market( @@ -146,25 +214,25 @@ impl FixedOrderAccountRetriever { } impl AccountRetriever for FixedOrderAccountRetriever { + fn available_banks(&self) -> Result> { + let mut result = Vec::with_capacity(self.n_banks); + for bank_ai in &self.ais[0..self.n_banks] { + let bank = bank_ai.load_fully_unchecked::()?; + result.push(bank.token_index); + } + Ok(result) + } + fn bank_and_oracle( &self, group: &Pubkey, active_token_position_index: usize, token_index: TokenIndex, ) -> Result<(&Bank, I80F48)> { - let bank_account_index = active_token_position_index; - let bank = self - .bank(group, bank_account_index, token_index) - .with_context(|| { - format!( - "loading bank with health account index {}, token index {}, passed account {}", - bank_account_index, - token_index, - self.ais[bank_account_index].key(), - ) - })?; + let (bank_account_index, bank) = + self.bank(group, active_token_position_index, token_index)?; - let oracle_index = self.n_banks + active_token_position_index; + let oracle_index = self.n_banks + bank_account_index; let oracle_acc_infos = &self.create_oracle_infos(oracle_index, &bank.fallback_oracle); let oracle_price_result = bank.oracle_price(oracle_acc_infos, self.staleness_slot); let oracle_price = oracle_price_result.with_context(|| { @@ -505,6 +573,10 @@ impl<'a, 'info> ScanningAccountRetriever<'a, 'info> { } impl<'a, 'info> AccountRetriever for ScanningAccountRetriever<'a, 'info> { + fn available_banks(&self) -> Result> { + Ok(self.banks_and_oracles.index_map.keys().copied().collect()) + } + fn bank_and_oracle( &self, _group: &Pubkey, @@ -530,6 +602,8 @@ impl<'a, 'info> AccountRetriever for ScanningAccountRetriever<'a, 'info> { #[cfg(test)] mod tests { + use crate::state::{MangoAccount, MangoAccountValue}; + use super::super::test::*; use super::*; use serum_dex::state::OpenOrders; @@ -650,4 +724,98 @@ mod tests { .perp_market_and_oracle_price(&group, 1, 5) .is_err()); } + + #[test] + fn test_fixed_account_retriever_with_skips() { + let group = Pubkey::new_unique(); + + let (mut bank1, mut oracle1) = mock_bank_and_oracle(group, 10, 1.0, 0.2, 0.1); + let (mut bank2, mut oracle2) = mock_bank_and_oracle(group, 20, 2.0, 0.2, 0.1); + let (mut bank3, mut oracle3) = mock_bank_and_oracle(group, 30, 3.0, 0.2, 0.1); + + let mut perp1 = mock_perp_market(group, oracle2.pubkey, 2.0, 9, (0.2, 0.1), (0.05, 0.02)); + let mut oracle2_clone = oracle2.clone(); + + let buffer = MangoAccount::default_for_tests().try_to_vec().unwrap(); + let mut account = MangoAccountValue::from_bytes(&buffer).unwrap(); + account.ensure_token_position(10).unwrap(); + account.ensure_token_position(20).unwrap(); + account.ensure_token_position(30).unwrap(); + account.ensure_perp_position(9, 10).unwrap(); + + // pass all + { + let ais = vec![ + bank1.as_account_info(), + bank2.as_account_info(), + bank3.as_account_info(), + oracle1.as_account_info(), + oracle2.as_account_info(), + oracle3.as_account_info(), + perp1.as_account_info(), + oracle2_clone.as_account_info(), + ]; + let retriever = + new_fixed_order_account_retriever_with_optional_banks(&ais, &account.borrow(), 0) + .unwrap(); + assert_eq!(retriever.available_banks(), Ok(vec![10, 20, 30])); + + let (i, bank) = retriever.bank(&group, 0, 10).unwrap(); + assert_eq!(i, 0); + assert_eq!(bank.token_index, 10); + + let (i, bank) = retriever.bank(&group, 1, 20).unwrap(); + assert_eq!(i, 1); + assert_eq!(bank.token_index, 20); + + let (i, bank) = retriever.bank(&group, 2, 30).unwrap(); + assert_eq!(i, 2); + assert_eq!(bank.token_index, 30); + + assert!(retriever.perp_market(&group, 6, 9).is_ok()); + } + + // skip bank2 + { + let ais = vec![ + bank1.as_account_info(), + bank3.as_account_info(), + oracle1.as_account_info(), + oracle3.as_account_info(), + perp1.as_account_info(), + oracle2_clone.as_account_info(), + ]; + let retriever = + new_fixed_order_account_retriever_with_optional_banks(&ais, &account.borrow(), 0) + .unwrap(); + assert_eq!(retriever.available_banks(), Ok(vec![10, 30])); + + let (i, bank) = retriever.bank(&group, 0, 10).unwrap(); + assert_eq!(i, 0); + assert_eq!(bank.token_index, 10); + + let (i, bank) = retriever.bank(&group, 2, 30).unwrap(); + assert_eq!(i, 1); + assert_eq!(bank.token_index, 30); + + assert!(retriever.bank(&group, 1, 20).is_err()); + + assert!(retriever.perp_market(&group, 4, 9).is_ok()); + } + + // skip all + { + let ais = vec![perp1.as_account_info(), oracle2_clone.as_account_info()]; + let retriever = + new_fixed_order_account_retriever_with_optional_banks(&ais, &account.borrow(), 0) + .unwrap(); + assert_eq!(retriever.available_banks(), Ok(vec![])); + + assert!(retriever.bank(&group, 0, 10).is_err()); + assert!(retriever.bank(&group, 1, 20).is_err()); + assert!(retriever.bank(&group, 2, 30).is_err()); + + assert!(retriever.perp_market(&group, 0, 9).is_ok()); + } + } } diff --git a/programs/mango-v4/src/health/cache.rs b/programs/mango-v4/src/health/cache.rs index 4c9fa7c3e..21f8cf289 100644 --- a/programs/mango-v4/src/health/cache.rs +++ b/programs/mango-v4/src/health/cache.rs @@ -96,7 +96,7 @@ pub fn compute_health_from_fixed_accounts( ais: &[AccountInfo], now_ts: u64, ) -> Result { - let retriever = new_fixed_order_account_retriever(ais, account)?; + let retriever = new_fixed_order_account_retriever(ais, account, Clock::get()?.slot)?; Ok(new_health_cache(account, &retriever, now_ts)?.health(health_type)) } @@ -820,6 +820,12 @@ impl HealthCache { }) } + pub fn has_token_info(&self, token_index: TokenIndex) -> bool { + self.token_infos + .iter() + .any(|t| t.token_index == token_index) + } + pub fn perp_info(&self, perp_market_index: PerpMarketIndex) -> Result<&PerpInfo> { Ok(&self.perp_infos[self.perp_info_index(perp_market_index)?]) } @@ -1234,11 +1240,11 @@ pub fn new_health_cache( } /// Generate a special HealthCache for an account and its health accounts -/// where nonnegative token positions for bad oracles are skipped. +/// where nonnegative token positions for bad oracles are skipped as well as missing banks. /// /// This health cache must be used carefully, since it doesn't provide the actual /// account health, just a value that is guaranteed to be less than it. -pub fn new_health_cache_skipping_bad_oracles( +pub fn new_health_cache_skipping_missing_banks_and_bad_oracles( account: &MangoAccountRef, retriever: &impl AccountRetriever, now_ts: u64, @@ -1246,22 +1252,49 @@ pub fn new_health_cache_skipping_bad_oracles( new_health_cache_impl(account, retriever, now_ts, true) } +// On `allow_skipping_banks`: +// If (a Bank is not provided or its oracle is stale or inconfident) and the health contribution would +// not be negative, skip it. This decreases health, but many operations are still allowed as long +// as the decreased amount stays positive. fn new_health_cache_impl( account: &MangoAccountRef, retriever: &impl AccountRetriever, now_ts: u64, - // If an oracle is stale or inconfident and the health contribution would - // not be negative, skip it. This decreases health, but maybe overall it's - // still positive? - skip_bad_oracles: bool, + allow_skipping_banks: bool, ) -> Result { // token contribution from token accounts let mut token_infos = Vec::with_capacity(account.active_token_positions().count()); + // As a CU optimization, don't call available_banks() unless necessary + let available_banks_opt = if allow_skipping_banks { + Some(retriever.available_banks()?) + } else { + None + }; + for (i, position) in account.active_token_positions().enumerate() { + // Allow skipping of missing banks only if the account has a nonnegative balance + if allow_skipping_banks { + let bank_is_available = available_banks_opt + .as_ref() + .unwrap() + .contains(&position.token_index); + if !bank_is_available { + require_msg_typed!( + position.indexed_position >= 0, + MangoError::InvalidBank, + "the bank for token index {} is a required health account when the account has a negative balance in it", + position.token_index + ); + continue; + } + } + let bank_oracle_result = retriever.bank_and_oracle(&account.fixed.group, i, position.token_index); - if skip_bad_oracles + + // Allow skipping of bad-oracle banks if the account has a nonnegative balance + if allow_skipping_banks && bank_oracle_result.is_oracle_error() && position.indexed_position >= 0 { @@ -1301,9 +1334,25 @@ fn new_health_cache_impl( let oo = retriever.serum_oo(i, &serum_account.open_orders)?; // find the TokenInfos for the market's base and quote tokens - let base_info_index = find_token_info_index(&token_infos, serum_account.base_token_index)?; - let quote_info_index = - find_token_info_index(&token_infos, serum_account.quote_token_index)?; + // and potentially skip the whole serum contribution if they are not available + let info_index_results = ( + find_token_info_index(&token_infos, serum_account.base_token_index), + find_token_info_index(&token_infos, serum_account.quote_token_index), + ); + let (base_info_index, quote_info_index) = match info_index_results { + (Ok(base), Ok(quote)) => (base, quote), + _ => { + require_msg_typed!( + allow_skipping_banks, + MangoError::InvalidBank, + "serum market {} misses health accounts for bank {} or {}", + serum_account.market_index, + serum_account.base_token_index, + serum_account.quote_token_index, + ); + continue; + } + }; // add the amounts that are freely settleable immediately to token balances let base_free = I80F48::from(oo.native_coin_free); @@ -1329,6 +1378,12 @@ fn new_health_cache_impl( i, perp_position.market_index, )?; + + // Ensure the settle token is available in the health cache + if allow_skipping_banks { + find_token_info_index(&token_infos, perp_market.settle_token_index)?; + } + perp_infos.push(PerpInfo::new( perp_position, perp_market, @@ -1879,4 +1934,170 @@ mod tests { test_health1_runner(testcase); } } + + #[test] + fn test_health_with_skips() { + let testcase = TestHealth1Case { + // 6, reserved oo funds + token1: 100, + token2: 10, + token3: -10, + oo_1_2: (5, 1), + oo_1_3: (0, 0), + ..Default::default() + }; + + let buffer = MangoAccount::default_for_tests().try_to_vec().unwrap(); + let mut account = MangoAccountValue::from_bytes(&buffer).unwrap(); + + let group = Pubkey::new_unique(); + account.fixed.group = group; + + let (mut bank1, mut oracle1) = mock_bank_and_oracle(group, 0, 1.0, 0.2, 0.1); + let (mut bank2, mut oracle2) = mock_bank_and_oracle(group, 4, 5.0, 0.5, 0.3); + let (mut bank3, mut oracle3) = mock_bank_and_oracle(group, 5, 10.0, 0.5, 0.3); + bank1 + .data() + .change_without_fee( + account.ensure_token_position(0).unwrap().0, + I80F48::from(testcase.token1), + DUMMY_NOW_TS, + ) + .unwrap(); + bank2 + .data() + .change_without_fee( + account.ensure_token_position(4).unwrap().0, + I80F48::from(testcase.token2), + DUMMY_NOW_TS, + ) + .unwrap(); + bank3 + .data() + .change_without_fee( + account.ensure_token_position(5).unwrap().0, + I80F48::from(testcase.token3), + DUMMY_NOW_TS, + ) + .unwrap(); + + let mut oo1 = TestAccount::::new_zeroed(); + let serum3account1 = account.create_serum3_orders(2).unwrap(); + serum3account1.open_orders = oo1.pubkey; + serum3account1.base_token_index = 4; + serum3account1.quote_token_index = 0; + oo1.data().native_pc_total = testcase.oo_1_2.0; + oo1.data().native_coin_total = testcase.oo_1_2.1; + + fn compute_health_with_retriever<'a, 'info>( + ais: &[AccountInfo], + account: &MangoAccountValue, + group: Pubkey, + kind: bool, + ) -> Result { + let hc = if kind { + let retriever = + ScanningAccountRetriever::new_with_staleness(&ais, &group, None).unwrap(); + new_health_cache_skipping_missing_banks_and_bad_oracles( + &account.borrow(), + &retriever, + DUMMY_NOW_TS, + )? + } else { + let retriever = new_fixed_order_account_retriever_with_optional_banks( + &ais, + &account.borrow(), + 0, + ) + .unwrap(); + new_health_cache_skipping_missing_banks_and_bad_oracles( + &account.borrow(), + &retriever, + DUMMY_NOW_TS, + )? + }; + Ok(hc.health(HealthType::Init)) + } + + for retriever_kind in [false, true] { + // baseline with everything + { + let ais = vec![ + bank1.as_account_info(), + bank2.as_account_info(), + bank3.as_account_info(), + oracle1.as_account_info(), + oracle2.as_account_info(), + oracle3.as_account_info(), + oo1.as_account_info(), + ]; + + let health = + compute_health_with_retriever(&ais, &account, group, retriever_kind).unwrap(); + assert!(health_eq( + health, + 0.8 * 100.0 + 0.5 * 5.0 * (10.0 + 2.0) - 1.5 * 10.0 * 10.0 + )); + } + + // missing bank1 + { + let ais = vec![ + bank2.as_account_info(), + bank3.as_account_info(), + oracle2.as_account_info(), + oracle3.as_account_info(), + oo1.as_account_info(), + ]; + + let health = + compute_health_with_retriever(&ais, &account, group, retriever_kind).unwrap(); + assert!(health_eq(health, 0.5 * 5.0 * 10.0 - 1.5 * 10.0 * 10.0)); + } + + // missing bank2 + { + let ais = vec![ + bank1.as_account_info(), + bank3.as_account_info(), + oracle1.as_account_info(), + oracle3.as_account_info(), + oo1.as_account_info(), + ]; + + let health = + compute_health_with_retriever(&ais, &account, group, retriever_kind).unwrap(); + assert!(health_eq(health, 0.8 * 100.0 - 1.5 * 10.0 * 10.0)); + } + + // missing bank1 and 2 + { + let ais = vec![ + bank3.as_account_info(), + oracle3.as_account_info(), + oo1.as_account_info(), + ]; + + let health = + compute_health_with_retriever(&ais, &account, group, retriever_kind).unwrap(); + assert!(health_eq(health, -1.5 * 10.0 * 10.0)); + } + + // missing bank3 + { + let ais = vec![ + bank1.as_account_info(), + bank2.as_account_info(), + oracle1.as_account_info(), + oracle2.as_account_info(), + oo1.as_account_info(), + ]; + + // bank3 has a negative balance and can't be skipped! + assert!( + compute_health_with_retriever(&ais, &account, group, retriever_kind).is_err() + ); + } + } + } } diff --git a/programs/mango-v4/src/instructions/flash_loan.rs b/programs/mango-v4/src/instructions/flash_loan.rs index 5b2ecfc18..09defac67 100644 --- a/programs/mango-v4/src/instructions/flash_loan.rs +++ b/programs/mango-v4/src/instructions/flash_loan.rs @@ -2,9 +2,10 @@ use crate::accounts_ix::*; use crate::accounts_zerocopy::*; use crate::error::*; use crate::group_seeds; -use crate::health::{new_fixed_order_account_retriever, new_health_cache, AccountRetriever}; +use crate::health::*; use crate::logs::{emit_stack, FlashLoanLogV3, FlashLoanTokenDetailV3, TokenBalanceLog}; use crate::state::*; +use crate::util::clock_now; use anchor_lang::prelude::*; use anchor_lang::solana_program::sysvar::instructions as tx_instructions; @@ -368,8 +369,9 @@ pub fn flash_loan_end<'key, 'accounts, 'remaining, 'info>( // all vaults must have had matching banks for (i, has_bank) in vaults_with_banks.iter().enumerate() { - require_msg!( + require_msg_typed!( has_bank, + MangoError::InvalidBank, "missing bank for vault index {}, address {}", i, vaults[i].key @@ -387,12 +389,26 @@ pub fn flash_loan_end<'key, 'accounts, 'remaining, 'info>( } // Check health before balance adjustments - let retriever = new_fixed_order_account_retriever(health_ais, &account.borrow())?; - let now_ts: u64 = Clock::get()?.unix_timestamp.try_into().unwrap(); - let health_cache = new_health_cache(&account.borrow(), &retriever, now_ts)?; + // The vault-to-bank matching above ensures that the banks for the affected tokens are available. + let (now_ts, now_slot) = clock_now(); + let retriever = new_fixed_order_account_retriever_with_optional_banks( + health_ais, + &account.borrow(), + now_slot, + )?; + let health_cache = new_health_cache_skipping_missing_banks_and_bad_oracles( + &account.borrow(), + &retriever, + now_ts, + )?; + let pre_init_health = account.check_health_pre(&health_cache)?; // Prices for logging and net borrow checks + // + // This also verifies that all affected banks/oracles are available in health_cache: + // That is essential to avoid issues around withdrawing tokens when init health is negative + // (similar issue to token_withdraw) let mut oracle_prices = vec![]; for change in &changes { let (_, oracle_price) = retriever.bank_and_oracle( @@ -400,6 +416,8 @@ pub fn flash_loan_end<'key, 'accounts, 'remaining, 'info>( change.bank_index, change.token_index, )?; + // Sanity check + health_cache.token_info_index(change.token_index)?; oracle_prices.push(oracle_price); } @@ -502,8 +520,16 @@ pub fn flash_loan_end<'key, 'accounts, 'remaining, 'info>( }); // Check health after account position changes - let retriever = new_fixed_order_account_retriever(health_ais, &account.borrow())?; - let health_cache = new_health_cache(&account.borrow(), &retriever, now_ts)?; + let retriever = new_fixed_order_account_retriever_with_optional_banks( + health_ais, + &account.borrow(), + now_slot, + )?; + let health_cache = new_health_cache_skipping_missing_banks_and_bad_oracles( + &account.borrow(), + &retriever, + now_ts, + )?; account.check_health_post(&health_cache, pre_init_health)?; // Deactivate inactive token accounts after health check diff --git a/programs/mango-v4/src/instructions/health_check.rs b/programs/mango-v4/src/instructions/health_check.rs new file mode 100644 index 000000000..cbab8169a --- /dev/null +++ b/programs/mango-v4/src/instructions/health_check.rs @@ -0,0 +1,48 @@ +use anchor_lang::prelude::*; +use fixed::types::I80F48; + +use crate::accounts_ix::*; +use crate::error::{Contextable, MangoError}; +use crate::health::{ + new_health_cache_skipping_missing_banks_and_bad_oracles, HealthType, ScanningAccountRetriever, +}; +use crate::state::*; +use crate::util::clock_now; + +pub fn health_check( + ctx: Context, + min_value: f64, + health_check_kind: HealthCheckKind, +) -> Result<()> { + let account = ctx.accounts.account.load_full_mut()?; + let (now_ts, now_slot) = clock_now(); + + let group_pk = &ctx.accounts.group.key(); + + let retriever = ScanningAccountRetriever::new(ctx.remaining_accounts, group_pk) + .context("create account retriever")?; + + let health_cache = new_health_cache_skipping_missing_banks_and_bad_oracles( + &account.borrow(), + &retriever, + now_ts, + ) + .context("health_check health cache")?; + + let min_value = I80F48::from_num(min_value); + let actual_value = match health_check_kind { + HealthCheckKind::Maint => health_cache.health(HealthType::Maint), + HealthCheckKind::Init => health_cache.health(HealthType::Init), + HealthCheckKind::LiquidationEnd => health_cache.health(HealthType::LiquidationEnd), + HealthCheckKind::MaintRatio => health_cache.health_ratio(HealthType::Maint), + HealthCheckKind::InitRatio => health_cache.health_ratio(HealthType::Init), + HealthCheckKind::LiquidationEndRatio => { + health_cache.health_ratio(HealthType::LiquidationEnd) + } + }; + + // msg!("{}", actual_value); + require_gte!(actual_value, min_value, MangoError::InvalidHealth); + + Ok(()) +} diff --git a/programs/mango-v4/src/instructions/ix_gate_set.rs b/programs/mango-v4/src/instructions/ix_gate_set.rs index 413b9ceff..8fdd0b853 100644 --- a/programs/mango-v4/src/instructions/ix_gate_set.rs +++ b/programs/mango-v4/src/instructions/ix_gate_set.rs @@ -96,6 +96,8 @@ pub fn ix_gate_set(ctx: Context, ix_gate: u128) -> Result<()> { ); log_if_changed(&group, ix_gate, IxGate::Serum3PlaceOrderV2); log_if_changed(&group, ix_gate, IxGate::TokenForceWithdraw); + log_if_changed(&group, ix_gate, IxGate::SequenceCheck); + log_if_changed(&group, ix_gate, IxGate::HealthCheck); group.ix_gate = ix_gate; diff --git a/programs/mango-v4/src/instructions/mod.rs b/programs/mango-v4/src/instructions/mod.rs index faa5d8e88..1f91a7b53 100644 --- a/programs/mango-v4/src/instructions/mod.rs +++ b/programs/mango-v4/src/instructions/mod.rs @@ -16,6 +16,7 @@ pub use group_close::*; pub use group_create::*; pub use group_edit::*; pub use group_withdraw_insurance_fund::*; +pub use health_check::*; pub use health_region::*; pub use ix_gate_set::*; pub use perp_cancel_all_orders::*; @@ -35,6 +36,7 @@ pub use perp_place_order::*; pub use perp_settle_fees::*; pub use perp_settle_pnl::*; pub use perp_update_funding::*; +pub use sequence_check::*; pub use serum3_cancel_all_orders::*; pub use serum3_cancel_order::*; pub use serum3_cancel_order_by_client_order_id::*; @@ -85,6 +87,7 @@ mod group_close; mod group_create; mod group_edit; mod group_withdraw_insurance_fund; +mod health_check; mod health_region; mod ix_gate_set; mod perp_cancel_all_orders; @@ -104,6 +107,7 @@ mod perp_place_order; mod perp_settle_fees; mod perp_settle_pnl; mod perp_update_funding; +mod sequence_check; mod serum3_cancel_all_orders; mod serum3_cancel_order; mod serum3_cancel_order_by_client_order_id; diff --git a/programs/mango-v4/src/instructions/perp_liq_force_cancel_orders.rs b/programs/mango-v4/src/instructions/perp_liq_force_cancel_orders.rs index 0e39a4b5d..37bfdc6d5 100644 --- a/programs/mango-v4/src/instructions/perp_liq_force_cancel_orders.rs +++ b/programs/mango-v4/src/instructions/perp_liq_force_cancel_orders.rs @@ -4,6 +4,7 @@ use crate::accounts_ix::*; use crate::error::*; use crate::health::*; use crate::state::*; +use crate::util::clock_now; pub fn perp_liq_force_cancel_orders( ctx: Context, @@ -11,10 +12,10 @@ pub fn perp_liq_force_cancel_orders( ) -> Result<()> { let mut account = ctx.accounts.account.load_full_mut()?; - let now_ts: u64 = Clock::get()?.unix_timestamp.try_into().unwrap(); + let (now_ts, now_slot) = clock_now(); let mut health_cache = { let retriever = - new_fixed_order_account_retriever(ctx.remaining_accounts, &account.borrow())?; + new_fixed_order_account_retriever(ctx.remaining_accounts, &account.borrow(), now_slot)?; new_health_cache(&account.borrow(), &retriever, now_ts).context("create health cache")? }; diff --git a/programs/mango-v4/src/instructions/perp_place_order.rs b/programs/mango-v4/src/instructions/perp_place_order.rs index adf17a354..36e3b9579 100644 --- a/programs/mango-v4/src/instructions/perp_place_order.rs +++ b/programs/mango-v4/src/instructions/perp_place_order.rs @@ -3,8 +3,9 @@ use anchor_lang::prelude::*; use crate::accounts_ix::*; use crate::accounts_zerocopy::*; use crate::error::*; -use crate::health::{new_fixed_order_account_retriever, new_health_cache}; +use crate::health::*; use crate::state::*; +use crate::util::clock_now; // TODO #[allow(clippy::too_many_arguments)] @@ -16,7 +17,7 @@ pub fn perp_place_order( require_gte!(order.max_base_lots, 0); require_gte!(order.max_quote_lots, 0); - let now_ts: u64 = Clock::get()?.unix_timestamp.try_into().unwrap(); + let (now_ts, now_slot) = clock_now(); let oracle_price; // Update funding if possible. @@ -66,10 +67,21 @@ pub fn perp_place_order( // Pre-health computation, _after_ perp position is created // let pre_health_opt = if !account.fixed.is_in_health_region() { - let retriever = - new_fixed_order_account_retriever(ctx.remaining_accounts, &account.borrow())?; - let health_cache = new_health_cache(&account.borrow(), &retriever, now_ts) - .context("pre-withdraw init health")?; + let retriever = new_fixed_order_account_retriever_with_optional_banks( + ctx.remaining_accounts, + &account.borrow(), + now_slot, + )?; + let health_cache = new_health_cache_skipping_missing_banks_and_bad_oracles( + &account.borrow(), + &retriever, + now_ts, + ) + .context("pre init health")?; + + // The settle token banks/oracles must be passed and be valid + health_cache.token_info_index(settle_token_index)?; + let pre_init_health = account.check_health_pre(&health_cache)?; Some((health_cache, pre_init_health)) } else { diff --git a/programs/mango-v4/src/instructions/perp_settle_fees.rs b/programs/mango-v4/src/instructions/perp_settle_fees.rs index d7c31cf82..627f484c5 100644 --- a/programs/mango-v4/src/instructions/perp_settle_fees.rs +++ b/programs/mango-v4/src/instructions/perp_settle_fees.rs @@ -9,6 +9,7 @@ use crate::state::*; use crate::accounts_ix::*; use crate::logs::{emit_perp_balances, emit_stack, PerpSettleFeesLog, TokenBalanceLog}; +use crate::util::clock_now; pub fn perp_settle_fees(ctx: Context, max_settle_amount: u64) -> Result<()> { // max_settle_amount must greater than zero @@ -123,8 +124,9 @@ pub fn perp_settle_fees(ctx: Context, max_settle_amount: u64) -> drop(perp_market); // Verify that the result of settling did not violate the health of the account that lost money - let retriever = new_fixed_order_account_retriever(ctx.remaining_accounts, &account.borrow())?; - let now_ts: u64 = Clock::get()?.unix_timestamp.try_into().unwrap(); + let (now_ts, now_slot) = clock_now(); + let retriever = + new_fixed_order_account_retriever(ctx.remaining_accounts, &account.borrow(), now_slot)?; let health = compute_health(&account.borrow(), HealthType::Init, &retriever, now_ts)?; require!(health >= 0, MangoError::HealthMustBePositive); diff --git a/programs/mango-v4/src/instructions/sequence_check.rs b/programs/mango-v4/src/instructions/sequence_check.rs new file mode 100644 index 000000000..1f59ca59a --- /dev/null +++ b/programs/mango-v4/src/instructions/sequence_check.rs @@ -0,0 +1,18 @@ +use anchor_lang::prelude::*; + +use crate::accounts_ix::*; +use crate::error::MangoError; +use crate::state::*; + +pub fn sequence_check(ctx: Context, expected_sequence_number: u8) -> Result<()> { + let mut account = ctx.accounts.account.load_full_mut()?; + + require_eq!( + expected_sequence_number, + account.fixed.sequence_number, + MangoError::InvalidSequenceNumber + ); + + account.fixed.sequence_number = account.fixed.sequence_number.wrapping_add(1); + Ok(()) +} diff --git a/programs/mango-v4/src/instructions/serum3_liq_force_cancel_orders.rs b/programs/mango-v4/src/instructions/serum3_liq_force_cancel_orders.rs index ed395d091..5a508b183 100644 --- a/programs/mango-v4/src/instructions/serum3_liq_force_cancel_orders.rs +++ b/programs/mango-v4/src/instructions/serum3_liq_force_cancel_orders.rs @@ -8,6 +8,7 @@ use crate::instructions::charge_loan_origination_fees; use crate::logs::{emit_stack, Serum3OpenOrdersBalanceLogV2}; use crate::serum3_cpi::{load_open_orders_ref, OpenOrdersAmounts, OpenOrdersSlim}; use crate::state::*; +use crate::util::clock_now; pub fn serum3_liq_force_cancel_orders( ctx: Context, @@ -50,14 +51,15 @@ pub fn serum3_liq_force_cancel_orders( ); } + let (now_ts, now_slot) = clock_now(); + // // Early return if if liquidation is not allowed or if market is not in force close // let mut health_cache = { let mut account = ctx.accounts.account.load_full_mut()?; let retriever = - new_fixed_order_account_retriever(ctx.remaining_accounts, &account.borrow())?; - let now_ts: u64 = Clock::get()?.unix_timestamp.try_into().unwrap(); + new_fixed_order_account_retriever(ctx.remaining_accounts, &account.borrow(), now_slot)?; let health_cache = new_health_cache(&account.borrow(), &retriever, now_ts) .context("create health cache")?; diff --git a/programs/mango-v4/src/instructions/serum3_place_order.rs b/programs/mango-v4/src/instructions/serum3_place_order.rs index 725c72dd6..80d43fb18 100644 --- a/programs/mango-v4/src/instructions/serum3_place_order.rs +++ b/programs/mango-v4/src/instructions/serum3_place_order.rs @@ -9,6 +9,7 @@ use crate::logs::{emit_stack, Serum3OpenOrdersBalanceLogV2, TokenBalanceLog}; use crate::serum3_cpi::{ load_market_state, load_open_orders_ref, OpenOrdersAmounts, OpenOrdersSlim, }; +use crate::util::clock_now; use anchor_lang::prelude::*; use fixed::types::I80F48; @@ -40,6 +41,7 @@ pub fn serum3_place_order( // Validation // let receiver_token_index; + let payer_token_index; { let account = ctx.accounts.account.load_full()?; // account constraint #1 @@ -60,7 +62,7 @@ pub fn serum3_place_order( // Validate bank and vault #3 let payer_bank = ctx.accounts.payer_bank.load()?; require_keys_eq!(payer_bank.vault, ctx.accounts.payer_vault.key()); - let payer_token_index = match side { + payer_token_index = match side { Serum3Side::Bid => serum_market.quote_token_index, Serum3Side::Ask => serum_market.base_token_index, }; @@ -76,10 +78,23 @@ pub fn serum3_place_order( // Pre-health computation // let mut account = ctx.accounts.account.load_full_mut()?; - let retriever = new_fixed_order_account_retriever(ctx.remaining_accounts, &account.borrow())?; - let now_ts: u64 = Clock::get()?.unix_timestamp.try_into().unwrap(); - let mut health_cache = new_health_cache(&account.borrow(), &retriever, now_ts) - .context("pre-withdraw init health")?; + let (now_ts, now_slot) = clock_now(); + let retriever = new_fixed_order_account_retriever_with_optional_banks( + ctx.remaining_accounts, + &account.borrow(), + now_slot, + )?; + let mut health_cache = new_health_cache_skipping_missing_banks_and_bad_oracles( + &account.borrow(), + &retriever, + now_ts, + ) + .context("pre init health")?; + + // The payer and receiver token banks/oracles must be passed and be valid + health_cache.token_info_index(payer_token_index)?; + health_cache.token_info_index(receiver_token_index)?; + let pre_health_opt = if !account.fixed.is_in_health_region() { let pre_init_health = account.check_health_pre(&health_cache)?; Some(pre_init_health) @@ -412,6 +427,20 @@ pub fn serum3_place_order( // Note that all orders on the book executing can still cause a net deposit. That's because // the total serum3 potential amount assumes all reserved amounts convert at the current // oracle price. + // + // This also requires that all serum3 oos that touch the receiver_token are avaliable in the + // health cache. We make this a general requirement to avoid surprises. + for serum3 in account.active_serum3_orders() { + if serum3.base_token_index == receiver_token_index + || serum3.quote_token_index == receiver_token_index + { + require_msg!( + health_cache.serum3_infos.iter().any(|s3| s3.market_index == serum3.market_index), + "health cache is missing serum3 info {} involving receiver token {}; passed banks and oracles?", + serum3.market_index, receiver_token_index + ); + } + } if receiver_bank_reduce_only { let balance = health_cache.token_info(receiver_token_index)?.balance_spot; let potential = diff --git a/programs/mango-v4/src/instructions/token_charge_collateral_fees.rs b/programs/mango-v4/src/instructions/token_charge_collateral_fees.rs index f58fbf1d3..944c2722d 100644 --- a/programs/mango-v4/src/instructions/token_charge_collateral_fees.rs +++ b/programs/mango-v4/src/instructions/token_charge_collateral_fees.rs @@ -1,6 +1,7 @@ use crate::accounts_zerocopy::*; use crate::health::*; use crate::state::*; +use crate::util::clock_now; use anchor_lang::prelude::*; use fixed::types::I80F48; @@ -10,7 +11,7 @@ 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(); + let (now_ts, now_slot) = clock_now(); if group.collateral_fee_interval == 0 { // By resetting, a new enabling of collateral fees will not immediately create a charge @@ -42,7 +43,7 @@ pub fn token_charge_collateral_fees(ctx: Context) -> let health_cache = { let retriever = - new_fixed_order_account_retriever(ctx.remaining_accounts, &account.borrow())?; + new_fixed_order_account_retriever(ctx.remaining_accounts, &account.borrow(), now_slot)?; new_health_cache(&account.borrow(), &retriever, now_ts)? }; diff --git a/programs/mango-v4/src/instructions/token_deposit.rs b/programs/mango-v4/src/instructions/token_deposit.rs index da0630a95..1155cd7d9 100644 --- a/programs/mango-v4/src/instructions/token_deposit.rs +++ b/programs/mango-v4/src/instructions/token_deposit.rs @@ -11,6 +11,7 @@ use crate::state::*; use crate::accounts_ix::*; use crate::logs::*; +use crate::util::clock_now; struct DepositCommon<'a, 'info> { pub group: &'a AccountLoader<'info, Group>, @@ -119,13 +120,21 @@ impl<'a, 'info> DepositCommon<'a, 'info> { // // Health computation // - let retriever = new_fixed_order_account_retriever(remaining_accounts, &account.borrow())?; - let now_ts: u64 = Clock::get()?.unix_timestamp.try_into().unwrap(); + let (now_ts, now_slot) = clock_now(); + let retriever = new_fixed_order_account_retriever_with_optional_banks( + remaining_accounts, + &account.borrow(), + now_slot, + )?; // We only compute health to check if the account leaves the being_liquidated state. - // So it's ok to possibly skip token positions for bad oracles and compute a health + // So it's ok to possibly skip nonnegative token positions and compute a health // value that is too low. - let cache = new_health_cache_skipping_bad_oracles(&account.borrow(), &retriever, now_ts)?; + let cache = new_health_cache_skipping_missing_banks_and_bad_oracles( + &account.borrow(), + &retriever, + now_ts, + )?; // Since depositing can only increase health, we can skip the usual pre-health computation. // Also, TokenDeposit is one of the rare instructions that is allowed even during being_liquidated. @@ -143,6 +152,13 @@ impl<'a, 'info> DepositCommon<'a, 'info> { // Group level deposit limit on account let group = self.group.load()?; if group.deposit_limit_quote > 0 { + // Requires that all banks were provided an all oracles are healthy, otherwise we + // can't know how much this account has deposited + require_eq!( + cache.token_infos.len(), + account.active_token_positions().count() + ); + let assets = cache .health_assets_and_liabs_stable_assets(HealthType::Init) .0 diff --git a/programs/mango-v4/src/instructions/token_withdraw.rs b/programs/mango-v4/src/instructions/token_withdraw.rs index bcbfbc172..cf0dfbc36 100644 --- a/programs/mango-v4/src/instructions/token_withdraw.rs +++ b/programs/mango-v4/src/instructions/token_withdraw.rs @@ -2,6 +2,7 @@ use crate::accounts_zerocopy::*; use crate::error::*; use crate::health::*; use crate::state::*; +use crate::util::clock_now; use anchor_lang::prelude::*; use anchor_spl::associated_token; use anchor_spl::token; @@ -19,7 +20,7 @@ pub fn token_withdraw(ctx: Context, amount: u64, allow_borrow: bo 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 (now_ts, now_slot) = clock_now(); // Create the account's position for that token index let mut account = ctx.accounts.account.load_full_mut()?; @@ -27,21 +28,19 @@ pub fn token_withdraw(ctx: Context, amount: u64, allow_borrow: bo // Health check _after_ the token position is guaranteed to exist let pre_health_opt = if !account.fixed.is_in_health_region() { - let retriever = - new_fixed_order_account_retriever(ctx.remaining_accounts, &account.borrow())?; - let hc_result = new_health_cache(&account.borrow(), &retriever, now_ts) - .context("pre-withdraw health cache"); - if hc_result.is_oracle_error() { - // We allow NOT checking the pre init health. That means later on the health - // check will be stricter (post_init > 0, without the post_init >= pre_init option) - // Then later we can compute the health while ignoring potential nonnegative - // health contributions from tokens with stale oracles. - None - } else { - let health_cache = hc_result?; - let pre_init_health = account.check_health_pre(&health_cache)?; - Some((health_cache, pre_init_health)) - } + let retriever = new_fixed_order_account_retriever_with_optional_banks( + ctx.remaining_accounts, + &account.borrow(), + now_slot, + )?; + let health_cache = new_health_cache_skipping_missing_banks_and_bad_oracles( + &account.borrow(), + &retriever, + now_ts, + ) + .context("pre-withdraw health cache")?; + let pre_init_health = account.check_health_pre(&health_cache)?; + Some((health_cache, pre_init_health)) } else { None }; @@ -156,26 +155,29 @@ pub fn token_withdraw(ctx: Context, amount: u64, allow_borrow: bo // // Health check // - if !account.fixed.is_in_health_region() { - if let Some((mut health_cache, pre_init_health)) = pre_health_opt { - // This is the normal case + if let Some((mut health_cache, pre_init_health_lower_bound)) = pre_health_opt { + if health_cache.has_token_info(token_index) { + // This is the normal case: the health cache knows about the token, we can + // compute the health for the new state by adjusting its balance health_cache.adjust_token_balance(&bank, native_position_after - native_position)?; - account.check_health_post(&health_cache, pre_init_health)?; + account.check_health_post(&health_cache, pre_init_health_lower_bound)?; } else { - // Some oracle was stale/not confident enough above. + // The health cache does not know about the token! It has a bad oracle or wasn't + // provided in the health accounts. Borrows are out of the question! + require!(!is_borrow, MangoError::BorrowsRequireHealthAccountBank); + + // Since the health cache isn't aware of the bank we changed, the health + // estimation is the same. + let post_init_health_lower_bound = pre_init_health_lower_bound; + + // If health without the token is positive, then full health is positive and + // withdrawing all of the token would still keep it positive. + // However, if health without it is negative then full health could be negative + // and could be made worse by withdrawals. // - // Try computing health while ignoring nonnegative contributions from bad oracles. - // If the health is good enough without those, we can pass. - // - // Note that this must include the normal pre and post health checks. - let retriever = - new_fixed_order_account_retriever(ctx.remaining_accounts, &account.borrow())?; - let health_cache = - new_health_cache_skipping_bad_oracles(&account.borrow(), &retriever, now_ts) - .context("special post-withdraw health-cache")?; - let post_init_health = health_cache.health(HealthType::Init); - account.check_health_pre_checks(&health_cache, post_init_health)?; - account.check_health_post_checks(I80F48::MAX, post_init_health)?; + // We don't know the true pre_init_health: So require that our lower bound on + // post health is strictly good enough. + account.check_health_post_checks_strict(post_init_health_lower_bound)?; } } diff --git a/programs/mango-v4/src/lib.rs b/programs/mango-v4/src/lib.rs index b1f4b9102..533434b14 100644 --- a/programs/mango-v4/src/lib.rs +++ b/programs/mango-v4/src/lib.rs @@ -458,6 +458,22 @@ pub mod mango_v4 { Ok(()) } + pub fn sequence_check(ctx: Context, expected_sequence_number: u8) -> Result<()> { + #[cfg(feature = "enable-gpl")] + instructions::sequence_check(ctx, expected_sequence_number)?; + Ok(()) + } + + pub fn health_check( + ctx: Context, + min_health_value: f64, + check_kind: HealthCheckKind, + ) -> Result<()> { + #[cfg(feature = "enable-gpl")] + instructions::health_check(ctx, min_health_value, check_kind)?; + Ok(()) + } + // todo: // ckamm: generally, using an I80F48 arg will make it harder to call // because generic anchor clients won't know how to deal with it diff --git a/programs/mango-v4/src/state/amm_cpi.rs b/programs/mango-v4/src/state/amm_cpi.rs new file mode 100644 index 000000000..307eb2869 --- /dev/null +++ b/programs/mango-v4/src/state/amm_cpi.rs @@ -0,0 +1,168 @@ +use anchor_lang::prelude::*; +use fixed::types::{I80F48, U64F64}; +use solana_program::pubkey::Pubkey; + +use crate::{accounts_zerocopy::KeyedAccountReader, error::MangoError}; + +use super::{ + get_pyth_state, pyth_mainnet_sol_oracle, pyth_mainnet_usdc_oracle, sol_mint_mainnet, + usdc_mint_mainnet, OracleAccountInfos, OracleState, QUOTE_DECIMALS, SOL_DECIMALS, +}; + +pub mod orca_mainnet_whirlpool { + use solana_program::declare_id; + declare_id!("whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc"); +} + +pub mod raydium_mainnet { + use solana_program::declare_id; + declare_id!("CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK"); +} + +pub const ORCA_WHIRLPOOL_LEN: usize = 653; +pub const ORCA_WHIRLPOOL_DISCRIMINATOR: [u8; 8] = [63, 149, 209, 12, 225, 128, 99, 9]; + +pub const RAYDIUM_POOL_LEN: usize = 1544; +pub const RAYDIUM_POOL_DISCRIMINATOR: [u8; 8] = [247, 237, 227, 245, 215, 195, 222, 70]; + +pub struct CLMMPoolState { + // Q64.64 + pub sqrt_price: u128, // 16 + pub token_mint_a: Pubkey, // 32 + pub token_mint_b: Pubkey, // 32 +} + +impl CLMMPoolState { + 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_clmm_price(&self) -> I80F48 { + if self.is_inverted() { + let sqrt_price = U64F64::from_bits(self.sqrt_price).to_num::(); + let inverted_price = sqrt_price * sqrt_price; + I80F48::from_num(1.0f64 / inverted_price) + } else { + let sqrt_price = U64F64::from_bits(self.sqrt_price); + I80F48::from_num(sqrt_price * sqrt_price) + } + } + + pub fn quote_state_unchecked( + &self, + acc_infos: &OracleAccountInfos, + ) -> Result { + if self.is_inverted() { + self.quote_state_inner(acc_infos, &self.token_mint_a) + } else { + self.quote_state_inner(acc_infos, &self.token_mint_b) + } + } + + fn quote_state_inner( + &self, + acc_infos: &OracleAccountInfos, + quote_mint: &Pubkey, + ) -> Result { + if quote_mint == &usdc_mint_mainnet::ID { + let usd_feed = acc_infos + .usdc_opt + .ok_or_else(|| error!(MangoError::MissingFeedForCLMMOracle))?; + let usd_state = get_pyth_state(usd_feed, QUOTE_DECIMALS as u8)?; + return Ok(usd_state); + } else if quote_mint == &sol_mint_mainnet::ID { + let sol_feed = acc_infos + .sol_opt + .ok_or_else(|| error!(MangoError::MissingFeedForCLMMOracle))?; + let sol_state = get_pyth_state(sol_feed, SOL_DECIMALS as u8)?; + return Ok(sol_state); + } else { + return Err(MangoError::MissingFeedForCLMMOracle.into()); + } + } + + 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 has_quote_token(&self) -> bool { + let has_usdc_token = self.token_mint_a == usdc_mint_mainnet::ID + || self.token_mint_b == usdc_mint_mainnet::ID; + let has_sol_token = + self.token_mint_a == sol_mint_mainnet::ID || self.token_mint_b == sol_mint_mainnet::ID; + + has_usdc_token || has_sol_token + } +} + +pub fn load_orca_pool_state(acc_info: &impl KeyedAccountReader) -> Result { + let data = &acc_info.data(); + require!( + data[0..8] == ORCA_WHIRLPOOL_DISCRIMINATOR[..], + MangoError::InvalidCLMMOracle + ); + require!( + data.len() == ORCA_WHIRLPOOL_LEN, + MangoError::InvalidCLMMOracle + ); + require!( + acc_info.owner() == &orca_mainnet_whirlpool::ID, + MangoError::InvalidCLMMOracle + ); + + let price_bytes: &[u8; 16] = &data[65..81].try_into().unwrap(); + let sqrt_price = u128::from_le_bytes(*price_bytes); + let a: &[u8; 32] = &(&data[101..133]).try_into().unwrap(); + let b: &[u8; 32] = &(&data[181..213]).try_into().unwrap(); + let mint_a = Pubkey::from(*a); + let mint_b = Pubkey::from(*b); + + Ok(CLMMPoolState { + sqrt_price, + token_mint_a: mint_a, + token_mint_b: mint_b, + }) +} + +pub fn load_raydium_pool_state(acc_info: &impl KeyedAccountReader) -> Result { + let data = &acc_info.data(); + require!( + data[0..8] == RAYDIUM_POOL_DISCRIMINATOR[..], + MangoError::InvalidCLMMOracle + ); + require!( + data.len() == RAYDIUM_POOL_LEN, + MangoError::InvalidCLMMOracle + ); + require!( + acc_info.owner() == &raydium_mainnet::ID, + MangoError::InvalidCLMMOracle + ); + + let price_bytes: &[u8; 16] = &data[253..269].try_into().unwrap(); + let sqrt_price = u128::from_le_bytes(*price_bytes); + let a: &[u8; 32] = &(&data[73..105]).try_into().unwrap(); + let b: &[u8; 32] = &(&data[105..137]).try_into().unwrap(); + let mint_a = Pubkey::from(*a); + let mint_b = Pubkey::from(*b); + + Ok(CLMMPoolState { + sqrt_price, + token_mint_a: mint_a, + token_mint_b: mint_b, + }) +} diff --git a/programs/mango-v4/src/state/group.rs b/programs/mango-v4/src/state/group.rs index 19fc8db03..61f61cde2 100644 --- a/programs/mango-v4/src/state/group.rs +++ b/programs/mango-v4/src/state/group.rs @@ -246,6 +246,8 @@ pub enum IxGate { TokenConditionalSwapCreateLinearAuction = 70, Serum3PlaceOrderV2 = 71, TokenForceWithdraw = 72, + SequenceCheck = 73, + HealthCheck = 74, // 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 99ea08781..c146b2323 100644 --- a/programs/mango-v4/src/state/mango_account.rs +++ b/programs/mango-v4/src/state/mango_account.rs @@ -123,8 +123,7 @@ pub struct MangoAccount { pub bump: u8, - #[derivative(Debug = "ignore")] - pub padding: [u8; 1], + pub sequence_number: u8, // (Display only) // Cumulative (deposits - withdraws) @@ -200,7 +199,7 @@ impl MangoAccount { in_health_region: 0, account_num: 0, bump: 0, - padding: Default::default(), + sequence_number: 0, net_deposits: 0, perp_spot_transfers: 0, health_region_begin_init_health: 0, @@ -325,7 +324,7 @@ pub struct MangoAccountFixed { being_liquidated: u8, in_health_region: u8, pub bump: u8, - pub padding: [u8; 1], + pub sequence_number: u8, pub net_deposits: i64, pub perp_spot_transfers: i64, pub health_region_begin_init_health: i64, @@ -1458,6 +1457,13 @@ impl< Ok(()) } + /// A stricter version of check_health_post_checks() that requires >=0 health, it not getting + /// worse is not sufficient + pub fn check_health_post_checks_strict(&mut self, post_init_health: I80F48) -> Result<()> { + require!(post_init_health >= 0, MangoError::HealthMustBePositive); + Ok(()) + } + pub fn check_liquidatable(&mut self, health_cache: &HealthCache) -> Result { // Once maint_health falls below 0, we want to start liquidating, // we want to allow liquidation to continue until init_health is positive, @@ -2897,7 +2903,7 @@ mod tests { being_liquidated: fixed.being_liquidated, in_health_region: fixed.in_health_region, bump: fixed.bump, - padding: Default::default(), + sequence_number: 0, net_deposits: fixed.net_deposits, perp_spot_transfers: fixed.perp_spot_transfers, health_region_begin_init_health: fixed.health_region_begin_init_health, diff --git a/programs/mango-v4/src/state/mod.rs b/programs/mango-v4/src/state/mod.rs index 92f182ffb..a0320dd5d 100644 --- a/programs/mango-v4/src/state/mod.rs +++ b/programs/mango-v4/src/state/mod.rs @@ -1,3 +1,4 @@ +pub use amm_cpi::*; pub use bank::*; pub use dynamic_account::*; pub use equity::*; @@ -7,13 +8,13 @@ pub use mango_account_components::*; pub use mint_info::*; pub use openbook_v2_market::*; pub use oracle::*; -pub use orca_cpi::*; pub use orderbook::*; pub use perp_market::*; pub use serum3_market::*; pub use stable_price::*; pub use token_conditional_swap::*; +mod amm_cpi; mod bank; mod dynamic_account; mod equity; @@ -23,7 +24,6 @@ mod mango_account_components; mod mint_info; mod openbook_v2_market; mod oracle; -mod orca_cpi; mod orderbook; mod perp_market; mod serum3_market; diff --git a/programs/mango-v4/src/state/oracle.rs b/programs/mango-v4/src/state/oracle.rs index fc4106941..64ce6327f 100644 --- a/programs/mango-v4/src/state/oracle.rs +++ b/programs/mango-v4/src/state/oracle.rs @@ -3,7 +3,7 @@ use std::mem::size_of; use anchor_lang::prelude::*; use anchor_lang::{AnchorDeserialize, Discriminator}; use derivative::Derivative; -use fixed::types::{I80F48, U64F64}; +use fixed::types::I80F48; use static_assertions::const_assert_eq; use switchboard_program::FastRoundResultAccountData; @@ -12,9 +12,9 @@ use switchboard_v2::AggregatorAccountData; use crate::accounts_zerocopy::*; use crate::error::*; -use crate::state::load_whirlpool_state; +use crate::state::load_orca_pool_state; -use super::orca_mainnet_whirlpool; +use super::{load_raydium_pool_state, orca_mainnet_whirlpool, raydium_mainnet}; const DECIMAL_CONSTANT_ZERO_INDEX: i8 = 12; const DECIMAL_CONSTANTS: [I80F48; 25] = [ @@ -117,6 +117,7 @@ pub enum OracleType { SwitchboardV1, SwitchboardV2, OrcaCLMM, + RaydiumCLMM, } pub struct OracleState { @@ -195,6 +196,8 @@ pub fn determine_oracle_type(acc_info: &impl KeyedAccountReader) -> Result Res return Ok(()); }; let oracle_type = determine_oracle_type(acc_info)?; - if oracle_type == OracleType::OrcaCLMM { - let whirlpool = load_whirlpool_state(acc_info)?; + let valid_oracle = match oracle_type { + OracleType::OrcaCLMM => { + let whirlpool = load_orca_pool_state(acc_info)?; + whirlpool.has_quote_token() + } + OracleType::RaydiumCLMM => { + let pool = load_raydium_pool_state(acc_info)?; + pool.has_quote_token() + } + _ => true, + }; - let has_usdc_token = whirlpool.token_mint_a == usdc_mint_mainnet::ID - || whirlpool.token_mint_b == usdc_mint_mainnet::ID; - let has_sol_token = whirlpool.token_mint_a == sol_mint_mainnet::ID - || whirlpool.token_mint_b == sol_mint_mainnet::ID; - require!( - has_usdc_token || has_sol_token, - MangoError::InvalidCLMMOracle - ); - } + require!(valid_oracle, MangoError::UnexpectedOracle); Ok(()) } @@ -253,7 +257,7 @@ fn pyth_get_price( } } -fn get_pyth_state( +pub fn get_pyth_state( acc_info: &(impl KeyedAccountReader + ?Sized), base_decimals: u8, ) -> Result { @@ -404,56 +408,32 @@ fn oracle_state_unchecked_inner( } } OracleType::OrcaCLMM => { - let whirlpool = load_whirlpool_state(oracle_info)?; - - let inverted = whirlpool.is_inverted(); - let quote_state = if inverted { - quote_state_unchecked(acc_infos, &whirlpool.token_mint_a)? - } else { - quote_state_unchecked(acc_infos, &whirlpool.token_mint_b)? - }; - - let clmm_price = if inverted { - let sqrt_price = U64F64::from_bits(whirlpool.sqrt_price).to_num::(); - let inverted_price = sqrt_price * sqrt_price; - I80F48::from_num(1.0f64 / inverted_price) - } else { - let sqrt_price = U64F64::from_bits(whirlpool.sqrt_price); - I80F48::from_num(sqrt_price * sqrt_price) - }; - - let price = clmm_price * quote_state.price; + let whirlpool = load_orca_pool_state(oracle_info)?; + let clmm_price = whirlpool.get_clmm_price(); + let quote_oracle_state = whirlpool.quote_state_unchecked(acc_infos)?; + let price = clmm_price * quote_oracle_state.price; OracleState { price, - last_update_slot: quote_state.last_update_slot, - deviation: quote_state.deviation, + last_update_slot: quote_oracle_state.last_update_slot, + deviation: quote_oracle_state.deviation, oracle_type: OracleType::OrcaCLMM, } } + OracleType::RaydiumCLMM => { + let whirlpool = load_raydium_pool_state(oracle_info)?; + let clmm_price = whirlpool.get_clmm_price(); + let quote_oracle_state = whirlpool.quote_state_unchecked(acc_infos)?; + let price = clmm_price * quote_oracle_state.price; + OracleState { + price, + last_update_slot: quote_oracle_state.last_update_slot, + deviation: quote_oracle_state.deviation, + oracle_type: OracleType::RaydiumCLMM, + } + } }) } -fn quote_state_unchecked( - acc_infos: &OracleAccountInfos, - quote_mint: &Pubkey, -) -> Result { - if quote_mint == &usdc_mint_mainnet::ID { - let usd_feed = acc_infos - .usdc_opt - .ok_or_else(|| error!(MangoError::MissingFeedForCLMMOracle))?; - let usd_state = get_pyth_state(usd_feed, QUOTE_DECIMALS as u8)?; - return Ok(usd_state); - } else if quote_mint == &sol_mint_mainnet::ID { - let sol_feed = acc_infos - .sol_opt - .ok_or_else(|| error!(MangoError::MissingFeedForCLMMOracle))?; - let sol_state = get_pyth_state(sol_feed, SOL_DECIMALS as u8)?; - return Ok(sol_state); - } else { - return Err(MangoError::MissingFeedForCLMMOracle.into()); - } -} - pub fn oracle_log_context( name: &str, state: &OracleState, @@ -545,7 +525,87 @@ mod tests { } #[test] - pub fn test_clmm_price() -> Result<()> { + pub fn test_clmm_prices() -> Result<()> { + // add ability to find fixtures + let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + d.push("resources/test"); + + let usdc_fixture = ( + "Gnt27xtC473ZT2Mw5u8wZ68Z3gULkSTb5DuxJy7eJotD", + OracleType::Pyth, + Pubkey::default(), + 6, + ); + + let clmm_fixtures = vec![ + ( + "83v8iPyZihDEjDdY8RdZddyZNyUtXngz69Lgo9Kt5d6d", + OracleType::OrcaCLMM, + orca_mainnet_whirlpool::ID, + 9, // SOL/USDC pool + ), + ( + "Ds33rQ1d4AXwxqyeXX6Pc3G4pFNr6iWb3dd8YfBBQMPr", + OracleType::RaydiumCLMM, + raydium_mainnet::ID, + 9, // SOL/USDC pool + ), + ]; + + for fixture in clmm_fixtures { + let clmm_file = format!("resources/test/{}.bin", fixture.0); + let mut clmm_data = read_file(find_file(&clmm_file).unwrap()); + let data = RefCell::new(&mut clmm_data[..]); + let ai = &AccountInfoRef { + key: &Pubkey::from_str(fixture.0).unwrap(), + owner: &fixture.2, + data: data.borrow(), + }; + + let pyth_file = format!("resources/test/{}.bin", usdc_fixture.0); + let mut pyth_data = read_file(find_file(&pyth_file).unwrap()); + let pyth_data_cell = RefCell::new(&mut pyth_data[..]); + let usdc_ai = &AccountInfoRef { + key: &Pubkey::from_str(usdc_fixture.0).unwrap(), + owner: &usdc_fixture.2, + data: pyth_data_cell.borrow(), + }; + let base_decimals = fixture.3; + let usdc_decimals = usdc_fixture.3; + + let usdc_ais = OracleAccountInfos { + oracle: usdc_ai, + fallback_opt: None, + usdc_opt: None, + sol_opt: None, + }; + let clmm_ais = OracleAccountInfos { + oracle: ai, + fallback_opt: None, + usdc_opt: Some(usdc_ai), + sol_opt: None, + }; + let usdc = oracle_state_unchecked(&usdc_ais, usdc_decimals).unwrap(); + let clmm = oracle_state_unchecked(&clmm_ais, base_decimals).unwrap(); + assert!(usdc.price == I80F48::from_num(1.00000758274099)); + + match fixture.1 { + OracleType::OrcaCLMM => { + // 63.006792786538313 * 1.00000758274099 (but in native/native) + assert!(clmm.price == I80F48::from_num(0.06300727055072872)) + } + OracleType::RaydiumCLMM => { + // 83.551469620431 * 1.00000758274099 (but in native/native) + assert!(clmm.price == I80F48::from_num(0.083552103169584)) + } + _ => unimplemented!(), + } + } + Ok(()) + } + + #[test] + pub fn test_clmm_price_missing_usdc() -> Result<()> { // add ability to find fixtures let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR")); d.push("resources/test"); @@ -558,67 +618,13 @@ mod tests { 9, // SOL/USDC pool ), ( - "Gnt27xtC473ZT2Mw5u8wZ68Z3gULkSTb5DuxJy7eJotD", - OracleType::Pyth, - Pubkey::default(), - 6, + "Ds33rQ1d4AXwxqyeXX6Pc3G4pFNr6iWb3dd8YfBBQMPr", + OracleType::RaydiumCLMM, + raydium_mainnet::ID, + 9, // SOL/USDC pool ), ]; - let clmm_file = format!("resources/test/{}.bin", fixtures[0].0); - let mut clmm_data = read_file(find_file(&clmm_file).unwrap()); - let data = RefCell::new(&mut clmm_data[..]); - let ai = &AccountInfoRef { - key: &Pubkey::from_str(fixtures[0].0).unwrap(), - owner: &fixtures[0].2, - data: data.borrow(), - }; - - let pyth_file = format!("resources/test/{}.bin", fixtures[1].0); - let mut pyth_data = read_file(find_file(&pyth_file).unwrap()); - let pyth_data_cell = RefCell::new(&mut pyth_data[..]); - let usdc_ai = &AccountInfoRef { - key: &Pubkey::from_str(fixtures[1].0).unwrap(), - owner: &fixtures[1].2, - data: pyth_data_cell.borrow(), - }; - let base_decimals = fixtures[0].3; - let usdc_decimals = fixtures[1].3; - - let usdc_ais = OracleAccountInfos { - oracle: usdc_ai, - fallback_opt: None, - usdc_opt: None, - sol_opt: None, - }; - let orca_ais = OracleAccountInfos { - oracle: ai, - fallback_opt: None, - usdc_opt: Some(usdc_ai), - sol_opt: None, - }; - let usdc = oracle_state_unchecked(&usdc_ais, usdc_decimals).unwrap(); - let orca = oracle_state_unchecked(&orca_ais, base_decimals).unwrap(); - assert!(usdc.price == I80F48::from_num(1.00000758274099)); - // 63.006792786538313 * 1.00000758274099 (but in native/native) - assert!(orca.price == I80F48::from_num(0.06300727055072872)); - - Ok(()) - } - - #[test] - pub fn test_clmm_price_missing_usdc() -> Result<()> { - // add ability to find fixtures - let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - d.push("resources/test"); - - let fixtures = vec![( - "83v8iPyZihDEjDdY8RdZddyZNyUtXngz69Lgo9Kt5d6d", - OracleType::OrcaCLMM, - orca_mainnet_whirlpool::ID, - 9, // SOL/USDC pool - )]; - for fixture in fixtures { let filename = format!("resources/test/{}.bin", fixture.0); let mut clmm_data = read_file(find_file(&filename).unwrap()); @@ -642,4 +648,47 @@ mod tests { Ok(()) } + + #[test] + pub fn test_valid_fallbacks() -> Result<()> { + // add ability to find fixtures + let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + d.push("resources/test"); + + let usdc_fixture = ( + "Gnt27xtC473ZT2Mw5u8wZ68Z3gULkSTb5DuxJy7eJotD", + OracleType::Pyth, + Pubkey::default(), + 6, + ); + + let clmm_fixtures = vec![ + ( + "83v8iPyZihDEjDdY8RdZddyZNyUtXngz69Lgo9Kt5d6d", + OracleType::OrcaCLMM, + orca_mainnet_whirlpool::ID, + 9, // SOL/USDC pool + ), + ( + "Ds33rQ1d4AXwxqyeXX6Pc3G4pFNr6iWb3dd8YfBBQMPr", + OracleType::RaydiumCLMM, + raydium_mainnet::ID, + 9, // SOL/USDC pool + ), + ]; + + for fixture in clmm_fixtures { + let clmm_file = format!("resources/test/{}.bin", fixture.0); + let mut clmm_data = read_file(find_file(&clmm_file).unwrap()); + let data = RefCell::new(&mut clmm_data[..]); + let ai = &AccountInfoRef { + key: &Pubkey::from_str(fixture.0).unwrap(), + owner: &fixture.2, + data: data.borrow(), + }; + + check_is_valid_fallback_oracle(ai)?; + } + Ok(()) + } } diff --git a/programs/mango-v4/src/state/orca_cpi.rs b/programs/mango-v4/src/state/orca_cpi.rs deleted file mode 100644 index f33f7ff0b..000000000 --- a/programs/mango-v4/src/state/orca_cpi.rs +++ /dev/null @@ -1,76 +0,0 @@ -use anchor_lang::prelude::*; -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"); -} - -pub const ORCA_WHIRLPOOL_LEN: usize = 653; -pub const ORCA_WHIRLPOOL_DISCRIMINATOR: [u8; 8] = [63, 149, 209, 12, 225, 128, 99, 9]; - -pub struct WhirlpoolState { - // Q64.64 - pub sqrt_price: u128, // 16 - pub token_mint_a: Pubkey, // 32 - 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!( - data[0..8] == ORCA_WHIRLPOOL_DISCRIMINATOR[..], - MangoError::InvalidCLMMOracle - ); - require!( - data.len() == ORCA_WHIRLPOOL_LEN, - MangoError::InvalidCLMMOracle - ); - require!( - acc_info.owner() == &orca_mainnet_whirlpool::ID, - MangoError::InvalidCLMMOracle - ); - - let price_bytes: &[u8; 16] = &data[65..81].try_into().unwrap(); - let sqrt_price = u128::from_le_bytes(*price_bytes); - let a: &[u8; 32] = &(&data[101..133]).try_into().unwrap(); - let b: &[u8; 32] = &(&data[181..213]).try_into().unwrap(); - let mint_a = Pubkey::from(*a); - let mint_b = Pubkey::from(*b); - - Ok(WhirlpoolState { - sqrt_price, - token_mint_a: mint_a, - token_mint_b: mint_b, - }) -} diff --git a/programs/mango-v4/src/util.rs b/programs/mango-v4/src/util.rs index 58c34dc72..e49fb18c2 100644 --- a/programs/mango-v4/src/util.rs +++ b/programs/mango-v4/src/util.rs @@ -31,6 +31,12 @@ pub fn format_zero_terminated_utf8_bytes( ) } +// Returns (now_ts, now_slot) +pub fn clock_now() -> (u64, u64) { + let clock = Clock::get().unwrap(); + (clock.unix_timestamp.try_into().unwrap(), clock.slot) +} + #[cfg(test)] mod tests { use super::*; diff --git a/programs/mango-v4/tests/cases/mod.rs b/programs/mango-v4/tests/cases/mod.rs index 3d2c3b175..bf15b1ac0 100644 --- a/programs/mango-v4/tests/cases/mod.rs +++ b/programs/mango-v4/tests/cases/mod.rs @@ -10,7 +10,8 @@ pub use program_test::*; pub use super::program_test; -pub use utils::assert_equal_fixed_f64 as assert_equal; +pub use crate::assert_eq_f64; +pub use crate::assert_eq_fixed_f64; mod test_alt; mod test_bankrupt_tokens; @@ -21,6 +22,7 @@ mod test_collateral_fees; mod test_delegate; mod test_fees_buyback_with_mngo; mod test_force_close; +mod test_health_check; mod test_health_compute; mod test_health_region; mod test_ix_gate_set; @@ -35,6 +37,7 @@ mod test_perp_settle; mod test_perp_settle_fees; mod test_position_lifetime; mod test_reduce_only; +mod test_replay; mod test_serum; mod test_stale_oracles; mod test_token_conditional_swap; diff --git a/programs/mango-v4/tests/cases/test_basic.rs b/programs/mango-v4/tests/cases/test_basic.rs index dbbbbfa76..c2ba99091 100644 --- a/programs/mango-v4/tests/cases/test_basic.rs +++ b/programs/mango-v4/tests/cases/test_basic.rs @@ -450,7 +450,7 @@ async fn test_bank_maint_weight_shift() -> Result<(), TransportError> { .await; let maint_health = account_maint_health(solana, account).await; - assert!(assert_equal_f64_f64(maint_health, 1000.0, 1e-2)); + assert_eq_f64!(maint_health, 1000.0, 1e-2); let start_time = solana.clock_timestamp().await; @@ -476,17 +476,17 @@ async fn test_bank_maint_weight_shift() -> Result<(), TransportError> { .unwrap(); let maint_health = account_maint_health(solana, account).await; - assert!(assert_equal_f64_f64(maint_health, 1000.0, 1e-2)); + assert_eq_f64!(maint_health, 1000.0, 1e-2); solana.set_clock_timestamp(start_time + 1500).await; let maint_health = account_maint_health(solana, account).await; - assert!(assert_equal_f64_f64(maint_health, 750.0, 1e-2)); + assert_eq_f64!(maint_health, 750.0, 1e-2); solana.set_clock_timestamp(start_time + 3000).await; let maint_health = account_maint_health(solana, account).await; - assert!(assert_equal_f64_f64(maint_health, 500.0, 1e-2)); + assert_eq_f64!(maint_health, 500.0, 1e-2); solana.set_clock_timestamp(start_time + 1600).await; @@ -507,11 +507,11 @@ async fn test_bank_maint_weight_shift() -> Result<(), TransportError> { .unwrap(); let maint_health = account_maint_health(solana, account).await; - assert!(assert_equal_f64_f64(maint_health, 700.0, 1e-2)); + assert_eq_f64!(maint_health, 700.0, 1e-2); let bank: Bank = solana.get_account(tokens[0].bank).await; - assert!(assert_equal_fixed_f64(bank.maint_asset_weight, 0.7, 1e-4)); - assert!(assert_equal_fixed_f64(bank.maint_liab_weight, 1.3, 1e-4)); + assert_eq_fixed_f64!(bank.maint_asset_weight, 0.7, 1e-4); + assert_eq_fixed_f64!(bank.maint_liab_weight, 1.3, 1e-4); assert_eq!(bank.maint_weight_shift_duration_inv, I80F48::ZERO); Ok(()) @@ -687,3 +687,365 @@ async fn test_bank_deposit_limit() -> Result<(), TransportError> { Ok(()) } + +#[tokio::test] +async fn test_withdraw_skip_bank() -> 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 payer_token_accounts = &context.users[1].token_accounts; + let mints = &context.mints[0..3]; + + let mango_setup::GroupWithTokens { group, tokens, .. } = mango_setup::GroupWithTokensConfig { + admin, + payer, + mints: mints.to_vec(), + zero_token_is_quote: true, + ..mango_setup::GroupWithTokensConfig::default() + } + .create(solana) + .await; + + // Funding to fill the vaults + 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..2], + 1000, + 0, + ) + .await; + + // + // TEST: when all balances are positive + // + + send_tx( + solana, + HealthAccountSkipping { + inner: TokenWithdrawInstruction { + amount: 1, + allow_borrow: false, + account, + owner, + token_account: payer_token_accounts[0], + bank_index: 0, + }, + skip_banks: vec![tokens[0].bank], + }, + ) + .await + .unwrap(); + + send_tx( + solana, + HealthAccountSkipping { + inner: TokenWithdrawInstruction { + amount: 1, + allow_borrow: false, + account, + owner, + token_account: payer_token_accounts[0], + bank_index: 0, + }, + skip_banks: vec![tokens[1].bank], + }, + ) + .await + .unwrap(); + + // ok even when total health = 0 + send_tx( + solana, + HealthAccountSkipping { + inner: TokenWithdrawInstruction { + amount: 1, + allow_borrow: false, + account, + owner, + token_account: payer_token_accounts[0], + bank_index: 0, + }, + skip_banks: vec![tokens[0].bank, tokens[1].bank], + }, + ) + .await + .unwrap(); + + send_tx_expect_error!( + solana, + HealthAccountSkipping { + inner: TokenWithdrawInstruction { + amount: 1001, + allow_borrow: true, + account, + owner, + token_account: payer_token_accounts[0], + bank_index: 0, + }, + skip_banks: vec![tokens[0].bank], + }, + MangoError::BorrowsRequireHealthAccountBank + ); + + send_tx_expect_error!( + solana, + HealthAccountSkipping { + inner: TokenWithdrawInstruction { + amount: 1001, + allow_borrow: true, + account, + owner, + token_account: payer_token_accounts[0], + bank_index: 0, + }, + skip_banks: vec![tokens[1].bank], + }, + MangoError::HealthMustBePositiveOrIncrease + ); + + // + // TEST: create a borrow + // + + send_tx_expect_error!( + solana, + HealthAccountSkipping { + inner: TokenWithdrawInstruction { + amount: 1, + allow_borrow: true, + account, + owner, + token_account: payer_token_accounts[2], + bank_index: 0, + }, + skip_banks: vec![tokens[0].bank, tokens[1].bank], + }, + MangoError::HealthMustBePositiveOrIncrease + ); + + send_tx_expect_error!( + solana, + HealthAccountSkipping { + inner: TokenWithdrawInstruction { + amount: 1, + allow_borrow: true, + account, + owner, + token_account: payer_token_accounts[2], + bank_index: 0, + }, + skip_banks: vec![tokens[2].bank], + }, + MangoError::BorrowsRequireHealthAccountBank + ); + + send_tx( + solana, + HealthAccountSkipping { + inner: TokenWithdrawInstruction { + amount: 1, + allow_borrow: true, + account, + owner, + token_account: payer_token_accounts[2], + bank_index: 0, + }, + skip_banks: vec![tokens[0].bank], + }, + ) + .await + .unwrap(); + + // + // TEST: withdraw positive balances when there's a borrow + // + + send_tx( + solana, + HealthAccountSkipping { + inner: TokenWithdrawInstruction { + amount: 1, + allow_borrow: false, + account, + owner, + token_account: payer_token_accounts[0], + bank_index: 0, + }, + skip_banks: vec![tokens[0].bank], + }, + ) + .await + .unwrap(); + + send_tx( + solana, + HealthAccountSkipping { + inner: TokenWithdrawInstruction { + amount: 1, + allow_borrow: false, + account, + owner, + token_account: payer_token_accounts[0], + bank_index: 0, + }, + skip_banks: vec![tokens[1].bank], + }, + ) + .await + .unwrap(); + + send_tx_expect_error!( + solana, + HealthAccountSkipping { + inner: TokenWithdrawInstruction { + amount: 1, + allow_borrow: false, + account, + owner, + token_account: payer_token_accounts[0], + bank_index: 0, + }, + skip_banks: vec![tokens[2].bank], + }, + MangoError::InvalidBank + ); + + send_tx_expect_error!( + solana, + HealthAccountSkipping { + inner: TokenWithdrawInstruction { + amount: 1, + allow_borrow: false, + account, + owner, + token_account: payer_token_accounts[0], + bank_index: 0, + }, + skip_banks: vec![tokens[0].bank, tokens[1].bank], + }, + MangoError::HealthMustBePositive + ); + + Ok(()) +} + +#[tokio::test] +async fn test_sequence_check() -> 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..1]; + + let mango_setup::GroupWithTokens { group, .. } = mango_setup::GroupWithTokensConfig { + admin, + payer, + mints: mints.to_vec(), + ..mango_setup::GroupWithTokensConfig::default() + } + .create(solana) + .await; + + let account = send_tx( + solana, + AccountCreateInstruction { + account_num: 0, + token_count: 6, + serum3_count: 3, + perp_count: 3, + perp_oo_count: 3, + token_conditional_swap_count: 3, + group, + owner, + payer, + }, + ) + .await + .unwrap() + .account; + + let mango_account = get_mango_account(solana, account).await; + assert_eq!(mango_account.fixed.sequence_number, 0); + + // + // TEST: Sequence check with right sequence number + // + + send_tx( + solana, + SequenceCheckInstruction { + account, + owner, + expected_sequence_number: 0, + }, + ) + .await + .unwrap(); + + let mango_account = get_mango_account(solana, account).await; + assert_eq!(mango_account.fixed.sequence_number, 1); + + send_tx( + solana, + SequenceCheckInstruction { + account, + owner, + expected_sequence_number: 1, + }, + ) + .await + .unwrap(); + + let mango_account = get_mango_account(solana, account).await; + assert_eq!(mango_account.fixed.sequence_number, 2); + + // + // TEST: Sequence check with wrong sequence number + // + + send_tx_expect_error!( + solana, + SequenceCheckInstruction { + account, + owner, + expected_sequence_number: 1 + }, + MangoError::InvalidSequenceNumber + ); + + send_tx_expect_error!( + solana, + SequenceCheckInstruction { + account, + owner, + expected_sequence_number: 4 + }, + MangoError::InvalidSequenceNumber + ); + + let mango_account = get_mango_account(solana, account).await; + assert_eq!(mango_account.fixed.sequence_number, 2); + + Ok(()) +} diff --git a/programs/mango-v4/tests/cases/test_borrow_limits.rs b/programs/mango-v4/tests/cases/test_borrow_limits.rs index b8aaedc09..d1a656919 100644 --- a/programs/mango-v4/tests/cases/test_borrow_limits.rs +++ b/programs/mango-v4/tests/cases/test_borrow_limits.rs @@ -221,7 +221,7 @@ async fn test_bank_net_borrows_based_borrow_limit() -> Result<(), TransportError // fails because borrow is greater than remaining margin in net borrow limit // (requires the test to be quick enough to avoid accidentally going to the next borrow limit window!) - let res = send_tx( + send_tx_expect_error!( solana, TokenWithdrawInstruction { amount: 4000, @@ -231,12 +231,7 @@ async fn test_bank_net_borrows_based_borrow_limit() -> Result<(), TransportError token_account: payer_mint_accounts[0], bank_index: 0, }, - ) - .await; - assert_mango_error( - &res, - MangoError::BankNetBorrowsLimitReached.into(), - "".into(), + MangoError::BankNetBorrowsLimitReached ); // succeeds because is not a borrow @@ -314,7 +309,7 @@ async fn test_bank_net_borrows_based_borrow_limit() -> Result<(), TransportError set_bank_stub_oracle_price(solana, group, &tokens[0], admin, 10.0).await; // cannot borrow anything: net borrowed 1002 * price 10.0 > limit 6000 - let res = send_tx( + send_tx_expect_error!( solana, TokenWithdrawInstruction { amount: 1, @@ -324,12 +319,7 @@ async fn test_bank_net_borrows_based_borrow_limit() -> Result<(), TransportError token_account: payer_mint_accounts[0], bank_index: 0, }, - ) - .await; - assert_mango_error( - &res, - MangoError::BankNetBorrowsLimitReached.into(), - "".into(), + MangoError::BankNetBorrowsLimitReached ); // can still withdraw @@ -350,7 +340,7 @@ async fn test_bank_net_borrows_based_borrow_limit() -> Result<(), TransportError set_bank_stub_oracle_price(solana, group, &tokens[0], admin, 5.0).await; // cannot borrow this much: (net borrowed 1000 + new borrow 201) * price 5.0 > limit 6000 - let res = send_tx( + send_tx_expect_error!( solana, TokenWithdrawInstruction { amount: 200, @@ -360,12 +350,7 @@ async fn test_bank_net_borrows_based_borrow_limit() -> Result<(), TransportError token_account: payer_mint_accounts[0], bank_index: 0, }, - ) - .await; - assert_mango_error( - &res, - MangoError::BankNetBorrowsLimitReached.into(), - "".into(), + MangoError::BankNetBorrowsLimitReached ); // can borrow smaller amounts: (net borrowed 1000 + new borrow 199) * price 5.0 < limit 6000 diff --git a/programs/mango-v4/tests/cases/test_collateral_fees.rs b/programs/mango-v4/tests/cases/test_collateral_fees.rs index 5d069f023..1ee9d8fa5 100644 --- a/programs/mango-v4/tests/cases/test_collateral_fees.rs +++ b/programs/mango-v4/tests/cases/test_collateral_fees.rs @@ -177,11 +177,11 @@ async fn test_collateral_fees() -> Result<(), TransportError> { .await .unwrap(); last_time = solana.clock_timestamp().await; - assert!(assert_equal_f64_f64( + assert_eq_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; // @@ -208,11 +208,11 @@ async fn test_collateral_fees() -> Result<(), TransportError> { .await .unwrap(); //last_time = solana.clock_timestamp().await; - assert!(assert_equal_f64_f64( + assert_eq_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_fees_buyback_with_mngo.rs b/programs/mango-v4/tests/cases/test_fees_buyback_with_mngo.rs index cf86954b9..202732bfd 100644 --- a/programs/mango-v4/tests/cases/test_fees_buyback_with_mngo.rs +++ b/programs/mango-v4/tests/cases/test_fees_buyback_with_mngo.rs @@ -185,16 +185,16 @@ async fn test_fees_buyback_with_mngo() -> Result<(), TransportError> { assert_eq!(before_fees_accrued - after_fees_accrued, 19); // token[1] swapped at discount for token[0] - assert!(assert_equal( + assert_eq_fixed_f64!( fees_token_position_after - fees_token_position_before, 19.0 / 2.0, 0.1 - )); - assert!(assert_equal( + ); + assert_eq_fixed_f64!( mngo_token_position_after - mngo_token_position_before, -19.0 / 3.0 / 1.2, 0.1 - )); + ); Ok(()) } diff --git a/programs/mango-v4/tests/cases/test_force_close.rs b/programs/mango-v4/tests/cases/test_force_close.rs index 9f7e4d1fe..e2098b028 100644 --- a/programs/mango-v4/tests/cases/test_force_close.rs +++ b/programs/mango-v4/tests/cases/test_force_close.rs @@ -357,18 +357,18 @@ async fn test_force_close_perp() -> Result<(), TransportError> { let mango_account_0 = solana.get_account::(account_0).await; assert_eq!(mango_account_0.perps[0].base_position_lots(), 1); - assert!(assert_equal( + assert_eq_fixed_f64!( mango_account_0.perps[0].quote_position_native(), -99.99, 0.001 - )); + ); let mango_account_1 = solana.get_account::(account_1).await; assert_eq!(mango_account_1.perps[0].base_position_lots(), -1); - assert!(assert_equal( + assert_eq_fixed_f64!( mango_account_1.perps[0].quote_position_native(), 99.98, 0.001 - )); + ); // Market needs to be in force close assert!(send_tx( @@ -423,18 +423,18 @@ async fn test_force_close_perp() -> Result<(), TransportError> { let mango_account_0 = solana.get_account::(account_0).await; assert_eq!(mango_account_0.perps[0].base_position_lots(), 0); - assert!(assert_equal( + assert_eq_fixed_f64!( mango_account_0.perps[0].quote_position_native(), 0.009, 0.001 - )); + ); let mango_account_1 = solana.get_account::(account_1).await; assert_eq!(mango_account_1.perps[0].base_position_lots(), 0); - assert!(assert_equal( + assert_eq_fixed_f64!( mango_account_1.perps[0].quote_position_native(), -0.0199, 0.001 - )); + ); Ok(()) } diff --git a/programs/mango-v4/tests/cases/test_health_check.rs b/programs/mango-v4/tests/cases/test_health_check.rs new file mode 100644 index 000000000..b4624218b --- /dev/null +++ b/programs/mango-v4/tests/cases/test_health_check.rs @@ -0,0 +1,178 @@ +use crate::cases::{ + create_funded_account, mango_setup, send_tx, tokio, HealthAccountSkipping, + HealthCheckInstruction, TestContext, TestKeypair, TokenWithdrawInstruction, +}; +use crate::send_tx_expect_error; +use mango_v4::accounts_ix::{HealthCheck, HealthCheckKind}; +use mango_v4::error::MangoError; +use solana_sdk::transport::TransportError; + +// TODO FAS + +#[tokio::test] +async fn test_health_check() -> Result<(), TransportError> { + let context = TestContext::new().await; + let solana = &context.solana.clone(); + + let admin = TestKeypair::new(); + let owner = context.users[0].key; + let payer = context.users[1].key; + let payer_token_accounts = &context.users[1].token_accounts; + let mints = &context.mints[0..3]; + + let mango_setup::GroupWithTokens { group, tokens, .. } = mango_setup::GroupWithTokensConfig { + admin, + payer, + mints: mints.to_vec(), + zero_token_is_quote: true, + ..mango_setup::GroupWithTokensConfig::default() + } + .create(solana) + .await; + + // Funding to fill the vaults + 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..2], + 1000, + 0, + ) + .await; + + send_tx( + solana, + TokenWithdrawInstruction { + amount: 775, + allow_borrow: true, + account, + owner, + token_account: payer_token_accounts[2], + bank_index: 0, + }, + ) + .await + .unwrap(); + + // + // TEST (Health is about 93% with all banks, 7% without banks 1) + // + + send_tx( + solana, + HealthCheckInstruction { + account, + owner, + min_health_value: 20.0, + check_kind: HealthCheckKind::MaintRatio, + }, + ) + .await + .unwrap(); + + send_tx( + solana, + HealthCheckInstruction { + account, + owner, + min_health_value: 500.0, + check_kind: HealthCheckKind::Init, + }, + ) + .await + .unwrap(); + + send_tx_expect_error!( + solana, + HealthCheckInstruction { + owner, + account, + min_health_value: 600.0, + check_kind: HealthCheckKind::Init, + }, + MangoError::InvalidHealth + ); + + send_tx( + solana, + HealthCheckInstruction { + account, + owner, + min_health_value: 800.0, + check_kind: HealthCheckKind::Maint, + }, + ) + .await + .unwrap(); + + send_tx_expect_error!( + solana, + HealthCheckInstruction { + owner, + account, + min_health_value: 100.0, + check_kind: HealthCheckKind::MaintRatio, + }, + MangoError::InvalidHealth + ); + + send_tx( + solana, + HealthAccountSkipping { + inner: HealthCheckInstruction { + owner, + account, + min_health_value: 5.0, + check_kind: HealthCheckKind::MaintRatio, + }, + skip_banks: vec![tokens[1].bank], + }, + ) + .await + .unwrap(); + + send_tx_expect_error!( + solana, + HealthAccountSkipping { + inner: HealthCheckInstruction { + owner, + account, + min_health_value: 10.0, + check_kind: HealthCheckKind::MaintRatio, + }, + skip_banks: vec![tokens[1].bank], + }, + MangoError::InvalidHealth + ); + + send_tx_expect_error!( + solana, + HealthAccountSkipping { + inner: HealthCheckInstruction { + owner, + account, + min_health_value: 10.0, + check_kind: HealthCheckKind::MaintRatio, + }, + skip_banks: vec![tokens[2].bank], + }, + MangoError::InvalidBank + ); + + Ok(()) +} diff --git a/programs/mango-v4/tests/cases/test_health_compute.rs b/programs/mango-v4/tests/cases/test_health_compute.rs index 091205042..f72eaba49 100644 --- a/programs/mango-v4/tests/cases/test_health_compute.rs +++ b/programs/mango-v4/tests/cases/test_health_compute.rs @@ -344,7 +344,7 @@ async fn test_health_compute_tokens_fallback_oracles() -> Result<(), TransportEr #[tokio::test] async fn test_health_compute_serum() -> Result<(), TransportError> { let mut test_builder = TestContextBuilder::new(); - test_builder.test().set_compute_max_units(135_000); + test_builder.test().set_compute_max_units(137_000); let context = test_builder.start_default().await; let solana = &context.solana.clone(); diff --git a/programs/mango-v4/tests/cases/test_liq_perps_bankruptcy.rs b/programs/mango-v4/tests/cases/test_liq_perps_bankruptcy.rs index e3bb11f08..d551cb4ed 100644 --- a/programs/mango-v4/tests/cases/test_liq_perps_bankruptcy.rs +++ b/programs/mango-v4/tests/cases/test_liq_perps_bankruptcy.rs @@ -337,11 +337,11 @@ async fn test_liq_perps_bankruptcy() -> Result<(), TransportError> { liqor_quote_before + 12 ); let acc_data = solana.get_account::(account).await; - assert!(assert_equal( + assert_eq_fixed_f64!( acc_data.perps[0].quote_position_native(), -50.0 + 11.0 + 27.0, 0.1 - )); + ); assert_eq!(acc_data.being_liquidated, 0); let (_liqor_data, liqor_perp) = liqor_info(perp_market, liqor).await; assert_eq!(liqor_perp.quote_position_native(), -11); diff --git a/programs/mango-v4/tests/cases/test_liq_perps_base_and_bankruptcy.rs b/programs/mango-v4/tests/cases/test_liq_perps_base_and_bankruptcy.rs index 2a0c4ee8e..524bf8d5b 100644 --- a/programs/mango-v4/tests/cases/test_liq_perps_base_and_bankruptcy.rs +++ b/programs/mango-v4/tests/cases/test_liq_perps_base_and_bankruptcy.rs @@ -217,35 +217,35 @@ async fn test_liq_perps_base_and_bankruptcy() -> Result<(), TransportError> { let liqee_amount = 10.0 * 100.0 * 0.6 * (1.0 - 0.05); let liqor_data = solana.get_account::(liqor).await; assert_eq!(liqor_data.perps[0].base_position_lots(), 10); - assert!(assert_equal( + assert_eq_fixed_f64!( liqor_data.perps[0].quote_position_native(), -liqor_amount, 0.1 - )); + ); let liqee_data = solana.get_account::(account_0).await; assert_eq!(liqee_data.perps[0].base_position_lots(), 10); - assert!(assert_equal( + assert_eq_fixed_f64!( liqee_data.perps[0].quote_position_native(), -20.0 * 100.0 + liqee_amount, 0.1 - )); - assert!(assert_equal( + ); + assert_eq_fixed_f64!( liqee_data.perps[0].realized_pnl_for_position_native, liqee_amount - 1000.0, 0.1 - )); + ); // stable price is 1.0, so 0.2 * 1000 assert_eq!(liqee_data.perps[0].recurring_settle_pnl_allowance, 201); - assert!(assert_equal( + assert_eq_fixed_f64!( perp_market_after.fees_accrued - perp_market_before.fees_accrued, liqor_amount - liqee_amount, 0.1, - )); - assert!(assert_equal( + ); + assert_eq_fixed_f64!( perp_market_after.accrued_liquidation_fees - perp_market_before.accrued_liquidation_fees, liqor_amount - liqee_amount, 0.1, - )); + ); // // TEST: Liquidate base position max @@ -268,18 +268,18 @@ async fn test_liq_perps_base_and_bankruptcy() -> Result<(), TransportError> { let liqee_amount_2 = 6.0 * 100.0 * 0.6 * (1.0 - 0.05); let liqor_data = solana.get_account::(liqor).await; assert_eq!(liqor_data.perps[0].base_position_lots(), 10 + 6); - assert!(assert_equal( + assert_eq_fixed_f64!( liqor_data.perps[0].quote_position_native(), -liqor_amount - liqor_amount_2, 0.1 - )); + ); let liqee_data = solana.get_account::(account_0).await; assert_eq!(liqee_data.perps[0].base_position_lots(), 4); - assert!(assert_equal( + assert_eq_fixed_f64!( liqee_data.perps[0].quote_position_native(), -20.0 * 100.0 + liqee_amount + liqee_amount_2, 0.1 - )); + ); // verify health is good again send_tx( @@ -339,28 +339,28 @@ async fn test_liq_perps_base_and_bankruptcy() -> Result<(), TransportError> { let liqee_amount_3 = 10.0 * 100.0 * 1.32 * (1.0 + 0.05); let liqor_data = solana.get_account::(liqor).await; assert_eq!(liqor_data.perps[0].base_position_lots(), 16 - 10); - assert!(assert_equal( + assert_eq_fixed_f64!( liqor_data.perps[0].quote_position_native(), -liqor_amount - liqor_amount_2 + liqor_amount_3, 0.1 - )); + ); let liqee_data = solana.get_account::(account_1).await; assert_eq!(liqee_data.perps[0].base_position_lots(), -10); - assert!(assert_equal( + assert_eq_fixed_f64!( liqee_data.perps[0].quote_position_native(), 20.0 * 100.0 - liqee_amount_3, 0.1 - )); - assert!(assert_equal( + ); + assert_eq_fixed_f64!( perp_market_after.fees_accrued - perp_market_before.fees_accrued, liqee_amount_3 - liqor_amount_3, 0.1, - )); - assert!(assert_equal( + ); + assert_eq_fixed_f64!( perp_market_after.accrued_liquidation_fees - perp_market_before.accrued_liquidation_fees, liqee_amount_3 - liqor_amount_3, 0.1, - )); + ); // // TEST: Liquidate base position max @@ -383,18 +383,18 @@ async fn test_liq_perps_base_and_bankruptcy() -> Result<(), TransportError> { let liqee_amount_4 = 7.0 * 100.0 * 1.32 * (1.0 + 0.05); let liqor_data = solana.get_account::(liqor).await; assert_eq!(liqor_data.perps[0].base_position_lots(), 6 - 7); - assert!(assert_equal( + assert_eq_fixed_f64!( liqor_data.perps[0].quote_position_native(), -liqor_amount - liqor_amount_2 + liqor_amount_3 + liqor_amount_4, 0.1 - )); + ); let liqee_data = solana.get_account::(account_1).await; assert_eq!(liqee_data.perps[0].base_position_lots(), -3); - assert!(assert_equal( + assert_eq_fixed_f64!( liqee_data.perps[0].quote_position_native(), 20.0 * 100.0 - liqee_amount_3 - liqee_amount_4, 0.1 - )); + ); // verify health is good again send_tx( @@ -438,18 +438,18 @@ async fn test_liq_perps_base_and_bankruptcy() -> Result<(), TransportError> { let liqee_amount_5 = 3.0 * 100.0 * 2.0 * (1.0 + 0.05); let liqor_data = solana.get_account::(liqor).await; assert_eq!(liqor_data.perps[0].base_position_lots(), -1 - 3); - assert!(assert_equal( + assert_eq_fixed_f64!( liqor_data.perps[0].quote_position_native(), -liqor_amount - liqor_amount_2 + liqor_amount_3 + liqor_amount_4 + liqor_amount_5, 0.1 - )); + ); let liqee_data = solana.get_account::(account_1).await; assert_eq!(liqee_data.perps[0].base_position_lots(), 0); - assert!(assert_equal( + assert_eq_fixed_f64!( liqee_data.perps[0].quote_position_native(), 20.0 * 100.0 - liqee_amount_3 - liqee_amount_4 - liqee_amount_5, 0.1 - )); + ); // // TEST: Can settle-pnl even though health is negative @@ -481,11 +481,11 @@ async fn test_liq_perps_base_and_bankruptcy() -> Result<(), TransportError> { assert!(remaining_pnl < 0.0); let liqee_data = solana.get_account::(account_1).await; assert_eq!(liqee_data.perps[0].base_position_lots(), 0); - assert!(assert_equal( + assert_eq_fixed_f64!( liqee_data.perps[0].quote_position_native(), remaining_pnl, 0.1 - )); + ); assert_eq!( account_position(solana, account_1, quote_token.bank).await, liqee_quote_deposits_before as i64 @@ -566,27 +566,27 @@ async fn test_liq_perps_base_and_bankruptcy() -> Result<(), TransportError> { // insurance fund was depleted and the liqor received it assert_eq!(solana.token_account_balance(insurance_vault).await, 0); - assert!(assert_equal( + assert_eq_fixed_f64!( liqor_data.tokens[0].native("e_bank), liqor_before.tokens[0].native("e_bank).to_num::() + insurance_vault_funding as f64, 0.1 - )); - assert!(assert_equal( + ); + assert_eq_fixed_f64!( liqor_data.tokens[1].native(&settle_bank), liqor_before.tokens[1].native(&settle_bank).to_num::() - liqee_settle_limit_before as f64, 0.1 - )); + ); // liqor took over the max possible negative pnl - assert!(assert_equal( + assert_eq_fixed_f64!( liqor_data.perps[0].quote_position_native(), liqor_before.perps[0] .quote_position_native() .to_num::() - liq_perp_quote_amount, 0.1 - )); + ); // liqee exited liquidation assert!(account_init_health(solana, account_1).await >= 0.0); @@ -602,21 +602,21 @@ async fn test_liq_perps_base_and_bankruptcy() -> Result<(), TransportError> { .unwrap(); let socialized_amount = (pnl_after - pnl_before).to_num::() - liq_perp_quote_amount; let open_interest = 2 * liqor_data.perps[0].base_position_lots.abs(); - assert!(assert_equal( + assert_eq_fixed_f64!( perp_market.long_funding, socialized_amount / open_interest as f64, 0.1 - )); - assert!(assert_equal( + ); + assert_eq_fixed_f64!( perp_market.short_funding, -socialized_amount / open_interest as f64, 0.1 - )); - assert!(assert_equal( + ); + assert_eq_fixed_f64!( account0_before.perps[0].unsettled_funding(&perp_market), socialized_amount / 2.0, 0.1 - )); + ); Ok(()) } diff --git a/programs/mango-v4/tests/cases/test_liq_perps_positive_pnl.rs b/programs/mango-v4/tests/cases/test_liq_perps_positive_pnl.rs index 3fef042e9..cf734f1f2 100644 --- a/programs/mango-v4/tests/cases/test_liq_perps_positive_pnl.rs +++ b/programs/mango-v4/tests/cases/test_liq_perps_positive_pnl.rs @@ -297,22 +297,22 @@ async fn test_liq_perps_positive_pnl() -> Result<(), TransportError> { let liqor_data = solana.get_account::(liqor).await; assert_eq!(liqor_data.perps[0].base_position_lots(), 1); - assert!(assert_equal( + assert_eq_fixed_f64!( liqor_data.perps[0].quote_position_native(), 100.0 + 600.0 - 2100.0 * 0.95, 0.1 - )); + ); assert_eq!( account_position(solana, liqor, settle_token.bank).await, 10000 - 95 - 570 ); let liqee_data = solana.get_account::(account_0).await; assert_eq!(liqee_data.perps[0].base_position_lots(), 9); - assert!(assert_equal( + assert_eq_fixed_f64!( liqee_data.perps[0].quote_position_native(), -10000.0 - 100.0 - 600.0 + 2100.0 * 0.95, 0.1 - )); + ); assert_eq!( account_position(solana, account_0, settle_token.bank).await, 95 + 570 diff --git a/programs/mango-v4/tests/cases/test_liq_tokens.rs b/programs/mango-v4/tests/cases/test_liq_tokens.rs index db049681d..c20c5eeee 100644 --- a/programs/mango-v4/tests/cases/test_liq_tokens.rs +++ b/programs/mango-v4/tests/cases/test_liq_tokens.rs @@ -421,24 +421,16 @@ async fn test_liq_tokens_with_token() -> Result<(), TransportError> { // The liqee pays for the 20 collateral at a price of 1.02*1.02. The liqor gets 1.01*1.01, // so the platform fee is let platform_fee = 20.0 * (1.0 - 1.01 * 1.01 / (1.02 * 1.02)); - assert!(assert_equal_f64_f64( + assert_eq_f64!( account_position_f64(solana, vault_account, collateral_token2.bank).await, 100000.0 + 20.0 - platform_fee, 0.001, - )); + ); // Verify platform liq fee tracking let colbank = solana.get_account::(collateral_token2.bank).await; - assert!(assert_equal_fixed_f64( - colbank.collected_fees_native, - platform_fee, - 0.001 - )); - assert!(assert_equal_fixed_f64( - colbank.collected_liquidation_fees, - platform_fee, - 0.001 - )); + assert_eq_fixed_f64!(colbank.collected_fees_native, platform_fee, 0.001); + assert_eq_fixed_f64!(colbank.collected_liquidation_fees, platform_fee, 0.001); let liqee = get_mango_account(solana, account).await; assert!(liqee.being_liquidated()); diff --git a/programs/mango-v4/tests/cases/test_margin_trade.rs b/programs/mango-v4/tests/cases/test_margin_trade.rs index 132cde90e..92839d3cf 100644 --- a/programs/mango-v4/tests/cases/test_margin_trade.rs +++ b/programs/mango-v4/tests/cases/test_margin_trade.rs @@ -16,9 +16,6 @@ async fn test_margin_trade() -> Result<(), BanksClientError> { let payer_mint0_account = context.users[1].token_accounts[0]; let loan_origination_fee = 0.0005; - // higher resolution that the loan_origination_fee for one token - let balance_f64eq = |a: f64, b: f64| utils::assert_equal_f64_f64(a, b, 0.0001); - // // SETUP: Create a group, account, register a token (mint0) // @@ -173,10 +170,11 @@ async fn test_margin_trade() -> Result<(), BanksClientError> { margin_account_initial + withdraw_amount - deposit_amount ); // no fee because user had positive balance - assert!(balance_f64eq( + assert_eq_f64!( account_position_f64(solana, account, bank).await, - (deposit_amount_initial - withdraw_amount + deposit_amount) as f64 - )); + (deposit_amount_initial - withdraw_amount + deposit_amount) as f64, + 0.0001 + ); // // TEST: Bringing the balance to 0 deactivates the token @@ -210,10 +208,11 @@ async fn test_margin_trade() -> Result<(), BanksClientError> { solana.token_account_balance(margin_account).await, margin_account_initial - deposit_amount ); - assert!(balance_f64eq( + assert_eq_f64!( account_position_f64(solana, account, bank).await, - deposit_amount as f64 - )); + deposit_amount as f64, + 0.0001 + ); // // TEST: Try loan fees by withdrawing more than the user balance @@ -232,11 +231,12 @@ async fn test_margin_trade() -> Result<(), BanksClientError> { solana.token_account_balance(margin_account).await, margin_account_initial + withdraw_amount - deposit_amount ); - assert!(balance_f64eq( + assert_eq_f64!( account_position_f64(solana, account, bank).await, (deposit_amount_initial + deposit_amount - withdraw_amount) as f64 - - (withdraw_amount - deposit_amount_initial) as f64 * loan_origination_fee - )); + - (withdraw_amount - deposit_amount_initial) as f64 * loan_origination_fee, + 0.0001 + ); Ok(()) } @@ -255,9 +255,6 @@ async fn test_flash_loan_swap_fee() -> Result<(), BanksClientError> { let owner_accounts = context.users[0].token_accounts.clone(); let payer_accounts = context.users[1].token_accounts.clone(); - // higher resolution that the loan_origination_fee for one token - let balance_f64eq = |a: f64, b: f64| utils::assert_equal_f64_f64(a, b, 0.0001); - // // SETUP: Create a group, account, register a token (mint0) // @@ -414,13 +411,14 @@ async fn test_flash_loan_swap_fee() -> Result<(), BanksClientError> { ); let mango_withdraw_amount = account_position_f64(solana, account, tokens[0].bank).await; - assert!(balance_f64eq( + assert_eq_f64!( mango_withdraw_amount, - initial_deposit as f64 - withdraw_amount as f64 * (1.0 + swap_fee_rate) - )); + initial_deposit as f64 - withdraw_amount as f64 * (1.0 + swap_fee_rate), + 0.0001 + ); let mango_deposit_amount = account_position_f64(solana, account, tokens[1].bank).await; - assert!(balance_f64eq(mango_deposit_amount, deposit_amount as f64)); + assert_eq_f64!(mango_deposit_amount, deposit_amount as f64, 0.0001); Ok(()) } @@ -732,3 +730,112 @@ async fn test_margin_trade_deposit_limit() -> Result<(), BanksClientError> { Ok(()) } + +#[tokio::test] +async fn test_margin_trade_skip_bank() -> Result<(), BanksClientError> { + let mut test_builder = TestContextBuilder::new(); + test_builder.test().set_compute_max_units(100_000); + 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..2]; + let payer_mint0_account = context.users[1].token_accounts[0]; + + // + // SETUP: Create a group, account, register a token (mint0) + // + + let GroupWithTokens { group, tokens, .. } = GroupWithTokensConfig { + admin, + payer, + mints: mints.to_vec(), + ..GroupWithTokensConfig::default() + } + .create(solana) + .await; + let bank = tokens[0].bank; + + // + // create the test user account + // + + let deposit_amount_initial = 100; + let account = create_funded_account( + &solana, + group, + owner, + 0, + &context.users[1], + &mints, + deposit_amount_initial, + 0, + ) + .await; + + // + // TEST: Margin trade + // + let margin_account = payer_mint0_account; + let target_token_account = context.users[0].token_accounts[0]; + let make_flash_loan_tx = |solana, deposit_amount, skip_banks| async move { + let mut tx = ClientTransaction::new(solana); + let loans = vec![FlashLoanPart { + bank, + token_account: target_token_account, + withdraw_amount: 0, + }]; + tx.add_instruction(FlashLoanBeginInstruction { + account, + owner, + loans: loans.clone(), + }) + .await; + tx.add_instruction_direct( + spl_token::instruction::transfer( + &spl_token::ID, + &margin_account, + &target_token_account, + &payer.pubkey(), + &[&payer.pubkey()], + deposit_amount, + ) + .unwrap(), + ); + tx.add_signer(payer); + tx.add_instruction(HealthAccountSkipping { + inner: FlashLoanEndInstruction { + account, + owner, + loans, + // the test only accesses a single token: not a swap + flash_loan_type: mango_v4::accounts_ix::FlashLoanType::Unknown, + }, + skip_banks, + }) + .await; + tx + }; + + make_flash_loan_tx(solana, 1, vec![]) + .await + .send() + .await + .unwrap(); + + make_flash_loan_tx(solana, 1, vec![tokens[1].bank]) + .await + .send() + .await + .unwrap(); + + make_flash_loan_tx(solana, 1, vec![tokens[0].bank]) + .await + .send_expect_error(MangoError::InvalidBank) + .await + .unwrap(); + + Ok(()) +} diff --git a/programs/mango-v4/tests/cases/test_perp.rs b/programs/mango-v4/tests/cases/test_perp.rs index 93b4916bc..2b9fcfcd4 100644 --- a/programs/mango-v4/tests/cases/test_perp.rs +++ b/programs/mango-v4/tests/cases/test_perp.rs @@ -287,19 +287,19 @@ async fn test_perp_fixed() -> Result<(), TransportError> { let mango_account_0 = solana.get_account::(account_0).await; assert_eq!(mango_account_0.perps[0].base_position_lots(), 1); - assert!(assert_equal( + assert_eq_fixed_f64!( mango_account_0.perps[0].quote_position_native(), -99.99, 0.001 - )); + ); let mango_account_1 = solana.get_account::(account_1).await; assert_eq!(mango_account_1.perps[0].base_position_lots(), -1); - assert!(assert_equal( + assert_eq_fixed_f64!( mango_account_1.perps[0].quote_position_native(), 99.98, 0.001 - )); + ); // // TEST: closing perp positions @@ -364,19 +364,19 @@ async fn test_perp_fixed() -> Result<(), TransportError> { let mango_account_0 = solana.get_account::(account_0).await; assert_eq!(mango_account_0.perps[0].base_position_lots(), 0); - assert!(assert_equal( + assert_eq_fixed_f64!( mango_account_0.perps[0].quote_position_native(), 0.02, 0.001 - )); + ); let mango_account_1 = solana.get_account::(account_1).await; assert_eq!(mango_account_1.perps[0].base_position_lots(), 0); - assert!(assert_equal( + assert_eq_fixed_f64!( mango_account_1.perps[0].quote_position_native(), -0.04, 0.001 - )); + ); // settle pnl and fees to bring quote_position_native fully to 0 send_tx( @@ -644,19 +644,19 @@ async fn test_perp_oracle_peg() -> Result<(), TransportError> { let mango_account_0 = solana.get_account::(account_0).await; assert_eq!(mango_account_0.perps[0].base_position_lots(), 2); - assert!(assert_equal( + assert_eq_fixed_f64!( mango_account_0.perps[0].quote_position_native(), -19998.0, 0.001 - )); + ); let mango_account_1 = solana.get_account::(account_1).await; assert_eq!(mango_account_1.perps[0].base_position_lots(), -2); - assert!(assert_equal( + assert_eq_fixed_f64!( mango_account_1.perps[0].quote_position_native(), 19996.0, 0.001 - )); + ); // // TEST: Place a pegged order and check how it behaves with oracle changes @@ -1008,30 +1008,18 @@ async fn test_perp_realize_partially() -> Result<(), TransportError> { let mango_account_0 = solana.get_account::(account_0).await; let perp_0 = mango_account_0.perps[0]; assert_eq!(perp_0.base_position_lots(), 1); - assert!(assert_equal( + assert_eq_fixed_f64!( perp_0.quote_position_native(), -200_000.0 + 150_000.0, 0.001 - )); - assert!(assert_equal( - perp_0.realized_pnl_for_position_native, - 50_000.0, - 0.001 - )); + ); + assert_eq_fixed_f64!(perp_0.realized_pnl_for_position_native, 50_000.0, 0.001); let mango_account_1 = solana.get_account::(account_1).await; let perp_1 = mango_account_1.perps[0]; assert_eq!(perp_1.base_position_lots(), -1); - assert!(assert_equal( - perp_1.quote_position_native(), - 200_000.0 - 150_000.0, - 0.001 - )); - assert!(assert_equal( - perp_1.realized_pnl_for_position_native, - -50_000.0, - 0.001 - )); + assert_eq_fixed_f64!(perp_1.quote_position_native(), 200_000.0 - 150_000.0, 0.001); + assert_eq_fixed_f64!(perp_1.realized_pnl_for_position_native, -50_000.0, 0.001); Ok(()) } @@ -1593,6 +1581,138 @@ async fn test_perp_cancel_with_in_flight_events() -> Result<(), TransportError> Ok(()) } +#[tokio::test] +async fn test_perp_skip_bank() -> 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]; + + // + // SETUP: Create a group and an account + // + + let GroupWithTokens { group, tokens, .. } = GroupWithTokensConfig { + admin, + payer, + mints: mints.to_vec(), + ..GroupWithTokensConfig::default() + } + .create(solana) + .await; + + let deposit_amount = 1000; + let account = create_funded_account( + &solana, + group, + owner, + 0, + &context.users[1], + mints, + deposit_amount, + 0, + ) + .await; + + // + // SETUP: Create a perp market + // + let mango_v4::accounts::PerpCreateMarket { perp_market, .. } = send_tx( + solana, + PerpCreateMarketInstruction { + group, + admin, + payer, + perp_market_index: 0, + quote_lot_size: 10, + base_lot_size: 100, + maint_base_asset_weight: 0.975, + init_base_asset_weight: 0.95, + maint_base_liab_weight: 1.025, + init_base_liab_weight: 1.05, + base_liquidation_fee: 0.012, + maker_fee: 0.0000, + taker_fee: 0.0000, + settle_pnl_limit_factor: -1.0, + settle_pnl_limit_window_size_ts: 24 * 60 * 60, + ..PerpCreateMarketInstruction::with_new_book_and_queue(&solana, &tokens[1]).await + }, + ) + .await + .unwrap(); + + let perp_market_data = solana.get_account::(perp_market).await; + let price_lots = perp_market_data.native_price_to_lot(I80F48::from(1)); + + // + // TESTS + // + + // good without skips + send_tx( + solana, + HealthAccountSkipping { + inner: PerpPlaceOrderInstruction { + account, + perp_market, + owner, + side: Side::Bid, + price_lots, + max_base_lots: 2, + client_order_id: 5, + ..PerpPlaceOrderInstruction::default() + }, + skip_banks: vec![], + }, + ) + .await + .unwrap(); + + // can skip unrelated + send_tx( + solana, + HealthAccountSkipping { + inner: PerpPlaceOrderInstruction { + account, + perp_market, + owner, + side: Side::Bid, + price_lots, + max_base_lots: 2, + client_order_id: 5, + ..PerpPlaceOrderInstruction::default() + }, + skip_banks: vec![tokens[1].bank], + }, + ) + .await + .unwrap(); + + // can't skip settle token index + send_tx_expect_error!( + solana, + HealthAccountSkipping { + inner: PerpPlaceOrderInstruction { + account, + perp_market, + owner, + side: Side::Bid, + price_lots, + max_base_lots: 2, + client_order_id: 5, + ..PerpPlaceOrderInstruction::default() + }, + skip_banks: vec![tokens[0].bank], + }, + MangoError::TokenPositionDoesNotExist, + ); + + Ok(()) +} + async fn assert_no_perp_orders(solana: &SolanaCookie, account_0: Pubkey) { let mango_account_0 = solana.get_account::(account_0).await; diff --git a/programs/mango-v4/tests/cases/test_perp_settle.rs b/programs/mango-v4/tests/cases/test_perp_settle.rs index cc3fbb956..ec5f120ad 100644 --- a/programs/mango-v4/tests/cases/test_perp_settle.rs +++ b/programs/mango-v4/tests/cases/test_perp_settle.rs @@ -176,7 +176,7 @@ async fn test_perp_settle_pnl_basic() -> Result<(), TransportError> { } // Cannot settle with yourself - let result = send_tx( + send_tx_expect_error!( solana, PerpSettlePnlInstruction { settler, @@ -185,17 +185,11 @@ async fn test_perp_settle_pnl_basic() -> Result<(), TransportError> { account_b: account_0, perp_market, }, - ) - .await; - - assert_mango_error( - &result, - MangoError::CannotSettleWithSelf.into(), - "Cannot settle with yourself".to_string(), + MangoError::CannotSettleWithSelf ); // Cannot settle position that does not exist - let result = send_tx( + send_tx_expect_error!( solana, PerpSettlePnlInstruction { settler, @@ -204,13 +198,7 @@ async fn test_perp_settle_pnl_basic() -> Result<(), TransportError> { account_b: account_1, perp_market: perp_market_2, }, - ) - .await; - - assert_mango_error( - &result, - MangoError::PerpPositionDoesNotExist.into(), - "Cannot settle a position that does not exist".to_string(), + MangoError::PerpPositionDoesNotExist ); // TODO: Test funding settlement @@ -235,7 +223,7 @@ async fn test_perp_settle_pnl_basic() -> Result<(), TransportError> { set_perp_stub_oracle_price(solana, group, perp_market, &tokens[1], admin, 1200.0).await; // Account a must be the profitable one - let result = send_tx( + send_tx_expect_error!( solana, PerpSettlePnlInstruction { settler, @@ -244,13 +232,7 @@ async fn test_perp_settle_pnl_basic() -> Result<(), TransportError> { account_b: account_0, perp_market, }, - ) - .await; - - assert_mango_error( - &result, - MangoError::ProfitabilityMismatch.into(), - "Account a must be the profitable one".to_string(), + MangoError::ProfitabilityMismatch ); // Change the oracle to a more reasonable price @@ -1038,7 +1020,7 @@ async fn test_perp_pnl_settle_limit() -> Result<(), TransportError> { // Test 2: Once the settle limit is exhausted, we can't settle more // // we are in the same window, and we settled max. possible in previous attempt - let result = send_tx( + send_tx_expect_error!( solana, PerpSettlePnlInstruction { settler, @@ -1047,12 +1029,7 @@ async fn test_perp_pnl_settle_limit() -> Result<(), TransportError> { account_b: account_1, perp_market, }, - ) - .await; - assert_mango_error( - &result, - MangoError::ProfitabilityMismatch.into(), - "Account A has no settleable positive pnl left".to_string(), + MangoError::ProfitabilityMismatch ); // diff --git a/programs/mango-v4/tests/cases/test_perp_settle_fees.rs b/programs/mango-v4/tests/cases/test_perp_settle_fees.rs index 7867942b6..421655685 100644 --- a/programs/mango-v4/tests/cases/test_perp_settle_fees.rs +++ b/programs/mango-v4/tests/cases/test_perp_settle_fees.rs @@ -166,52 +166,40 @@ async fn test_perp_settle_fees() -> Result<(), TransportError> { let mango_account_0 = solana.get_account::(account_0).await; assert_eq!(mango_account_0.perps[0].base_position_lots(), 1); - assert!(assert_equal( + assert_eq_fixed_f64!( mango_account_0.perps[0].quote_position_native(), -100_020.0, 0.01 - )); + ); let mango_account_1 = solana.get_account::(account_1).await; assert_eq!(mango_account_1.perps[0].base_position_lots(), -1); - assert!(assert_equal( + assert_eq_fixed_f64!( mango_account_1.perps[0].quote_position_native(), 100_000.0, 0.01 - )); + ); // Cannot settle position that does not exist - let result = send_tx( + send_tx_expect_error!( solana, PerpSettleFeesInstruction { account: account_1, perp_market: perp_market_2, max_settle_amount: u64::MAX, }, - ) - .await; - - assert_mango_error( - &result, - MangoError::PerpPositionDoesNotExist.into(), - "Cannot settle a position that does not exist".to_string(), + MangoError::PerpPositionDoesNotExist ); // max_settle_amount must be greater than zero - let result = send_tx( + send_tx_expect_error!( solana, PerpSettleFeesInstruction { account: account_1, perp_market: perp_market, max_settle_amount: 0, }, - ) - .await; - - assert_mango_error( - &result, - MangoError::MaxSettleAmountMustBeGreaterThanZero.into(), - "max_settle_amount must be greater than zero".to_string(), + MangoError::MaxSettleAmountMustBeGreaterThanZero ); // TODO: Test funding settlement @@ -247,20 +235,20 @@ async fn test_perp_settle_fees() -> Result<(), TransportError> { // No change { let perp_market = solana.get_account::(perp_market).await; - assert!(assert_equal( + assert_eq_fixed_f64!( mango_account_0.perps[0] .unsettled_pnl(&perp_market, I80F48::from(1200)) .unwrap(), 19980.0, // 1*100*(1200-1000) - (20 in fees) 0.01 - )); - assert!(assert_equal( + ); + assert_eq_fixed_f64!( mango_account_1.perps[0] .unsettled_pnl(&perp_market, I80F48::from(1200)) .unwrap(), -20000.0, 0.01 - )); + ); } // TODO: Difficult to test health due to fees being so small. Need alternative diff --git a/programs/mango-v4/tests/cases/test_replay.rs b/programs/mango-v4/tests/cases/test_replay.rs new file mode 100644 index 000000000..881bbdf97 --- /dev/null +++ b/programs/mango-v4/tests/cases/test_replay.rs @@ -0,0 +1,215 @@ +use super::*; + +use solana_address_lookup_table_program::state::AddressLookupTable; +use solana_program::program_pack::Pack; +use solana_sdk::account::{Account, ReadableAccount}; +use solana_sdk::instruction::AccountMeta; +use solana_sdk::instruction::Instruction; +use solana_sdk::message::v0::LoadedAddresses; +use solana_sdk::message::SanitizedMessage; +use solana_sdk::message::SanitizedVersionedMessage; +use solana_sdk::message::SimpleAddressLoader; +use solana_sdk::transaction::VersionedTransaction; + +use anyhow::Context; +use mango_v4::accounts_zerocopy::LoadMutZeroCopy; +use std::str::FromStr; + +fn read_json_file>(path: P) -> anyhow::Result { + let file_contents = std::fs::read_to_string(path)?; + let json: serde_json::Value = serde_json::from_str(&file_contents)?; + Ok(json) +} + +fn account_from_snapshot(snapshot_path: &str, pk: Pubkey) -> anyhow::Result { + let file_path = format!("{}/{}.json", snapshot_path, pk); + let json = read_json_file(&file_path).with_context(|| format!("reading {file_path}"))?; + let account = json.get("account").unwrap(); + let data = base64::decode( + account.get("data").unwrap().as_array().unwrap()[0] + .as_str() + .unwrap(), + ) + .unwrap(); + let owner = Pubkey::from_str(account.get("owner").unwrap().as_str().unwrap()).unwrap(); + let mut account = Account::new(u64::MAX, data.len(), &owner); + account.data = data; + Ok(account) +} + +fn find_tx(block_file: &str, txsig: &str) -> Option<(u64, i64, Vec)> { + let txsig = bs58::decode(txsig).into_vec().unwrap(); + let block = read_json_file(block_file).unwrap(); + let slot = block.get("parentSlot").unwrap().as_u64().unwrap(); + let time = block.get("blockTime").unwrap().as_i64().unwrap(); + let txs = block.get("transactions").unwrap().as_array().unwrap(); + for tx_obj in txs { + let tx_bytes = base64::decode( + tx_obj.get("transaction").unwrap().as_array().unwrap()[0] + .as_str() + .unwrap(), + ) + .unwrap(); + let sig = &tx_bytes[1..65]; + if sig == txsig { + return Some((slot, time, tx_bytes.to_vec())); + } + } + None +} + +#[tokio::test] +async fn test_replay() -> anyhow::Result<()> { + // Path to a directory generated with cli save-snapshot, containing .json files + let snapshot_path = &"path/to/directory"; + // Path to the block data, retrieved with `solana block 252979760 --output json` + let block_file = &"path/to/block"; + // TX signature in the block that should be looked at + let txsig = &""; + // 0-based index of instuction in the tx to try replaying + let ix_index = 3; + + if txsig.is_empty() { + return Ok(()); + } + + let mut test_builder = TestContextBuilder::new(); + test_builder.test().set_compute_max_units(400_000); + let context = test_builder.start_default().await; + let solana = &context.solana.clone(); + let signer = context.users[0].key; + + let known_accounts = [ + "ComputeBudget111111111111111111111111111111", + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", + "4MangoMjqJ2firMokCjjGgoK8d4MXcrgL7XJaL3w6fVg", + "11111111111111111111111111111111", + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + ] + .iter() + .map(|s| Pubkey::from_str(s).unwrap()) + .collect_vec(); + + // Load block, find tx + let (slot, time, tx_bytes) = find_tx(block_file, txsig).unwrap(); + let tx: VersionedTransaction = bincode::deserialize(&tx_bytes).unwrap(); + + // Lookup ALTs so we can decompile + let loaded_addresses: LoadedAddresses = tx + .message + .address_table_lookups() + .unwrap_or_default() + .iter() + .map(|alt_lookup| { + let alt_account = account_from_snapshot(snapshot_path, alt_lookup.account_key).unwrap(); + let alt = AddressLookupTable::deserialize(&alt_account.data()).unwrap(); + LoadedAddresses { + readonly: alt_lookup + .readonly_indexes + .iter() + .map(|i| alt.addresses[*i as usize]) + .collect_vec(), + writable: alt_lookup + .writable_indexes + .iter() + .map(|i| alt.addresses[*i as usize]) + .collect_vec(), + } + }) + .collect(); + let alt_loader = SimpleAddressLoader::Enabled(loaded_addresses); + + // decompile instructions, looking up alts at the same time + let sv_message = SanitizedVersionedMessage::try_from(tx.message).unwrap(); + let s_message = SanitizedMessage::try_new(sv_message, alt_loader).unwrap(); + let bix = &s_message.decompile_instructions()[ix_index]; + let ix = Instruction { + program_id: *bix.program_id, + accounts: bix + .accounts + .iter() + .map(|m| AccountMeta { + pubkey: *m.pubkey, + is_writable: m.is_writable, + is_signer: m.is_signer, + }) + .collect(), + data: bix.data.to_vec(), + }; + + // since we can't retain the original signer/blockhash, replace it + let mut replaced_signers = vec![]; + let mut replaced_ix = ix.clone(); + for meta in &mut replaced_ix.accounts { + if meta.is_signer { + replaced_signers.push(meta.pubkey); + meta.pubkey = signer.pubkey(); + } + } + + // Load all accounts, reporting missing ones, add found to context + let mut missing_accounts = vec![]; + for pubkey in replaced_ix.accounts.iter().map(|m| m.pubkey) { + if known_accounts.contains(&pubkey) || pubkey == signer.pubkey() { + continue; + } + + let mut account = match account_from_snapshot(snapshot_path, pubkey) { + Ok(a) => a, + Err(e) => { + println!("error reading account from snapshot: {pubkey}, error {e:?}"); + missing_accounts.push(pubkey); + continue; + } + }; + + // Override where the previous signer was an owner + if replaced_signers.contains(&account.owner) { + account.owner = signer.pubkey(); + } + + // Override mango account owners or delegates + if let Ok(mut ma) = account.load_mut::() { + if replaced_signers.contains(&ma.owner) { + ma.owner = signer.pubkey(); + } + if replaced_signers.contains(&ma.delegate) { + ma.delegate = signer.pubkey(); + } + } + + // Override token account owners + if account.owner == spl_token::id() { + if let Ok(mut ta) = spl_token::state::Account::unpack(&account.data) { + if replaced_signers.contains(&ta.owner) { + ta.owner = signer.pubkey(); + } + spl_token::state::Account::pack(ta, &mut account.data).unwrap(); + } + } + + let mut program_test_context = solana.context.borrow_mut(); + program_test_context.set_account(&pubkey, &account.into()); + } + if !missing_accounts.is_empty() { + println!("There were account reading errors, maybe fetch them:"); + for a in &missing_accounts { + println!("solana account {a} --output json -o {snapshot_path}/{a}.json"); + } + anyhow::bail!("accounts were missing"); + } + + // update slot/time to roughly match + let mut clock = solana.clock().await; + clock.slot = slot; + clock.unix_timestamp = time; + solana.set_clock(&clock); + + // Send transaction + solana + .process_transaction(&[replaced_ix], Some(&[signer])) + .await + .unwrap(); + + Ok(()) +} diff --git a/programs/mango-v4/tests/cases/test_serum.rs b/programs/mango-v4/tests/cases/test_serum.rs index 20bf59fa3..27613a830 100644 --- a/programs/mango-v4/tests/cases/test_serum.rs +++ b/programs/mango-v4/tests/cases/test_serum.rs @@ -36,35 +36,39 @@ impl SerumOrderPlacer { None } + fn bid_ix( + &mut self, + limit_price: f64, + max_base: u64, + taker: bool, + ) -> Serum3PlaceOrderInstruction { + let client_order_id = self.inc_client_order_id(); + let fees = if taker { 0.0004 } else { 0.0 }; + Serum3PlaceOrderInstruction { + side: Serum3Side::Bid, + limit_price: (limit_price * 100.0 / 10.0) as u64, // in quote_lot (10) per base lot (100) + max_base_qty: max_base / 100, // in base lot (100) + // 4 bps taker fees added in + max_native_quote_qty_including_fees: (limit_price * (max_base as f64) * (1.0 + fees)) + .ceil() as u64, + self_trade_behavior: Serum3SelfTradeBehavior::AbortTransaction, + order_type: Serum3OrderType::Limit, + client_order_id, + limit: 10, + account: self.account, + owner: self.owner, + serum_market: self.serum_market, + } + } + async fn try_bid( &mut self, limit_price: f64, max_base: u64, taker: bool, ) -> Result { - let client_order_id = self.inc_client_order_id(); - let fees = if taker { 0.0004 } else { 0.0 }; - send_tx( - &self.solana, - Serum3PlaceOrderInstruction { - side: Serum3Side::Bid, - limit_price: (limit_price * 100.0 / 10.0) as u64, // in quote_lot (10) per base lot (100) - max_base_qty: max_base / 100, // in base lot (100) - // 4 bps taker fees added in - max_native_quote_qty_including_fees: (limit_price - * (max_base as f64) - * (1.0 + fees)) - .ceil() as u64, - self_trade_behavior: Serum3SelfTradeBehavior::AbortTransaction, - order_type: Serum3OrderType::Limit, - client_order_id, - limit: 10, - account: self.account, - owner: self.owner, - serum_market: self.serum_market, - }, - ) - .await + let ix = self.bid_ix(limit_price, max_base, taker); + send_tx(&self.solana, ix).await } async fn bid_maker(&mut self, limit_price: f64, max_base: u64) -> Option<(u128, u64)> { @@ -579,7 +583,7 @@ async fn test_serum_loan_origination_fees() -> Result<(), TransportError> { .get_account::(quote_bank) .await .collected_fees_native; - assert!(assert_equal(quote_fees2 - quote_fees1, 0.0, 0.1)); + assert_eq_fixed_f64!(quote_fees2 - quote_fees1, 0.0, 0.1); // check account2 balances too context @@ -610,11 +614,11 @@ async fn test_serum_loan_origination_fees() -> Result<(), TransportError> { .get_account::(quote_bank) .await .collected_fees_native; - assert!(assert_equal( + assert_eq_fixed_f64!( quote_fees3 - quote_fees1, loan_origination_fee(fill_amount - deposit_amount) as f64, 0.1 - )); + ); order_placer.settle().await; @@ -623,11 +627,11 @@ async fn test_serum_loan_origination_fees() -> Result<(), TransportError> { .get_account::(quote_bank) .await .collected_fees_native; - assert!(assert_equal( + assert_eq_fixed_f64!( quote_fees4 - quote_fees3, serum_fee(fill_amount) as f64, 0.1 - )); + ); let account_data = solana.get_account::(account).await; assert_eq!( @@ -720,11 +724,11 @@ async fn test_serum_settle_v1() -> Result<(), TransportError> { .get_account::(quote_bank) .await .collected_fees_native; - assert!(assert_equal( + assert_eq_fixed_f64!( quote_fees_end - quote_fees_start, (lof + serum_referrer_fee(amount)) as f64, 0.1 - )); + ); Ok(()) } @@ -817,11 +821,11 @@ async fn test_serum_settle_v2_to_dao() -> Result<(), TransportError> { .get_account::(quote_bank) .await .collected_fees_native; - assert!(assert_equal( + assert_eq_fixed_f64!( quote_fees_end - quote_fees_start, (lof + serum_referrer_fee(amount)) as f64, 0.1 - )); + ); let account_data = solana.get_account::(account).await; assert_eq!( @@ -913,11 +917,7 @@ async fn test_serum_settle_v2_to_account() -> Result<(), TransportError> { .get_account::(quote_bank) .await .collected_fees_native; - assert!(assert_equal( - quote_fees_end - quote_fees_start, - lof as f64, - 0.1 - )); + assert_eq_fixed_f64!(quote_fees_end - quote_fees_start, lof as f64, 0.1); let account_data = solana.get_account::(account).await; assert_eq!(account_data.buyback_fees_accrued_current, 0); @@ -1029,7 +1029,7 @@ async fn test_serum_reduce_only_deposits1() -> Result<(), TransportError> { #[tokio::test] async fn test_serum_reduce_only_deposits2() -> Result<(), TransportError> { let mut test_builder = TestContextBuilder::new(); - test_builder.test().set_compute_max_units(95_000); // Serum3PlaceOrder needs 92.8k + test_builder.test().set_compute_max_units(97_000); // Serum3PlaceOrder needs 95.8k let context = test_builder.start_default().await; let solana = &context.solana.clone(); @@ -1952,6 +1952,71 @@ async fn test_serum_deposit_limits() -> Result<(), TransportError> { Ok(()) } +#[tokio::test] +async fn test_serum_skip_bank() -> Result<(), TransportError> { + let mut test_builder = TestContextBuilder::new(); + test_builder.test().set_compute_max_units(150_000); // Serum3PlaceOrder needs lots + let context = test_builder.start_default().await; + let solana = &context.solana.clone(); + + // + // SETUP: Create a group, accounts, market etc + // + let deposit_amount = 5000; + let CommonSetup { + group_with_tokens, + mut order_placer, + .. + } = common_setup(&context, deposit_amount).await; + let tokens = group_with_tokens.tokens; + + // + // TESTS + // + + // verify generally good + send_tx( + solana, + HealthAccountSkipping { + inner: order_placer.bid_ix(1.0, 100, false), + skip_banks: vec![], + }, + ) + .await + .unwrap(); + + // can skip uninvolved token + send_tx( + solana, + HealthAccountSkipping { + inner: order_placer.bid_ix(1.0, 100, false), + skip_banks: vec![tokens[2].bank], + }, + ) + .await + .unwrap(); + + // can't skip base or quote token + send_tx_expect_error!( + solana, + HealthAccountSkipping { + inner: order_placer.bid_ix(1.0, 100, false), + skip_banks: vec![tokens[0].bank], + }, + MangoError::TokenPositionDoesNotExist + ); + send_tx_expect_error!( + solana, + HealthAccountSkipping { + inner: order_placer.bid_ix(1.0, 100, false), + skip_banks: vec![tokens[1].bank], + }, + MangoError::TokenPositionDoesNotExist + ); + + Ok(()) +} + struct CommonSetup { group_with_tokens: GroupWithTokens, serum_market_cookie: SpotMarketCookie, diff --git a/programs/mango-v4/tests/cases/test_stale_oracles.rs b/programs/mango-v4/tests/cases/test_stale_oracles.rs index 1a96fa578..0dc51fbb7 100644 --- a/programs/mango-v4/tests/cases/test_stale_oracles.rs +++ b/programs/mango-v4/tests/cases/test_stale_oracles.rs @@ -321,7 +321,7 @@ async fn test_fallback_oracle_withdraw() -> Result<(), TransportError> { } #[tokio::test] -async fn test_clmm_fallback_oracle() -> Result<(), TransportError> { +async fn test_orca_fallback_oracle() -> Result<(), TransportError> { // add ability to find fixtures let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR")); d.push("resources/test"); @@ -340,6 +340,237 @@ async fn test_clmm_fallback_oracle() -> Result<(), TransportError> { "Gnt27xtC473ZT2Mw5u8wZ68Z3gULkSTb5DuxJy7eJotD", "FsJ3A3u2vn5cTVofAjvy6y5kwABJAqYWpe4975bi2epH", ), + ( + "Ds33rQ1d4AXwxqyeXX6Pc3G4pFNr6iWb3dd8YfBBQMPr", + "CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK", + ), + ]; + + let fallback_oracle = Pubkey::from_str(fixtures[0].0).unwrap(); + let pyth_usd_oracle = Pubkey::from_str(fixtures[1].0).unwrap(); + let wrong_fallback_oracle = Pubkey::from_str(fixtures[2].0).unwrap(); + + // setup pyth and clmm accounts + for fixture in fixtures { + let filename = format!("resources/test/{}.bin", fixture.0); + let data = read_file(find_file(&filename).unwrap()); + let mut account = + AccountSharedData::new(u64::MAX, data.len(), &Pubkey::from_str(fixture.1).unwrap()); + account.set_data(data); + let mut program_test_context = solana.context.borrow_mut(); + program_test_context.set_account(&Pubkey::from_str(fixture.0).unwrap(), &account); + } + + let admin = TestKeypair::new(); + let owner = context.users[0].key; + let payer = context.users[1].key; + let mints = &context.mints[0..4]; + let payer_token_accounts = &context.users[1].token_accounts[0..3]; + + let mango_setup::GroupWithTokens { group, tokens, .. } = mango_setup::GroupWithTokensConfig { + admin, + payer, + mints: mints.to_vec(), + ..mango_setup::GroupWithTokensConfig::default() + } + .create(solana) + .await; + + // add a fallback oracle + send_tx( + solana, + TokenEdit { + group, + admin, + mint: mints[2].pubkey, + fallback_oracle, + options: mango_v4::instruction::TokenEdit { + set_fallback_oracle: true, + ..token_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); + + let bank_data: Bank = solana.get_account(tokens[2].bank).await; + assert!(bank_data.fallback_oracle == fallback_oracle); + + // fill vaults, so we can borrow + let _vault_account = create_funded_account( + &solana, + group, + owner, + 2, + &context.users[1], + mints, + 100_000, + 0, + ) + .await; + + // Create account with token3 of deposits + let account = create_funded_account( + &solana, + group, + owner, + 0, + &context.users[1], + &[mints[2]], + 10_000, + 0, + ) + .await; + + // Adjust oracle prices to match CLMM + for i in 0..3 { + send_tx( + solana, + StubOracleSetTestInstruction { + oracle: tokens[i].oracle, + group, + mint: mints[i].pubkey, + admin, + price: 0.06300727055072872, + last_update_slot: 0, + deviation: 0.0, + }, + ) + .await + .unwrap(); + } + + // Create some token1 borrows + send_tx( + solana, + TokenWithdrawInstruction { + amount: 100, + allow_borrow: true, + account, + owner, + token_account: payer_token_accounts[1], + bank_index: 0, + }, + ) + .await + .unwrap(); + + // Make oracle invalid by increasing deviation + send_tx( + solana, + StubOracleSetTestInstruction { + oracle: tokens[2].oracle, + group, + mint: mints[2].pubkey, + admin, + price: 0.06300727055072872, + last_update_slot: 0, + deviation: 100.0, + }, + ) + .await + .unwrap(); + + let token_withdraw_ix = TokenWithdrawInstruction { + amount: 1, + allow_borrow: true, + account, + owner, + token_account: payer_token_accounts[2], + bank_index: 0, + }; + + // Verify that withdrawing collateral won't work + assert!(send_tx(solana, token_withdraw_ix.clone()).await.is_err()); + + // Send txn with a fallback oracle in the remaining accounts, but no pyth USD feed + let fallback_oracle_meta = AccountMeta { + pubkey: fallback_oracle, + is_writable: false, + is_signer: false, + }; + assert!(send_tx_with_extra_accounts( + solana, + token_withdraw_ix.clone(), + vec![fallback_oracle_meta.clone()] + ) + .await + .unwrap() + .result + .is_err()); + + // add wrong_fallback_oracle for a different token + send_tx( + solana, + TokenEdit { + group, + admin, + mint: mints[3].pubkey, + fallback_oracle: wrong_fallback_oracle, + options: mango_v4::instruction::TokenEdit { + set_fallback_oracle: true, + ..token_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); + + // Send txn with a the wrong fallback oracle + let wrong_fallback_meta = AccountMeta { + pubkey: wrong_fallback_oracle, + is_writable: false, + is_signer: false, + }; + assert!(send_tx_with_extra_accounts( + solana, + token_withdraw_ix.clone(), + vec![wrong_fallback_meta.clone()] + ) + .await + .unwrap() + .result + .is_err()); + + // Finally send txn with a fallback oracle and pyth USD feed + let pyth_usd_oracle_meta = AccountMeta { + pubkey: pyth_usd_oracle, + is_writable: false, + is_signer: false, + }; + send_tx_with_extra_accounts( + solana, + token_withdraw_ix, + vec![fallback_oracle_meta, pyth_usd_oracle_meta], + ) + .await + .unwrap() + .result + .unwrap(); + + Ok(()) +} + +#[tokio::test] +async fn test_raydium_fallback_oracle() -> Result<(), TransportError> { + // add ability to find fixtures + let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + d.push("resources/test"); + + let mut test_builder = TestContextBuilder::new(); + test_builder.test().set_compute_max_units(150_000); // bad oracles log a lot + let context = test_builder.start_default().await; + let solana = &context.solana.clone(); + + let fixtures = vec![ + ( + "Ds33rQ1d4AXwxqyeXX6Pc3G4pFNr6iWb3dd8YfBBQMPr", + "CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK", + ), + ( + "Gnt27xtC473ZT2Mw5u8wZ68Z3gULkSTb5DuxJy7eJotD", + "FsJ3A3u2vn5cTVofAjvy6y5kwABJAqYWpe4975bi2epH", + ), ]; let fallback_oracle = Pubkey::from_str(fixtures[0].0).unwrap(); diff --git a/programs/mango-v4/tests/cases/test_token_conditional_swap.rs b/programs/mango-v4/tests/cases/test_token_conditional_swap.rs index 0945afff5..2f59f8eba 100644 --- a/programs/mango-v4/tests/cases/test_token_conditional_swap.rs +++ b/programs/mango-v4/tests/cases/test_token_conditional_swap.rs @@ -2,8 +2,6 @@ use super::*; #[tokio::test] async fn test_token_conditional_swap_basic() -> Result<(), TransportError> { - pub use utils::assert_equal_f64_f64 as assert_equal_f_f; - let context = TestContext::new().await; let solana = &context.solana.clone(); @@ -263,17 +261,17 @@ async fn test_token_conditional_swap_basic() -> Result<(), TransportError> { let liqee_quote = account_position_f64(solana, account, quote_token.bank).await; let liqee_base = account_position_f64(solana, account, base_token.bank).await; - assert!(assert_equal_f_f( + assert_eq_f64!( liqee_quote, deposit_amount + 42.0, // roughly 50 / (1.1 * 1.1) 0.01 - )); - assert!(assert_equal_f_f(liqee_base, deposit_amount - 50.0, 0.01)); + ); + assert_eq_f64!(liqee_base, deposit_amount - 50.0, 0.01); let liqor_quote = account_position_f64(solana, liqor, quote_token.bank).await; let liqor_base = account_position_f64(solana, liqor, base_token.bank).await; - assert!(assert_equal_f_f(liqor_quote, deposit_amount - 42.0, 0.01)); - assert!(assert_equal_f_f(liqor_base, deposit_amount + 44.0, 0.01)); // roughly 42*1.1*0.95 + assert_eq_f64!(liqor_quote, deposit_amount - 42.0, 0.01); + assert_eq_f64!(liqor_base, deposit_amount + 44.0, 0.01); // roughly 42*1.1*0.95 // // TEST: requiring a too-high min buy token execution makes it fail @@ -315,13 +313,13 @@ async fn test_token_conditional_swap_basic() -> Result<(), TransportError> { let liqee_quote = account_position_f64(solana, account, quote_token.bank).await; let liqee_base = account_position_f64(solana, account, base_token.bank).await; - assert!(assert_equal_f_f(liqee_quote, deposit_amount + 84.0, 0.01)); - assert!(assert_equal_f_f(liqee_base, deposit_amount - 100.0, 0.01)); + assert_eq_f64!(liqee_quote, deposit_amount + 84.0, 0.01); + assert_eq_f64!(liqee_base, deposit_amount - 100.0, 0.01); let liqor_quote = account_position_f64(solana, liqor, quote_token.bank).await; let liqor_base = account_position_f64(solana, liqor, base_token.bank).await; - assert!(assert_equal_f_f(liqor_quote, deposit_amount - 84.0, 0.01)); - assert!(assert_equal_f_f(liqor_base, deposit_amount + 88.0, 0.01)); + assert_eq_f64!(liqor_quote, deposit_amount - 84.0, 0.01); + assert_eq_f64!(liqor_base, deposit_amount + 88.0, 0.01); let account_data = get_mango_account(solana, account).await; assert!(!account_data @@ -334,8 +332,6 @@ async fn test_token_conditional_swap_basic() -> Result<(), TransportError> { #[tokio::test] async fn test_token_conditional_swap_linear_auction() -> Result<(), TransportError> { - pub use utils::assert_equal_f64_f64 as assert_equal_f_f; - let context = TestContext::new().await; let solana = &context.solana.clone(); @@ -460,7 +456,7 @@ async fn test_token_conditional_swap_linear_auction() -> Result<(), TransportErr // TEST: Can't take an auction at any price when it's not started yet // - let res = send_tx( + send_tx_expect_error!( solana, TokenConditionalSwapTriggerInstruction { liqee: account, @@ -472,12 +468,7 @@ async fn test_token_conditional_swap_linear_auction() -> Result<(), TransportErr min_buy_token: 0, min_taker_price: 0.0, }, - ) - .await; - assert_mango_error( - &res, - MangoError::TokenConditionalSwapNotStarted.into(), - "tcs should not be started yet".to_string(), + MangoError::TokenConditionalSwapNotStarted ); // @@ -507,21 +498,13 @@ async fn test_token_conditional_swap_linear_auction() -> Result<(), TransportErr let account_quote = account_position_f64(solana, account, quote_token.bank).await; let account_base = account_position_f64(solana, account, base_token.bank).await; - assert!(assert_equal_f64_f64( - account_quote, - account_quote_expected, - 0.1 - )); - assert!(assert_equal_f64_f64( - account_base, - account_base_expected, - 0.1 - )); + assert_eq_f64!(account_quote, account_quote_expected, 0.1); + assert_eq_f64!(account_base, account_base_expected, 0.1); let liqor_quote = account_position_f64(solana, liqor, quote_token.bank).await; let liqor_base = account_position_f64(solana, liqor, base_token.bank).await; - assert!(assert_equal_f64_f64(liqor_quote, liqor_quote_expected, 0.1)); - assert!(assert_equal_f64_f64(liqor_base, liqor_base_expected, 0.1)); + assert_eq_f64!(liqor_quote, liqor_quote_expected, 0.1); + assert_eq_f64!(liqor_base, liqor_base_expected, 0.1); // // TEST: Stays at end price after end and before expiry @@ -550,27 +533,19 @@ async fn test_token_conditional_swap_linear_auction() -> Result<(), TransportErr let account_quote = account_position_f64(solana, account, quote_token.bank).await; let account_base = account_position_f64(solana, account, base_token.bank).await; - assert!(assert_equal_f64_f64( - account_quote, - account_quote_expected, - 0.1 - )); - assert!(assert_equal_f64_f64( - account_base, - account_base_expected, - 0.1 - )); + assert_eq_f64!(account_quote, account_quote_expected, 0.1); + assert_eq_f64!(account_base, account_base_expected, 0.1); let liqor_quote = account_position_f64(solana, liqor, quote_token.bank).await; let liqor_base = account_position_f64(solana, liqor, base_token.bank).await; - assert!(assert_equal_f64_f64(liqor_quote, liqor_quote_expected, 0.1)); - assert!(assert_equal_f64_f64(liqor_base, liqor_base_expected, 0.1)); + assert_eq_f64!(liqor_quote, liqor_quote_expected, 0.1); + assert_eq_f64!(liqor_base, liqor_base_expected, 0.1); // // TEST: Can't take when expired // solana.set_clock_timestamp(initial_time + 22).await; - let res = send_tx( + send_tx_expect_error!( solana, TokenConditionalSwapTriggerInstruction { liqee: account, @@ -582,12 +557,7 @@ async fn test_token_conditional_swap_linear_auction() -> Result<(), TransportErr min_buy_token: 1, min_taker_price: 0.0, }, - ) - .await; - assert_mango_error( - &res, - MangoError::TokenConditionalSwapExpired.into(), - "tcs should be expired".to_string(), + MangoError::TokenConditionalSwapExpired ); Ok(()) @@ -595,8 +565,6 @@ async fn test_token_conditional_swap_linear_auction() -> Result<(), TransportErr #[tokio::test] async fn test_token_conditional_swap_premium_auction() -> Result<(), TransportError> { - pub use utils::assert_equal_f64_f64 as assert_equal_f_f; - let context = TestContext::new().await; let solana = &context.solana.clone(); @@ -720,7 +688,7 @@ async fn test_token_conditional_swap_premium_auction() -> Result<(), TransportEr // set_bank_stub_oracle_price(solana, group, &base_token, admin, 10.0).await; - let res = send_tx( + send_tx_expect_error!( solana, TokenConditionalSwapTriggerInstruction { liqee: account, @@ -732,15 +700,10 @@ async fn test_token_conditional_swap_premium_auction() -> Result<(), TransportEr min_buy_token: 0, min_taker_price: 0.0, }, - ) - .await; - assert_mango_error( - &res, - MangoError::TokenConditionalSwapNotStarted.into(), - "not started yet".to_string(), + MangoError::TokenConditionalSwapNotStarted ); - let res = send_tx( + send_tx_expect_error!( solana, TokenConditionalSwapStartInstruction { liqee: account, @@ -748,19 +711,14 @@ async fn test_token_conditional_swap_premium_auction() -> Result<(), TransportEr liqor_owner: owner, index: 0, }, - ) - .await; - assert_mango_error( - &res, - MangoError::TokenConditionalSwapPriceNotInRange.into(), - "price not in range".to_string(), + MangoError::TokenConditionalSwapPriceNotInRange ); // // TEST: Cannot trigger without start // set_bank_stub_oracle_price(solana, group, &base_token, admin, 1.0).await; - let res = send_tx( + send_tx_expect_error!( solana, TokenConditionalSwapTriggerInstruction { liqee: account, @@ -772,12 +730,7 @@ async fn test_token_conditional_swap_premium_auction() -> Result<(), TransportEr min_buy_token: 1, min_taker_price: 0.0, }, - ) - .await; - assert_mango_error( - &res, - MangoError::TokenConditionalSwapNotStarted.into(), - "not started yet".to_string(), + MangoError::TokenConditionalSwapNotStarted ); send_tx( @@ -815,21 +768,13 @@ async fn test_token_conditional_swap_premium_auction() -> Result<(), TransportEr let account_quote = account_position_f64(solana, account, quote_token.bank).await; let account_base = account_position_f64(solana, account, base_token.bank).await; - assert!(assert_equal_f64_f64( - account_quote, - account_quote_expected, - 0.1 - )); - assert!(assert_equal_f64_f64( - account_base, - account_base_expected, - 0.1 - )); + assert_eq_f64!(account_quote, account_quote_expected, 0.1); + assert_eq_f64!(account_base, account_base_expected, 0.1); let liqor_quote = account_position_f64(solana, liqor, quote_token.bank).await; let liqor_base = account_position_f64(solana, liqor, base_token.bank).await; - assert!(assert_equal_f64_f64(liqor_quote, liqor_quote_expected, 0.1)); - assert!(assert_equal_f64_f64(liqor_base, liqor_base_expected, 0.1)); + assert_eq_f64!(liqor_quote, liqor_quote_expected, 0.1); + assert_eq_f64!(liqor_base, liqor_base_expected, 0.1); let account_data = get_mango_account(solana, account).await; let tcs = account_data @@ -866,21 +811,13 @@ async fn test_token_conditional_swap_premium_auction() -> Result<(), TransportEr let account_quote = account_position_f64(solana, account, quote_token.bank).await; let account_base = account_position_f64(solana, account, base_token.bank).await; - assert!(assert_equal_f64_f64( - account_quote, - account_quote_expected, - 0.1 - )); - assert!(assert_equal_f64_f64( - account_base, - account_base_expected, - 0.1 - )); + assert_eq_f64!(account_quote, account_quote_expected, 0.1); + assert_eq_f64!(account_base, account_base_expected, 0.1); let liqor_quote = account_position_f64(solana, liqor, quote_token.bank).await; let liqor_base = account_position_f64(solana, liqor, base_token.bank).await; - assert!(assert_equal_f64_f64(liqor_quote, liqor_quote_expected, 0.1)); - assert!(assert_equal_f64_f64(liqor_base, liqor_base_expected, 0.1)); + assert_eq_f64!(liqor_quote, liqor_quote_expected, 0.1); + assert_eq_f64!(liqor_base, liqor_base_expected, 0.1); // // TEST: Premium stops at max increases @@ -910,21 +847,13 @@ async fn test_token_conditional_swap_premium_auction() -> Result<(), TransportEr let account_quote = account_position_f64(solana, account, quote_token.bank).await; let account_base = account_position_f64(solana, account, base_token.bank).await; - assert!(assert_equal_f64_f64( - account_quote, - account_quote_expected, - 0.1 - )); - assert!(assert_equal_f64_f64( - account_base, - account_base_expected, - 0.1 - )); + assert_eq_f64!(account_quote, account_quote_expected, 0.1); + assert_eq_f64!(account_base, account_base_expected, 0.1); let liqor_quote = account_position_f64(solana, liqor, quote_token.bank).await; let liqor_base = account_position_f64(solana, liqor, base_token.bank).await; - assert!(assert_equal_f64_f64(liqor_quote, liqor_quote_expected, 0.1)); - assert!(assert_equal_f64_f64(liqor_base, liqor_base_expected, 0.1)); + assert_eq_f64!(liqor_quote, liqor_quote_expected, 0.1); + assert_eq_f64!(liqor_base, liqor_base_expected, 0.1); // // SETUP: make another premium auction to test starting @@ -954,7 +883,7 @@ async fn test_token_conditional_swap_premium_auction() -> Result<(), TransportEr // TEST: Can't start if oracle not in range // - let res = send_tx( + send_tx_expect_error!( solana, TokenConditionalSwapStartInstruction { liqee: account, @@ -962,12 +891,7 @@ async fn test_token_conditional_swap_premium_auction() -> Result<(), TransportEr liqor_owner: owner, index: 1, }, - ) - .await; - assert_mango_error( - &res, - MangoError::TokenConditionalSwapPriceNotInRange.into(), - "price is not in range".to_string(), + MangoError::TokenConditionalSwapPriceNotInRange ); // @@ -998,7 +922,7 @@ async fn test_token_conditional_swap_premium_auction() -> Result<(), TransportEr // TEST: Can't start a second time // - let res = send_tx( + send_tx_expect_error!( solana, TokenConditionalSwapStartInstruction { liqee: account, @@ -1006,12 +930,7 @@ async fn test_token_conditional_swap_premium_auction() -> Result<(), TransportEr liqor_owner: owner, index: 1, }, - ) - .await; - assert_mango_error( - &res, - MangoError::TokenConditionalSwapAlreadyStarted.into(), - "already started".to_string(), + MangoError::TokenConditionalSwapAlreadyStarted ); Ok(()) @@ -1019,8 +938,6 @@ async fn test_token_conditional_swap_premium_auction() -> Result<(), TransportEr #[tokio::test] async fn test_token_conditional_swap_deposit_limit() -> Result<(), TransportError> { - pub use utils::assert_equal_f64_f64 as assert_equal_f_f; - let context = TestContext::new().await; let solana = &context.solana.clone(); diff --git a/programs/mango-v4/tests/cases/test_token_update_index_and_rate.rs b/programs/mango-v4/tests/cases/test_token_update_index_and_rate.rs index 29a197a4a..242125c42 100644 --- a/programs/mango-v4/tests/cases/test_token_update_index_and_rate.rs +++ b/programs/mango-v4/tests/cases/test_token_update_index_and_rate.rs @@ -78,22 +78,22 @@ async fn test_token_update_index_and_rate() -> Result<(), TransportError> { let interest_change = 5000.0 * (dynamic_rate + loan_fee_rate) * diff_ts / year; let fee_change = 5000.0 * loan_fee_rate * diff_ts / year; - assert!(assert_equal( + assert_eq_fixed_f64!( bank_after.native_borrows() - bank_before.native_borrows(), interest_change, 0.1 - )); - assert!(assert_equal( + ); + assert_eq_fixed_f64!( bank_after.native_deposits() - bank_before.native_deposits(), interest_change, 0.1 - )); - assert!(assert_equal( + ); + assert_eq_fixed_f64!( bank_after.collected_fees_native - bank_before.collected_fees_native, fee_change, 0.1 - )); - assert!(assert_equal(bank_after.avg_utilization, utilization, 0.01)); + ); + assert_eq_fixed_f64!(bank_after.avg_utilization, utilization, 0.01); Ok(()) } @@ -140,19 +140,11 @@ async fn test_token_rates_migrate() -> Result<(), TransportError> { let bank_after = solana.get_account::(tokens[0].bank).await; - assert!(assert_equal_fixed_f64(bank_after.rate0, 0.07 / 3.0, 0.0001)); - assert!(assert_equal_fixed_f64(bank_after.rate1, 0.9 / 3.0, 0.0001)); - assert!(assert_equal_fixed_f64(bank_after.max_rate, 0.5, 0.0001)); - assert!(assert_equal_f64_f64( - bank_after.interest_curve_scaling, - 3.0, - 0.0001 - )); - assert!(assert_equal_f64_f64( - bank_after.interest_target_utilization as f64, - 0.4, - 0.0001 - )); + assert_eq_fixed_f64!(bank_after.rate0, 0.07 / 3.0, 0.0001); + assert_eq_fixed_f64!(bank_after.rate1, 0.9 / 3.0, 0.0001); + assert_eq_fixed_f64!(bank_after.max_rate, 0.5, 0.0001); + assert_eq_f64!(bank_after.interest_curve_scaling, 3.0, 0.0001); + assert_eq_f64!(bank_after.interest_target_utilization as f64, 0.4, 0.0001); Ok(()) } diff --git a/programs/mango-v4/tests/program_test/mango_client.rs b/programs/mango-v4/tests/program_test/mango_client.rs index a0e28b5a2..2efa8967a 100644 --- a/programs/mango-v4/tests/program_test/mango_client.rs +++ b/programs/mango-v4/tests/program_test/mango_client.rs @@ -7,7 +7,7 @@ use anchor_spl::token::{Token, TokenAccount}; use fixed::types::I80F48; use itertools::Itertools; use mango_v4::accounts_ix::{ - InterestRateParams, Serum3OrderType, Serum3SelfTradeBehavior, Serum3Side, + HealthCheckKind, InterestRateParams, Serum3OrderType, Serum3SelfTradeBehavior, Serum3Side, }; use mango_v4::state::{MangoAccount, MangoAccountValue}; use solana_program::instruction::Instruction; @@ -35,7 +35,7 @@ pub trait ClientAccountLoader { } #[async_trait::async_trait(?Send)] -impl ClientAccountLoader for &SolanaCookie { +impl ClientAccountLoader for SolanaCookie { async fn load_bytes(&self, pubkey: &Pubkey) -> Option> { self.get_account_data(*pubkey).await } @@ -186,7 +186,7 @@ pub trait ClientInstruction { async fn to_instruction( &self, - loader: impl ClientAccountLoader + 'async_trait, + loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction); fn signers(&self) -> Vec; } @@ -552,7 +552,7 @@ impl ClientInstruction for FlashLoanBeginInstruction { type Instruction = mango_v4::instruction::FlashLoanBegin; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); @@ -626,7 +626,7 @@ impl ClientInstruction for FlashLoanSwapBeginInstruction { type Instruction = mango_v4::instruction::FlashLoanSwapBegin; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); @@ -718,7 +718,7 @@ impl ClientInstruction for FlashLoanEndInstruction { type Instruction = mango_v4::instruction::FlashLoanEndV2; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -735,14 +735,9 @@ impl ClientInstruction for FlashLoanEndInstruction { account.ensure_token_position(bank.token_index).unwrap(); } - let health_check_metas = derive_health_check_remaining_account_metas( - &account_loader, - &account, - None, - true, - None, - ) - .await; + let health_check_metas = + derive_health_check_remaining_account_metas(account_loader, &account, None, true, None) + .await; let accounts = Self::Accounts { account: self.account, @@ -797,7 +792,7 @@ impl ClientInstruction for TokenWithdrawInstruction { type Instruction = mango_v4::instruction::TokenWithdraw; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -823,7 +818,7 @@ impl ClientInstruction for TokenWithdrawInstruction { let mint_info: MintInfo = account_loader.load(&mint_info).await.unwrap(); let health_check_metas = derive_health_check_remaining_account_metas( - &account_loader, + account_loader, &account, Some(mint_info.banks[self.bank_index]), false, @@ -869,7 +864,7 @@ impl ClientInstruction for TokenDepositInstruction { type Instruction = mango_v4::instruction::TokenDeposit; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -895,7 +890,7 @@ impl ClientInstruction for TokenDepositInstruction { let mint_info: MintInfo = account_loader.load(&mint_info).await.unwrap(); let health_check_metas = derive_health_check_remaining_account_metas( - &account_loader, + account_loader, &account, Some(mint_info.banks[self.bank_index]), false, @@ -940,7 +935,7 @@ impl ClientInstruction for TokenDepositIntoExistingInstruction { type Instruction = mango_v4::instruction::TokenDepositIntoExisting; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -966,7 +961,7 @@ impl ClientInstruction for TokenDepositIntoExistingInstruction { let mint_info: MintInfo = account_loader.load(&mint_info).await.unwrap(); let health_check_metas = derive_health_check_remaining_account_metas( - &account_loader, + account_loader, &account, Some(mint_info.banks[self.bank_index]), false, @@ -1030,7 +1025,7 @@ impl ClientInstruction for TokenRegisterInstruction { type Instruction = mango_v4::instruction::TokenRegister; async fn to_instruction( &self, - _account_loader: impl ClientAccountLoader + 'async_trait, + _account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -1150,7 +1145,7 @@ impl ClientInstruction for TokenAddBankInstruction { type Instruction = mango_v4::instruction::TokenAddBank; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -1239,7 +1234,7 @@ impl ClientInstruction for TokenDeregisterInstruction { async fn to_instruction( &self, - _loader: impl ClientAccountLoader + 'async_trait, + _loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction {}; @@ -1345,7 +1340,7 @@ impl ClientInstruction for TokenEdit { type Instruction = mango_v4::instruction::TokenEdit; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); @@ -1401,7 +1396,7 @@ impl ClientInstruction for TokenEditWeights { type Instruction = mango_v4::instruction::TokenEdit; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); @@ -1460,7 +1455,7 @@ impl ClientInstruction for TokenResetStablePriceModel { type Instruction = mango_v4::instruction::TokenEdit; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); @@ -1520,7 +1515,7 @@ impl ClientInstruction for TokenResetNetBorrows { type Instruction = mango_v4::instruction::TokenEdit; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); @@ -1581,7 +1576,7 @@ impl ClientInstruction for TokenMakeReduceOnly { type Instruction = mango_v4::instruction::TokenEdit; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); @@ -1640,7 +1635,7 @@ impl ClientInstruction for StubOracleSetInstruction { async fn to_instruction( &self, - _loader: impl ClientAccountLoader + 'async_trait, + _loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -1678,7 +1673,7 @@ impl ClientInstruction for StubOracleSetTestInstruction { async fn to_instruction( &self, - _loader: impl ClientAccountLoader + 'async_trait, + _loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -1716,7 +1711,7 @@ impl ClientInstruction for StubOracleCreate { async fn to_instruction( &self, - _loader: impl ClientAccountLoader + 'async_trait, + _loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -1755,7 +1750,7 @@ impl ClientInstruction for StubOracleCloseInstruction { async fn to_instruction( &self, - _loader: impl ClientAccountLoader + 'async_trait, + _loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction {}; @@ -1788,7 +1783,7 @@ impl ClientInstruction for GroupCreateInstruction { type Instruction = mango_v4::instruction::GroupCreate; async fn to_instruction( &self, - _account_loader: impl ClientAccountLoader + 'async_trait, + _account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -1864,7 +1859,7 @@ impl ClientInstruction for GroupEditFeeParameters { type Instruction = mango_v4::instruction::GroupEdit; async fn to_instruction( &self, - _account_loader: impl ClientAccountLoader + 'async_trait, + _account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -1900,7 +1895,7 @@ impl ClientInstruction for GroupEdit { type Instruction = mango_v4::instruction::GroupEdit; async fn to_instruction( &self, - _account_loader: impl ClientAccountLoader + 'async_trait, + _account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = &self.options; @@ -1930,7 +1925,7 @@ impl ClientInstruction for IxGateSetInstruction { type Instruction = mango_v4::instruction::IxGateSet; async fn to_instruction( &self, - _account_loader: impl ClientAccountLoader + 'async_trait, + _account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -1962,7 +1957,7 @@ impl ClientInstruction for GroupCloseInstruction { type Instruction = mango_v4::instruction::GroupClose; async fn to_instruction( &self, - _account_loader: impl ClientAccountLoader + 'async_trait, + _account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction {}; @@ -2022,7 +2017,7 @@ impl ClientInstruction for AccountCreateInstruction { type Instruction = mango_v4::instruction::AccountCreateV2; async fn to_instruction( &self, - _account_loader: impl ClientAccountLoader + 'async_trait, + _account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -2081,7 +2076,7 @@ impl ClientInstruction for AccountExpandInstruction { type Instruction = mango_v4::instruction::AccountExpandV2; async fn to_instruction( &self, - _account_loader: impl ClientAccountLoader + 'async_trait, + _account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -2131,7 +2126,7 @@ impl ClientInstruction for AccountSizeMigrationInstruction { type Instruction = mango_v4::instruction::AccountSizeMigration; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction {}; @@ -2170,7 +2165,7 @@ impl ClientInstruction for AccountEditInstruction { type Instruction = mango_v4::instruction::AccountEdit; async fn to_instruction( &self, - _account_loader: impl ClientAccountLoader + 'async_trait, + _account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = mango_v4::instruction::AccountEdit { @@ -2218,7 +2213,7 @@ impl ClientInstruction for AccountCloseInstruction { type Instruction = mango_v4::instruction::AccountClose; async fn to_instruction( &self, - _account_loader: impl ClientAccountLoader + 'async_trait, + _account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { force_close: false }; @@ -2252,7 +2247,7 @@ impl ClientInstruction for AccountBuybackFeesWithMngo { type Instruction = mango_v4::instruction::AccountBuybackFeesWithMngo; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -2308,7 +2303,7 @@ impl ClientInstruction for Serum3RegisterMarketInstruction { type Instruction = mango_v4::instruction::Serum3RegisterMarket; async fn to_instruction( &self, - _account_loader: impl ClientAccountLoader + 'async_trait, + _account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -2380,7 +2375,7 @@ impl ClientInstruction for Serum3EditMarketInstruction { type Instruction = mango_v4::instruction::Serum3EditMarket; async fn to_instruction( &self, - _account_loader: impl ClientAccountLoader + 'async_trait, + _account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); @@ -2411,7 +2406,7 @@ impl ClientInstruction for Serum3DeregisterMarketInstruction { type Instruction = mango_v4::instruction::Serum3DeregisterMarket; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction {}; @@ -2467,7 +2462,7 @@ impl ClientInstruction for Serum3CreateOpenOrdersInstruction { type Instruction = mango_v4::instruction::Serum3CreateOpenOrders; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction {}; @@ -2518,7 +2513,7 @@ impl ClientInstruction for Serum3CloseOpenOrdersInstruction { type Instruction = mango_v4::instruction::Serum3CloseOpenOrders; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction {}; @@ -2576,7 +2571,7 @@ impl ClientInstruction for Serum3PlaceOrderInstruction { type Instruction = mango_v4::instruction::Serum3PlaceOrderV2; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -2600,10 +2595,10 @@ impl ClientInstruction for Serum3PlaceOrderInstruction { .unwrap() .open_orders; let quote_info = - get_mint_info_by_token_index(&account_loader, &account, serum_market.quote_token_index) + get_mint_info_by_token_index(account_loader, &account, serum_market.quote_token_index) .await; let base_info = - get_mint_info_by_token_index(&account_loader, &account, serum_market.base_token_index) + get_mint_info_by_token_index(account_loader, &account, serum_market.base_token_index) .await; let market_external_bytes = account_loader @@ -2628,7 +2623,7 @@ impl ClientInstruction for Serum3PlaceOrderInstruction { .unwrap(); let mut health_check_metas = derive_health_check_remaining_account_metas( - &account_loader, + account_loader, &account, None, false, @@ -2694,7 +2689,7 @@ impl ClientInstruction for Serum3CancelOrderInstruction { type Instruction = mango_v4::instruction::Serum3CancelOrder; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -2760,7 +2755,7 @@ impl ClientInstruction for Serum3CancelOrderByClientOrderIdInstruction { type Instruction = mango_v4::instruction::Serum3CancelOrderByClientOrderId; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -2823,7 +2818,7 @@ impl ClientInstruction for Serum3CancelAllOrdersInstruction { type Instruction = mango_v4::instruction::Serum3CancelAllOrders; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { limit: self.limit }; @@ -2885,7 +2880,7 @@ impl ClientInstruction for Serum3SettleFundsV2Instruction { type Instruction = mango_v4::instruction::Serum3SettleFundsV2; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -2902,10 +2897,10 @@ impl ClientInstruction for Serum3SettleFundsV2Instruction { .unwrap() .open_orders; let quote_info = - get_mint_info_by_token_index(&account_loader, &account, serum_market.quote_token_index) + get_mint_info_by_token_index(account_loader, &account, serum_market.quote_token_index) .await; let base_info = - get_mint_info_by_token_index(&account_loader, &account, serum_market.base_token_index) + get_mint_info_by_token_index(account_loader, &account, serum_market.base_token_index) .await; let market_external_bytes = account_loader @@ -2969,7 +2964,7 @@ impl ClientInstruction for Serum3LiqForceCancelOrdersInstruction { type Instruction = mango_v4::instruction::Serum3LiqForceCancelOrders; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { limit: self.limit }; @@ -2984,10 +2979,10 @@ impl ClientInstruction for Serum3LiqForceCancelOrdersInstruction { .unwrap() .open_orders; let quote_info = - get_mint_info_by_token_index(&account_loader, &account, serum_market.quote_token_index) + get_mint_info_by_token_index(account_loader, &account, serum_market.quote_token_index) .await; let base_info = - get_mint_info_by_token_index(&account_loader, &account, serum_market.base_token_index) + get_mint_info_by_token_index(account_loader, &account, serum_market.base_token_index) .await; let market_external_bytes = account_loader @@ -3011,7 +3006,7 @@ impl ClientInstruction for Serum3LiqForceCancelOrdersInstruction { .unwrap(); let health_check_metas = derive_health_check_remaining_account_metas( - &account_loader, + account_loader, &account, None, false, @@ -3067,7 +3062,7 @@ impl ClientInstruction for TokenForceCloseBorrowsWithTokenInstruction { type Instruction = mango_v4::instruction::TokenForceCloseBorrowsWithToken; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -3085,7 +3080,7 @@ impl ClientInstruction for TokenForceCloseBorrowsWithTokenInstruction { .await .unwrap(); let health_check_metas = derive_liquidation_remaining_account_metas( - &account_loader, + account_loader, &liqee, &liqor, self.asset_token_index, @@ -3124,7 +3119,7 @@ impl ClientInstruction for TokenForceWithdrawInstruction { type Instruction = mango_v4::instruction::TokenForceWithdraw; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction {}; @@ -3135,7 +3130,7 @@ impl ClientInstruction for TokenForceWithdrawInstruction { .unwrap(); let bank = account_loader.load::(&self.bank).await.unwrap(); let health_check_metas = derive_health_check_remaining_account_metas( - &account_loader, + account_loader, &account, None, false, @@ -3182,7 +3177,7 @@ impl ClientInstruction for TokenLiqWithTokenInstruction { type Instruction = mango_v4::instruction::TokenLiqWithToken; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -3200,7 +3195,7 @@ impl ClientInstruction for TokenLiqWithTokenInstruction { .await .unwrap(); let health_check_metas = derive_liquidation_remaining_account_metas( - &account_loader, + account_loader, &liqee, &liqor, self.asset_token_index, @@ -3242,7 +3237,7 @@ impl ClientInstruction for TokenLiqBankruptcyInstruction { type Instruction = mango_v4::instruction::TokenLiqBankruptcy; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -3259,7 +3254,7 @@ impl ClientInstruction for TokenLiqBankruptcyInstruction { .await .unwrap(); let health_check_metas = derive_liquidation_remaining_account_metas( - &account_loader, + account_loader, &liqee, &liqor, QUOTE_TOKEN_INDEX, @@ -3381,7 +3376,7 @@ impl ClientInstruction for PerpCreateMarketInstruction { type Instruction = mango_v4::instruction::PerpCreateMarket; async fn to_instruction( &self, - _loader: impl ClientAccountLoader + 'async_trait, + _loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -3497,7 +3492,7 @@ impl ClientInstruction for PerpResetStablePriceModel { type Instruction = mango_v4::instruction::PerpEditMarket; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); @@ -3537,7 +3532,7 @@ impl ClientInstruction for PerpSetSettleLimitWindow { type Instruction = mango_v4::instruction::PerpEditMarket; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); @@ -3578,7 +3573,7 @@ impl ClientInstruction for PerpMakeReduceOnly { type Instruction = mango_v4::instruction::PerpEditMarket; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); @@ -3620,7 +3615,7 @@ impl ClientInstruction for PerpChangeWeights { type Instruction = mango_v4::instruction::PerpEditMarket; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); @@ -3659,7 +3654,7 @@ impl ClientInstruction for PerpCloseMarketInstruction { type Instruction = mango_v4::instruction::PerpCloseMarket; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction {}; @@ -3696,7 +3691,7 @@ impl ClientInstruction for PerpDeactivatePositionInstruction { type Instruction = mango_v4::instruction::PerpDeactivatePosition; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let perp_market: PerpMarket = account_loader.load(&self.perp_market).await.unwrap(); @@ -3754,7 +3749,7 @@ impl ClientInstruction for PerpPlaceOrderInstruction { type Instruction = mango_v4::instruction::PerpPlaceOrderV2; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -3776,7 +3771,7 @@ impl ClientInstruction for PerpPlaceOrderInstruction { .await .unwrap(); let health_check_metas = derive_health_check_remaining_account_metas( - &account_loader, + account_loader, &account, None, false, @@ -3822,7 +3817,7 @@ impl ClientInstruction for PerpPlaceOrderPeggedInstruction { type Instruction = mango_v4::instruction::PerpPlaceOrderPeggedV2; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -3846,7 +3841,7 @@ impl ClientInstruction for PerpPlaceOrderPeggedInstruction { .await .unwrap(); let health_check_metas = derive_health_check_remaining_account_metas( - &account_loader, + account_loader, &account, None, false, @@ -3887,7 +3882,7 @@ impl ClientInstruction for PerpCancelOrderInstruction { type Instruction = mango_v4::instruction::PerpCancelOrder; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -3924,7 +3919,7 @@ impl ClientInstruction for PerpCancelOrderByClientOrderIdInstruction { type Instruction = mango_v4::instruction::PerpCancelOrderByClientOrderId; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -3961,7 +3956,7 @@ impl ClientInstruction for PerpCancelAllOrdersInstruction { type Instruction = mango_v4::instruction::PerpCancelAllOrders; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { limit: self.limit }; @@ -3994,7 +3989,7 @@ impl ClientInstruction for PerpConsumeEventsInstruction { type Instruction = mango_v4::instruction::PerpConsumeEvents; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { limit: 10 }; @@ -4033,7 +4028,7 @@ impl ClientInstruction for PerpUpdateFundingInstruction { type Instruction = mango_v4::instruction::PerpUpdateFunding; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction {}; @@ -4068,7 +4063,7 @@ impl ClientInstruction for PerpSettlePnlInstruction { type Instruction = mango_v4::instruction::PerpSettlePnl; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction {}; @@ -4083,7 +4078,7 @@ impl ClientInstruction for PerpSettlePnlInstruction { .await .unwrap(); let health_check_metas = derive_liquidation_remaining_account_metas( - &account_loader, + account_loader, &account_a, &account_b, TokenIndex::MAX, @@ -4093,7 +4088,7 @@ impl ClientInstruction for PerpSettlePnlInstruction { ) .await; let settle_mint_info = get_mint_info_by_token_index( - &account_loader, + account_loader, &account_a, perp_market.settle_token_index, ) @@ -4133,7 +4128,7 @@ impl ClientInstruction for PerpForceClosePositionInstruction { type Instruction = mango_v4::instruction::PerpForceClosePosition; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction {}; @@ -4169,7 +4164,7 @@ impl ClientInstruction for PerpSettleFeesInstruction { type Instruction = mango_v4::instruction::PerpSettleFees; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -4182,7 +4177,7 @@ impl ClientInstruction for PerpSettleFeesInstruction { .await .unwrap(); let health_check_metas = derive_health_check_remaining_account_metas( - &account_loader, + account_loader, &account, None, false, @@ -4190,7 +4185,7 @@ impl ClientInstruction for PerpSettleFeesInstruction { ) .await; let settle_mint_info = - get_mint_info_by_token_index(&account_loader, &account, perp_market.settle_token_index) + get_mint_info_by_token_index(account_loader, &account, perp_market.settle_token_index) .await; let accounts = Self::Accounts { @@ -4222,7 +4217,7 @@ impl ClientInstruction for PerpLiqForceCancelOrdersInstruction { type Instruction = mango_v4::instruction::PerpLiqForceCancelOrders; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { limit: 10 }; @@ -4233,7 +4228,7 @@ impl ClientInstruction for PerpLiqForceCancelOrdersInstruction { .await .unwrap(); let health_check_metas = derive_health_check_remaining_account_metas( - &account_loader, + account_loader, &account, None, false, @@ -4273,7 +4268,7 @@ impl ClientInstruction for PerpLiqBaseOrPositivePnlInstruction { type Instruction = mango_v4::instruction::PerpLiqBaseOrPositivePnl; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -4292,7 +4287,7 @@ impl ClientInstruction for PerpLiqBaseOrPositivePnlInstruction { .await .unwrap(); let health_check_metas = derive_liquidation_remaining_account_metas( - &account_loader, + account_loader, &liqee, &liqor, TokenIndex::MAX, @@ -4303,7 +4298,7 @@ impl ClientInstruction for PerpLiqBaseOrPositivePnlInstruction { .await; let settle_mint_info = - get_mint_info_by_token_index(&account_loader, &liqee, perp_market.settle_token_index) + get_mint_info_by_token_index(account_loader, &liqee, perp_market.settle_token_index) .await; let accounts = Self::Accounts { @@ -4341,7 +4336,7 @@ impl ClientInstruction for PerpLiqNegativePnlOrBankruptcyInstruction { type Instruction = mango_v4::instruction::PerpLiqNegativePnlOrBankruptcyV2; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -4359,7 +4354,7 @@ impl ClientInstruction for PerpLiqNegativePnlOrBankruptcyInstruction { .await .unwrap(); let health_check_metas = derive_liquidation_remaining_account_metas( - &account_loader, + account_loader, &liqee, &liqor, TokenIndex::MAX, @@ -4371,10 +4366,10 @@ impl ClientInstruction for PerpLiqNegativePnlOrBankruptcyInstruction { let group = account_loader.load::(&group_key).await.unwrap(); let settle_mint_info = - get_mint_info_by_token_index(&account_loader, &liqee, perp_market.settle_token_index) + get_mint_info_by_token_index(account_loader, &liqee, perp_market.settle_token_index) .await; let insurance_mint_info = - get_mint_info_by_token_index(&account_loader, &liqee, QUOTE_TOKEN_INDEX).await; + get_mint_info_by_token_index(account_loader, &liqee, QUOTE_TOKEN_INDEX).await; let accounts = Self::Accounts { group: group_key, @@ -4410,7 +4405,7 @@ impl ClientInstruction for BenchmarkInstruction { type Instruction = mango_v4::instruction::Benchmark; async fn to_instruction( &self, - _loader: impl ClientAccountLoader + 'async_trait, + _loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction {}; @@ -4435,7 +4430,7 @@ impl ClientInstruction for TokenUpdateIndexAndRateInstruction { type Instruction = mango_v4::instruction::TokenUpdateIndexAndRate; async fn to_instruction( &self, - loader: impl ClientAccountLoader + 'async_trait, + loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction {}; @@ -4478,7 +4473,7 @@ impl ClientInstruction for ComputeAccountDataInstruction { type Instruction = mango_v4::instruction::ComputeAccountData; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction {}; @@ -4489,7 +4484,7 @@ impl ClientInstruction for ComputeAccountDataInstruction { .unwrap(); let health_check_metas = derive_health_check_remaining_account_metas( - &account_loader, + account_loader, &account, None, false, @@ -4522,7 +4517,7 @@ impl ClientInstruction for HealthRegionBeginInstruction { type Instruction = mango_v4::instruction::HealthRegionBegin; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction {}; @@ -4533,7 +4528,7 @@ impl ClientInstruction for HealthRegionBeginInstruction { .unwrap(); let health_check_metas = derive_health_check_remaining_account_metas( - &account_loader, + account_loader, &account, None, false, @@ -4568,7 +4563,7 @@ impl ClientInstruction for HealthRegionEndInstruction { type Instruction = mango_v4::instruction::HealthRegionEnd; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction {}; @@ -4579,7 +4574,7 @@ impl ClientInstruction for HealthRegionEndInstruction { .unwrap(); let health_check_metas = derive_health_check_remaining_account_metas( - &account_loader, + account_loader, &account, self.affected_bank, false, @@ -4614,7 +4609,7 @@ impl ClientInstruction for AltSetInstruction { type Instruction = mango_v4::instruction::AltSet; async fn to_instruction( &self, - _account_loader: impl ClientAccountLoader + 'async_trait, + _account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { index: self.index }; @@ -4648,7 +4643,7 @@ impl ClientInstruction for AltExtendInstruction { type Instruction = mango_v4::instruction::AltExtend; async fn to_instruction( &self, - _account_loader: impl ClientAccountLoader + 'async_trait, + _account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -4692,7 +4687,7 @@ impl ClientInstruction for TokenConditionalSwapCreateInstruction { type Instruction = mango_v4::instruction::TokenConditionalSwapCreateV2; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -4773,7 +4768,7 @@ impl ClientInstruction for TokenConditionalSwapCreateLinearAuctionInstruction { type Instruction = mango_v4::instruction::TokenConditionalSwapCreateLinearAuction; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -4853,7 +4848,7 @@ impl ClientInstruction for TokenConditionalSwapCreatePremiumAuctionInstruction { type Instruction = mango_v4::instruction::TokenConditionalSwapCreatePremiumAuction; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -4926,7 +4921,7 @@ impl ClientInstruction for TokenConditionalSwapCancelInstruction { type Instruction = mango_v4::instruction::TokenConditionalSwapCancel; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); let instruction = Self::Instruction { @@ -4941,9 +4936,9 @@ impl ClientInstruction for TokenConditionalSwapCancelInstruction { let tcs = account.token_conditional_swap_by_id(self.id).unwrap().1; let buy_mint_info = - get_mint_info_by_token_index(&account_loader, &account, tcs.buy_token_index).await; + get_mint_info_by_token_index(account_loader, &account, tcs.buy_token_index).await; let sell_mint_info = - get_mint_info_by_token_index(&account_loader, &account, tcs.sell_token_index).await; + get_mint_info_by_token_index(account_loader, &account, tcs.sell_token_index).await; let accounts = Self::Accounts { group: account.fixed.group, @@ -4979,7 +4974,7 @@ impl ClientInstruction for TokenConditionalSwapTriggerInstruction { type Instruction = mango_v4::instruction::TokenConditionalSwapTriggerV2; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); @@ -5007,7 +5002,7 @@ impl ClientInstruction for TokenConditionalSwapTriggerInstruction { }; let health_check_metas = derive_liquidation_remaining_account_metas( - &account_loader, + account_loader, &liqee, &liqor, tcs.buy_token_index, @@ -5047,7 +5042,7 @@ impl ClientInstruction for TokenConditionalSwapStartInstruction { type Instruction = mango_v4::instruction::TokenConditionalSwapStart; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); @@ -5062,7 +5057,7 @@ impl ClientInstruction for TokenConditionalSwapStartInstruction { .clone(); let sell_mint_info = - get_mint_info_by_token_index(&account_loader, &liqee, tcs.sell_token_index).await; + get_mint_info_by_token_index(account_loader, &liqee, tcs.sell_token_index).await; let instruction = Self::Instruction { token_conditional_swap_index: self.index, @@ -5070,7 +5065,7 @@ impl ClientInstruction for TokenConditionalSwapStartInstruction { }; let health_check_metas = derive_health_check_remaining_account_metas( - &account_loader, + account_loader, &liqee, Some(sell_mint_info.first_bank()), true, @@ -5105,7 +5100,7 @@ impl ClientInstruction for TokenChargeCollateralFeesInstruction { type Instruction = mango_v4::instruction::TokenChargeCollateralFees; async fn to_instruction( &self, - account_loader: impl ClientAccountLoader + 'async_trait, + account_loader: &(impl ClientAccountLoader + 'async_trait), ) -> (Self::Accounts, instruction::Instruction) { let program_id = mango_v4::id(); @@ -5116,14 +5111,9 @@ impl ClientInstruction for TokenChargeCollateralFeesInstruction { let instruction = Self::Instruction {}; - let health_check_metas = derive_health_check_remaining_account_metas( - &account_loader, - &account, - None, - true, - None, - ) - .await; + 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, @@ -5139,3 +5129,131 @@ impl ClientInstruction for TokenChargeCollateralFeesInstruction { vec![] } } + +#[derive(Clone)] +pub struct HealthAccountSkipping { + pub inner: T, + pub skip_banks: Vec, +} +#[async_trait::async_trait(?Send)] +impl ClientInstruction for HealthAccountSkipping { + type Accounts = T::Accounts; + type Instruction = T::Instruction; + async fn to_instruction( + &self, + account_loader: &(impl ClientAccountLoader + 'async_trait), + ) -> (Self::Accounts, instruction::Instruction) { + let (accounts, mut instruction) = self.inner.to_instruction(account_loader).await; + + let ams = &mut instruction.accounts; + for bank_pk in &self.skip_banks { + let bank_pos = + ams.len() - 1 - ams.iter().rev().position(|m| m.pubkey == *bank_pk).unwrap(); + ams.remove(bank_pos); + + let bank = account_loader.load::(&bank_pk).await.unwrap(); + let oracle_pk = bank.oracle; + + let oracle_pos = bank_pos + + ams[bank_pos..] + .iter() + .position(|m| m.pubkey == oracle_pk) + .unwrap(); + ams.remove(oracle_pos); + } + + (accounts, instruction) + } + + fn signers(&self) -> Vec { + self.inner.signers() + } +} + +#[derive(Default)] +pub struct SequenceCheckInstruction { + pub account: Pubkey, + pub owner: TestKeypair, + pub expected_sequence_number: u8, +} +#[async_trait::async_trait(?Send)] +impl ClientInstruction for SequenceCheckInstruction { + type Accounts = mango_v4::accounts::SequenceCheck; + type Instruction = mango_v4::instruction::SequenceCheck; + 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 { + expected_sequence_number: self.expected_sequence_number, + }; + + let account = account_loader + .load_mango_account(&self.account) + .await + .unwrap(); + + let accounts = Self::Accounts { + group: account.fixed.group, + account: self.account, + owner: self.owner.pubkey(), + }; + + let instruction = make_instruction(program_id, &accounts, &instruction); + (accounts, instruction) + } + + fn signers(&self) -> Vec { + vec![self.owner] + } +} + +pub struct HealthCheckInstruction { + pub account: Pubkey, + pub owner: TestKeypair, + pub min_health_value: f64, + pub check_kind: HealthCheckKind, +} +#[async_trait::async_trait(?Send)] +impl ClientInstruction for HealthCheckInstruction { + type Accounts = mango_v4::accounts::HealthCheck; + type Instruction = mango_v4::instruction::HealthCheck; + 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 { + min_health_value: self.min_health_value, + check_kind: self.check_kind, + }; + + let account = account_loader + .load_mango_account(&self.account) + .await + .unwrap(); + + let accounts = Self::Accounts { + group: account.fixed.group, + account: self.account, + }; + + let health_check_metas = derive_health_check_remaining_account_metas( + account_loader, + &account, + None, + false, + None, + ) + .await; + + 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/programs/mango-v4/tests/program_test/utils.rs b/programs/mango-v4/tests/program_test/utils.rs index d774f541f..a42ae133e 100644 --- a/programs/mango-v4/tests/program_test/utils.rs +++ b/programs/mango-v4/tests/program_test/utils.rs @@ -1,7 +1,6 @@ #![allow(dead_code)] use bytemuck::{bytes_of, Contiguous}; -use fixed::types::I80F48; use solana_program::instruction::InstructionError; use solana_program::program_error::ProgramError; use solana_sdk::pubkey::Pubkey; @@ -97,18 +96,22 @@ pub fn assert_mango_error( } } -pub fn assert_equal_fixed_f64(value: I80F48, expected: f64, max_error: f64) -> bool { - let ok = (value.to_num::() - expected).abs() < max_error; - if !ok { - println!("comparison failed: value: {value}, expected: {expected}"); - } - ok +#[macro_export] +macro_rules! assert_eq_f64 { + ($value:expr, $expected:expr, $max_error:expr $(,)?) => { + let value = $value; + let expected = $expected; + let ok = (value - expected).abs() < $max_error; + if !ok { + println!("comparison failed: value: {value}, expected: {expected}"); + } + assert!(ok); + }; } -pub fn assert_equal_f64_f64(value: f64, expected: f64, max_error: f64) -> bool { - let ok = (value - expected).abs() < max_error; - if !ok { - println!("comparison failed: value: {value}, expected: {expected}"); - } - ok +#[macro_export] +macro_rules! assert_eq_fixed_f64 { + ($value:expr, $expected:expr, $max_error:expr $(,)?) => { + assert_eq_f64!($value.to_num::(), $expected, $max_error); + }; } diff --git a/ts/client/scripts/liqtest/README.md b/ts/client/scripts/liqtest/README.md new file mode 100644 index 000000000..f1889404d --- /dev/null +++ b/ts/client/scripts/liqtest/README.md @@ -0,0 +1,67 @@ +### Set environment variables + +``` +CLUSTER=devnet +CLUSTER_URL=https://mango.devnet.rpcpool.com/ +PAYER_KEYPAIR=~/.config/solana/mb-liqtest.json +# Adjust this to a free group +GROUP_NUM=200 +``` + +### Get devnet SOL + +The scripts need a lot of SOL for mint, market, group and account creation. +There's ample available, best to ask around. + +### Create tokens and markets + +This is one-time setup: + +``` +yarn ts-node ts/client/scripts/liqtest/liqtest-create-tokens-and-markets.ts +``` + +It'll emit some MINTS=... and SERUM_MARKETS=.. env vars, set those, all further +commands will use them. + +### Make a group + +``` +yarn ts-node ts/client/scripts/liqtest/liqtest-create-group.ts +``` + +Groups can be reused a lot, but sometimes closing them may be necessary + +``` +yarn ts-node ts/client/scripts/liqtest/liqtest-close-group.ts +``` + +Preferably close all mango accounts first. + +### Create candidate mango accounts + +``` +yarn ts-node ts/client/scripts/liqtest/liqtest-make-candidates.ts +``` + +This creates a bunch of to-be-liquidated accounts as well as a LIQOR account. + +### Liquidate + +Run the liquidator on the group with the liqor account. + +Since devnet doesn't have any jupiter, run with + +``` +JUPITER_VERSION=mock +TCS_MODE=borrow-buy +REBALANCE=false +``` + +### Settle and close all open mango accounts + +At any point, to reset by closing all accounts: + +``` +yarn ts-node ts/client/scripts/liqtest/liqtest-settle-and-close-all.ts +``` diff --git a/ts/client/scripts/liqtest/liqtest-close-group.ts b/ts/client/scripts/liqtest/liqtest-close-group.ts index c1d869e7b..457643946 100644 --- a/ts/client/scripts/liqtest/liqtest-close-group.ts +++ b/ts/client/scripts/liqtest/liqtest-close-group.ts @@ -51,7 +51,7 @@ async function main() { market.serumMarketExternal, ); console.log( - `Deregistered serum market ${market.name}, sig https://explorer.solana.com/tx/${sig}`, + `Deregistered serum market ${market.name}, sig https://explorer.solana.com/tx/${sig.signature}`, ); } @@ -59,7 +59,7 @@ async function main() { for (const market of group.perpMarketsMapByMarketIndex.values()) { sig = await client.perpCloseMarket(group, market.perpMarketIndex); console.log( - `Closed perp market ${market.name}, sig https://explorer.solana.com/tx/${sig}`, + `Closed perp market ${market.name}, sig https://explorer.solana.com/tx/${sig.signature}`, ); } @@ -67,7 +67,7 @@ async function main() { for (const banks of group.banksMapByMint.values()) { sig = await client.tokenDeregister(group, banks[0].mint); console.log( - `Removed token ${banks[0].name}, sig https://explorer.solana.com/tx/${sig}`, + `Removed token ${banks[0].name}, sig https://explorer.solana.com/tx/${sig.signature}`, ); } @@ -76,13 +76,15 @@ async function main() { for (const stubOracle of stubOracles) { sig = await client.stubOracleClose(group, stubOracle.publicKey); console.log( - `Closed stub oracle ${stubOracle.publicKey}, sig https://explorer.solana.com/tx/${sig}`, + `Closed stub oracle ${stubOracle.publicKey}, sig https://explorer.solana.com/tx/${sig.signature}`, ); } // finally, close the group sig = await client.groupClose(group); - console.log(`Closed group, sig https://explorer.solana.com/tx/${sig}`); + console.log( + `Closed group, sig https://explorer.solana.com/tx/${sig.signature}`, + ); } process.exit(); diff --git a/ts/client/scripts/liqtest/liqtest-create-group.ts b/ts/client/scripts/liqtest/liqtest-create-group.ts index e574d0226..804fd389c 100644 --- a/ts/client/scripts/liqtest/liqtest-create-group.ts +++ b/ts/client/scripts/liqtest/liqtest-create-group.ts @@ -330,12 +330,12 @@ async function createAndPopulateAlt( }); let sig = await client.sendAndConfirmTransaction([createIx[0]]); console.log( - `...created ALT ${createIx[1]} https://explorer.solana.com/tx/${sig}`, + `...created ALT ${createIx[1]} https://explorer.solana.com/tx/${sig.signature}`, ); console.log(`ALT: set at index 0 for group...`); sig = await client.altSet(group, createIx[1], 0); - console.log(`...https://explorer.solana.com/tx/${sig}`); + console.log(`...https://explorer.solana.com/tx/${sig.signature}`); group = await client.getGroupForCreator(admin.publicKey, GROUP_NUM); } catch (error) { @@ -366,7 +366,7 @@ async function createAndPopulateAlt( addresses, }); const sig = await client.sendAndConfirmTransaction([extendIx]); - console.log(`https://explorer.solana.com/tx/${sig}`); + console.log(`https://explorer.solana.com/tx/${sig.signature}`); } // Extend using mango v4 relevant pub keys diff --git a/ts/client/src/accounts/mangoAccount.spec.ts b/ts/client/src/accounts/mangoAccount.spec.ts index 9da5c319c..56bcf9be9 100644 --- a/ts/client/src/accounts/mangoAccount.spec.ts +++ b/ts/client/src/accounts/mangoAccount.spec.ts @@ -1,18 +1,12 @@ import { PublicKey } from '@solana/web3.js'; -import { - HealthType, - MangoAccount, - TokenPosition, - TokenPositionDto, -} from './mangoAccount'; import BN from 'bn.js'; -import { Bank, TokenIndex } from './bank'; -import { deepClone, toNative, toUiDecimals } from '../utils'; import { expect } from 'chai'; -import { I80F48, I80F48Dto, ONE_I80F48, ZERO_I80F48 } from '../numbers/I80F48'; -import { Group } from './group'; -import { HealthCache } from './healthCache'; import { assert } from 'console'; +import { I80F48, ONE_I80F48, ZERO_I80F48 } from '../numbers/I80F48'; +import { deepClone, toNative, toUiDecimals } from '../utils'; +import { Bank, TokenIndex } from './bank'; +import { Group } from './group'; +import { MangoAccount, TokenPosition } from './mangoAccount'; describe('Mango Account', () => { const mangoAccount = new MangoAccount( @@ -32,6 +26,7 @@ describe('Mango Account', () => { new BN(0), new BN(0), 0, + 0, new BN(0), [], [], @@ -106,6 +101,7 @@ describe('maxWithdraw', () => { new BN(0), new BN(0), 0, + 0, new BN(0), [], [], diff --git a/ts/client/src/accounts/mangoAccount.ts b/ts/client/src/accounts/mangoAccount.ts index c75c6a89d..948da0089 100644 --- a/ts/client/src/accounts/mangoAccount.ts +++ b/ts/client/src/accounts/mangoAccount.ts @@ -51,6 +51,7 @@ export class MangoAccount { buybackFeesAccruedCurrent: BN; buybackFeesAccruedPrevious: BN; buybackFeesExpiryTimestamp: BN; + sequenceNumber: number; headerVersion: number; tokens: unknown; serum3: unknown; @@ -76,6 +77,7 @@ export class MangoAccount { obj.buybackFeesAccruedCurrent, obj.buybackFeesAccruedPrevious, obj.buybackFeesExpiryTimestamp, + obj.sequenceNumber, obj.headerVersion, obj.lastCollateralFeeCharge, obj.tokens as TokenPositionDto[], @@ -103,6 +105,7 @@ export class MangoAccount { public buybackFeesAccruedCurrent: BN, public buybackFeesAccruedPrevious: BN, public buybackFeesExpiryTimestamp: BN, + public sequenceNumber: number, public headerVersion: number, public lastCollateralFeeCharge: BN, tokens: TokenPositionDto[], diff --git a/ts/client/src/client.ts b/ts/client/src/client.ts index d8721d0b6..51a4040af 100644 --- a/ts/client/src/client.ts +++ b/ts/client/src/client.ts @@ -83,7 +83,7 @@ import { import { Id } from './ids'; import { IDL, MangoV4 } from './mango_v4'; import { I80F48 } from './numbers/I80F48'; -import { FlashLoanType, OracleConfigParams } from './types'; +import { FlashLoanType, HealthCheckKind, OracleConfigParams } from './types'; import { I64_MAX_BN, U64_MAX_BN, @@ -1056,6 +1056,50 @@ export class MangoClient { return await this.sendAndConfirmTransactionForGroup(group, [ix]); } + public async sequenceCheckIx( + group: Group, + mangoAccount: MangoAccount, + ): Promise { + return await this.program.methods + .sequenceCheck(mangoAccount.sequenceNumber) + .accounts({ + group: group.publicKey, + account: mangoAccount.publicKey, + owner: (this.program.provider as AnchorProvider).wallet.publicKey, + }) + .instruction(); + } + + public async healthCheckIx( + group: Group, + mangoAccount: MangoAccount, + minHealthValue: number, + checkKind: HealthCheckKind, + ): Promise { + const healthRemainingAccounts: PublicKey[] = + await this.buildHealthRemainingAccounts( + group, + [mangoAccount], + [], + [], + [], + ); + + return await this.program.methods + .healthCheck(minHealthValue, checkKind) + .accounts({ + group: group.publicKey, + account: mangoAccount.publicKey, + }) + .remainingAccounts( + healthRemainingAccounts.map( + (pk) => + ({ pubkey: pk, isWritable: false, isSigner: false } as AccountMeta), + ), + ) + .instruction(); + } + public async getMangoAccount( mangoAccountPk: PublicKey, loadSerum3Oo = false, diff --git a/ts/client/src/clientIxParamBuilder.ts b/ts/client/src/clientIxParamBuilder.ts index 0074f7aa6..ddfb1dd51 100644 --- a/ts/client/src/clientIxParamBuilder.ts +++ b/ts/client/src/clientIxParamBuilder.ts @@ -310,6 +310,8 @@ export interface IxGateParams { TokenConditionalSwapCreateLinearAuction: boolean; Serum3PlaceOrderV2: boolean; TokenForceWithdraw: boolean; + SequenceCheck: boolean; + HealthCheck: boolean; } // Default with all ixs enabled, use with buildIxGate @@ -390,6 +392,8 @@ export const TrueIxGateParams: IxGateParams = { TokenConditionalSwapCreateLinearAuction: true, Serum3PlaceOrderV2: true, TokenForceWithdraw: true, + SequenceCheck: true, + HealthCheck: true, }; // build ix gate e.g. buildIxGate(Builder(TrueIxGateParams).TokenDeposit(false).build()).toNumber(), @@ -480,6 +484,8 @@ export function buildIxGate(p: IxGateParams): BN { toggleIx(ixGate, p, 'TokenConditionalSwapCreateLinearAuction', 70); toggleIx(ixGate, p, 'Serum3PlaceOrderV2', 71); toggleIx(ixGate, p, 'TokenForceWithdraw', 72); + toggleIx(ixGate, p, 'SequenceCheck', 73); + toggleIx(ixGate, p, 'HealthCheck', 74); return ixGate; } diff --git a/ts/client/src/mango_v4.ts b/ts/client/src/mango_v4.ts index 44fce5b9a..f294f383b 100644 --- a/ts/client/src/mango_v4.ts +++ b/ts/client/src/mango_v4.ts @@ -1,5 +1,5 @@ export type MangoV4 = { - "version": "0.23.0", + "version": "0.24.0", "name": "mango_v4", "instructions": [ { @@ -1760,6 +1760,66 @@ export type MangoV4 = { } ] }, + { + "name": "sequenceCheck", + "accounts": [ + { + "name": "group", + "isMut": false, + "isSigner": false + }, + { + "name": "account", + "isMut": true, + "isSigner": false, + "relations": [ + "group", + "owner" + ] + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + } + ], + "args": [ + { + "name": "expectedSequenceNumber", + "type": "u8" + } + ] + }, + { + "name": "healthCheck", + "accounts": [ + { + "name": "group", + "isMut": false, + "isSigner": false + }, + { + "name": "account", + "isMut": true, + "isSigner": false, + "relations": [ + "group" + ] + } + ], + "args": [ + { + "name": "minHealthValue", + "type": "f64" + }, + { + "name": "checkKind", + "type": { + "defined": "HealthCheckKind" + } + } + ] + }, { "name": "stubOracleCreate", "accounts": [ @@ -7871,13 +7931,8 @@ export type MangoV4 = { "type": "u8" }, { - "name": "padding", - "type": { - "array": [ - "u8", - 1 - ] - } + "name": "sequenceNumber", + "type": "u8" }, { "name": "netDeposits", @@ -9669,13 +9724,8 @@ export type MangoV4 = { "type": "u8" }, { - "name": "padding", - "type": { - "array": [ - "u8", - 1 - ] - } + "name": "sequenceNumber", + "type": "u8" }, { "name": "netDeposits", @@ -10654,6 +10704,32 @@ export type MangoV4 = { ] } }, + { + "name": "HealthCheckKind", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Maint" + }, + { + "name": "Init" + }, + { + "name": "LiquidationEnd" + }, + { + "name": "MaintRatio" + }, + { + "name": "InitRatio" + }, + { + "name": "LiquidationEndRatio" + } + ] + } + }, { "name": "Serum3SelfTradeBehavior", "docs": [ @@ -11008,6 +11084,12 @@ export type MangoV4 = { }, { "name": "TokenForceWithdraw" + }, + { + "name": "SequenceCheck" + }, + { + "name": "HealthCheck" } ] } @@ -11048,6 +11130,9 @@ export type MangoV4 = { }, { "name": "OrcaCLMM" + }, + { + "name": "RaydiumCLMM" } ] } @@ -14347,12 +14432,27 @@ export type MangoV4 = { "code": 6069, "name": "TokenAssetLiquidationDisabled", "msg": "the asset does not allow liquidation" + }, + { + "code": 6070, + "name": "BorrowsRequireHealthAccountBank", + "msg": "for borrows the bank must be in the health account list" + }, + { + "code": 6071, + "name": "InvalidSequenceNumber", + "msg": "invalid sequence number" + }, + { + "code": 6072, + "name": "InvalidHealth", + "msg": "invalid health" } ] }; export const IDL: MangoV4 = { - "version": "0.23.0", + "version": "0.24.0", "name": "mango_v4", "instructions": [ { @@ -16113,6 +16213,66 @@ export const IDL: MangoV4 = { } ] }, + { + "name": "sequenceCheck", + "accounts": [ + { + "name": "group", + "isMut": false, + "isSigner": false + }, + { + "name": "account", + "isMut": true, + "isSigner": false, + "relations": [ + "group", + "owner" + ] + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + } + ], + "args": [ + { + "name": "expectedSequenceNumber", + "type": "u8" + } + ] + }, + { + "name": "healthCheck", + "accounts": [ + { + "name": "group", + "isMut": false, + "isSigner": false + }, + { + "name": "account", + "isMut": true, + "isSigner": false, + "relations": [ + "group" + ] + } + ], + "args": [ + { + "name": "minHealthValue", + "type": "f64" + }, + { + "name": "checkKind", + "type": { + "defined": "HealthCheckKind" + } + } + ] + }, { "name": "stubOracleCreate", "accounts": [ @@ -22224,13 +22384,8 @@ export const IDL: MangoV4 = { "type": "u8" }, { - "name": "padding", - "type": { - "array": [ - "u8", - 1 - ] - } + "name": "sequenceNumber", + "type": "u8" }, { "name": "netDeposits", @@ -24022,13 +24177,8 @@ export const IDL: MangoV4 = { "type": "u8" }, { - "name": "padding", - "type": { - "array": [ - "u8", - 1 - ] - } + "name": "sequenceNumber", + "type": "u8" }, { "name": "netDeposits", @@ -25007,6 +25157,32 @@ export const IDL: MangoV4 = { ] } }, + { + "name": "HealthCheckKind", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Maint" + }, + { + "name": "Init" + }, + { + "name": "LiquidationEnd" + }, + { + "name": "MaintRatio" + }, + { + "name": "InitRatio" + }, + { + "name": "LiquidationEndRatio" + } + ] + } + }, { "name": "Serum3SelfTradeBehavior", "docs": [ @@ -25361,6 +25537,12 @@ export const IDL: MangoV4 = { }, { "name": "TokenForceWithdraw" + }, + { + "name": "SequenceCheck" + }, + { + "name": "HealthCheck" } ] } @@ -25401,6 +25583,9 @@ export const IDL: MangoV4 = { }, { "name": "OrcaCLMM" + }, + { + "name": "RaydiumCLMM" } ] } @@ -28700,6 +28885,21 @@ export const IDL: MangoV4 = { "code": 6069, "name": "TokenAssetLiquidationDisabled", "msg": "the asset does not allow liquidation" + }, + { + "code": 6070, + "name": "BorrowsRequireHealthAccountBank", + "msg": "for borrows the bank must be in the health account list" + }, + { + "code": 6071, + "name": "InvalidSequenceNumber", + "msg": "invalid sequence number" + }, + { + "code": 6072, + "name": "InvalidHealth", + "msg": "invalid health" } ] }; diff --git a/ts/client/src/types.ts b/ts/client/src/types.ts index 47d378477..cce99ae2c 100644 --- a/ts/client/src/types.ts +++ b/ts/client/src/types.ts @@ -18,6 +18,23 @@ export namespace FlashLoanType { export const swapWithoutFee = { swapWithoutFee: {} }; } +export type HealthCheckKind = + | { maint: Record } + | { init: Record } + | { liquidationEnd: Record } + | { maintRatio: Record } + | { initRatio: Record } + | { liquidationEndRatio: Record }; +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace HealthCheckKind { + export const maint = { maint: {} }; + export const init = { init: {} }; + export const liquidationEnd = { liquidationEnd: {} }; + export const maintRatio = { maintRatio: {} }; + export const initRatio = { initRatio: {} }; + export const liquidationEndRatio = { liquidationEndRatio: {} }; +} + export class InterestRateParams { util0: number; rate0: number;