From b995189eb52782191cae9b88a6402f9a82abf0fb Mon Sep 17 00:00:00 2001 From: Serge Farny Date: Wed, 17 Jul 2024 09:22:37 +0100 Subject: [PATCH] Program: charge collateral fee directly on borrowed tokens (#973) * Program: charge collateral fees on borrowed tokens and fixup OO impact (cherry picked from commit 96158e349f3cedb439e96062f4a431a154ba4d0b) --- Cargo.lock | 1 + bin/cli/Cargo.toml | 1 + bin/cli/src/main.rs | 13 + bin/cli/src/test_collateral_fees.rs | 224 +++++++ .../token_charge_collateral_fees.rs | 114 +++- .../tests/cases/test_collateral_fees.rs | 618 ++++++++++++++++-- programs/mango-v4/tests/cases/test_serum.rs | 20 +- .../tests/program_test/mango_setup.rs | 7 +- 8 files changed, 913 insertions(+), 85 deletions(-) create mode 100644 bin/cli/src/test_collateral_fees.rs diff --git a/Cargo.lock b/Cargo.lock index 47297bc6f6..8172ae14c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3494,6 +3494,7 @@ dependencies = [ "anyhow", "async-channel", "base64 0.21.7", + "chrono", "clap 3.2.25", "dotenv", "fixed 1.11.0 (git+https://github.com/blockworks-foundation/fixed.git?branch=v1.11.0-borsh0_10-mango)", diff --git a/bin/cli/Cargo.toml b/bin/cli/Cargo.toml index 5078f41909..129fe771d4 100644 --- a/bin/cli/Cargo.toml +++ b/bin/cli/Cargo.toml @@ -28,3 +28,4 @@ solana-sdk = { workspace = true } tokio = { version = "1.14.1", features = ["rt-multi-thread", "time", "macros", "sync"] } itertools = "0.10.3" tracing = "0.1" +chrono = "0.4.31" diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index 06ab893be4..28a46b1d39 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -11,6 +11,7 @@ use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; mod save_snapshot; +mod test_collateral_fees; mod test_oracles; #[derive(Parser, Debug, Clone)] @@ -214,6 +215,13 @@ enum Command { #[clap(flatten)] rpc: Rpc, }, + TestCollateralFees { + #[clap(short, long)] + group: String, + + #[clap(flatten)] + rpc: Rpc, + }, SaveSnapshot { #[clap(short, long)] group: String, @@ -336,6 +344,11 @@ async fn main() -> Result<(), anyhow::Error> { let group = pubkey_from_cli(&group); test_oracles::run(&client, group).await?; } + Command::TestCollateralFees { group, rpc } => { + let client = rpc.client(None)?; + let group = pubkey_from_cli(&group); + test_collateral_fees::run(&client, group).await?; + } Command::SaveSnapshot { group, rpc, output } => { let mango_group = pubkey_from_cli(&group); let client = rpc.client(None)?; diff --git a/bin/cli/src/test_collateral_fees.rs b/bin/cli/src/test_collateral_fees.rs new file mode 100644 index 0000000000..b758d09d6e --- /dev/null +++ b/bin/cli/src/test_collateral_fees.rs @@ -0,0 +1,224 @@ +use anchor_lang::prelude::AccountInfo; +use itertools::Itertools; +use mango_v4::accounts_zerocopy::LoadZeroCopy; +use mango_v4::instructions::token_charge_collateral_fees_internal; +use mango_v4::state::{DynamicAccount, Group}; +use mango_v4_client::snapshot_source::is_mango_account; +use mango_v4_client::{ + account_update_stream, chain_data, snapshot_source, websocket_source, Client, MangoGroupContext, +}; +use solana_sdk::account::ReadableAccount; +use solana_sdk::pubkey::Pubkey; +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; +use std::sync::{Arc, RwLock}; +use std::time::Duration; + +pub async fn run(client: &Client, mango_group: Pubkey) -> anyhow::Result<()> { + let rpc_async = client.rpc_async(); + let group_context = MangoGroupContext::new_from_rpc(&rpc_async, mango_group).await?; + + let rpc_url = client.config().cluster.url().to_string(); + let ws_url = client.config().cluster.ws_url().to_string(); + + let slot = client.rpc_async().get_slot().await?; + let ts = chrono::Utc::now().timestamp() as u64; + + 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::>(); + + let serum_programs = group_context + .serum3_markets + .values() + .map(|s3| s3.serum_program) + .unique() + .collect_vec(); + + let (account_update_sender, account_update_receiver) = + async_channel::unbounded::(); + + // Sourcing account and slot data from solana via websockets + websocket_source::start( + websocket_source::Config { + rpc_ws_url: ws_url.clone(), + serum_programs, + open_orders_authority: mango_group, + }, + extra_accounts.clone(), + account_update_sender.clone(), + ); + + let first_websocket_slot = websocket_source::get_next_create_bank_slot( + account_update_receiver.clone(), + Duration::from_secs(10), + ) + .await?; + + // Getting solana account snapshots via jsonrpc + snapshot_source::start( + snapshot_source::Config { + rpc_http_url: rpc_url.clone(), + mango_group, + get_multiple_accounts_count: 100, + parallel_rpc_requests: 10, + snapshot_interval: Duration::from_secs(6000), + min_slot: first_websocket_slot + 10, + }, + extra_accounts, + account_update_sender, + ); + + let mut chain_data = chain_data::ChainData::new(); + + use account_update_stream::Message; + loop { + let message = account_update_receiver + .recv() + .await + .expect("channel not closed"); + + message.update_chain_data(&mut chain_data); + + match message { + Message::Account(_) => {} + Message::Snapshot(snapshot) => { + for slot in snapshot.iter().map(|a| a.slot).unique() { + chain_data.update_slot(chain_data::SlotData { + slot, + parent: None, + status: chain_data::SlotStatus::Rooted, + chain: 0, + }); + } + break; + } + _ => {} + } + } + + let group = &chain_data.account(&mango_group).unwrap().account.clone(); + let group = group.load::()?; + + let chain_data = Arc::new(RwLock::new(chain_data)); + + let account_fetcher = Arc::new(chain_data::AccountFetcher { + chain_data: chain_data.clone(), + rpc: client.new_rpc_async(), + }); + + for (key, data) in chain_data.read().unwrap().iter_accounts() { + if let Some(account) = is_mango_account(&data.account, &mango_group) { + // let dyn_part = account.dynamic.clone(); + // let dyn_part = RefCell::new(*dyn_part); + let fixed = account.fixed.clone(); + let fixed_cell = RefCell::new(fixed); + let mut account = DynamicAccount { + header: account.header, + fixed: fixed_cell.borrow_mut(), + dynamic: account.dynamic.iter().map(|x| *x).collect::>(), + }; + + let acc = account_fetcher.fetch_mango_account(key)?; + + let (health_remaining_ams, _) = group_context + .derive_health_check_remaining_account_metas( + &acc, + vec![], + vec![], + vec![], + HashMap::new(), + ) + .unwrap(); + + let mut remaining_accounts: Vec<_> = health_remaining_ams + .into_iter() + .map(|x| { + let xx = account_fetcher.fetch_raw(&x.pubkey).unwrap(); + TestAccount::new( + xx.data().iter().map(|x| *x).collect(), + x.pubkey, + *xx.owner(), + ) + }) + .collect(); + + let remaining_accounts = remaining_accounts + .iter_mut() + .map(|x| return x.as_account_info()) + .collect::>(); + + let mut out = HashMap::new(); + + // Act like it was never charged, but not initial call (0) + account.borrow_mut().fixed.last_collateral_fee_charge = 1; + + match token_charge_collateral_fees_internal( + account, + group, + remaining_accounts.as_slice(), + mango_group, + *key, + (ts, slot), + Some(&mut out), + ) { + Ok(_) => { + for (x, fee) in out { + println!( + "{} -> Token: {} => {} ({} $)", + key, + group_context.tokens.get(&x).unwrap().name, + fee.0 / 2, + fee.1 / 2 + ); + } + } + Err(e) => { + println!("{} -> Error: {:?}", key, e); + } + } + } + } + + Ok(()) +} + +#[derive(Clone)] +pub struct TestAccount { + pub bytes: Vec, + pub pubkey: Pubkey, + pub owner: Pubkey, + pub lamports: u64, +} + +impl TestAccount { + pub fn new(bytes: Vec, pubkey: Pubkey, owner: Pubkey) -> Self { + Self { + bytes, + owner, + pubkey, + lamports: 0, + } + } + + pub fn as_account_info(&mut self) -> AccountInfo { + AccountInfo { + key: &self.pubkey, + owner: &self.owner, + lamports: Rc::new(RefCell::new(&mut self.lamports)), + data: Rc::new(RefCell::new(&mut self.bytes)), + is_signer: false, + is_writable: false, + executable: false, + rent_epoch: 0, + } + } +} 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 944c2722d3..25c0f8be89 100644 --- a/programs/mango-v4/src/instructions/token_charge_collateral_fees.rs +++ b/programs/mango-v4/src/instructions/token_charge_collateral_fees.rs @@ -4,14 +4,40 @@ use crate::state::*; use crate::util::clock_now; use anchor_lang::prelude::*; use fixed::types::I80F48; +use std::collections::HashMap; +use std::ops::{Deref, Div}; use crate::accounts_ix::*; use crate::logs::{emit_stack, TokenBalanceLog, TokenCollateralFeeLog}; pub fn token_charge_collateral_fees(ctx: Context) -> Result<()> { - let group = ctx.accounts.group.load()?; - let mut account = ctx.accounts.account.load_full_mut()?; - let (now_ts, now_slot) = clock_now(); + token_charge_collateral_fees_internal( + ctx.accounts.account.load_full_mut()?, + ctx.accounts.group.load()?.deref(), + &ctx.remaining_accounts, + ctx.accounts.group.key(), + ctx.accounts.account.key(), + clock_now(), + None, + ) +} + +pub fn token_charge_collateral_fees_internal( + mut account: DynamicAccount, + group: &Group, + remaining_accounts: &[AccountInfo], + group_key: Pubkey, + account_key: Pubkey, + now: (u64, u64), + mut out_fees: Option<&mut HashMap>, +) -> Result<()> +where + Header: DerefOrBorrowMut + DerefOrBorrow, + Fixed: DerefOrBorrowMut + DerefOrBorrow, + Dynamic: DerefOrBorrowMut<[u8]> + DerefOrBorrow<[u8]>, +{ + let mut account = account.borrow_mut(); + let (now_ts, now_slot) = now; if group.collateral_fee_interval == 0 { // By resetting, a new enabling of collateral fees will not immediately create a charge @@ -43,10 +69,17 @@ pub fn token_charge_collateral_fees(ctx: Context) -> let health_cache = { let retriever = - new_fixed_order_account_retriever(ctx.remaining_accounts, &account.borrow(), now_slot)?; + new_fixed_order_account_retriever(remaining_accounts, &account.borrow(), now_slot)?; new_health_cache(&account.borrow(), &retriever, now_ts)? }; + let (_, liabs) = health_cache.assets_and_liabs(); + // Account with liabs below ~100$ should not be charged any collateral fees + if liabs < 100_000_000 { + // msg!("liabs {}, below threshold to charge collateral fees", liabs); + return Ok(()); + } + // We want to find the total asset health and total liab health, but don't want // to treat borrows that moved into open orders accounts as realized. Hence we // pretend all spot orders are closed and settled and add their funds back to @@ -80,15 +113,21 @@ pub fn token_charge_collateral_fees(ctx: Context) -> let scaling = asset_usage_scaling * time_scaling; + let mut total_collateral_fees_in_usd = I80F48::ZERO; + let token_position_count = account.active_token_positions().count(); - for bank_ai in &ctx.remaining_accounts[0..token_position_count] { + for bank_ai in &remaining_accounts[0..token_position_count] { let mut bank = bank_ai.load_mut::()?; if bank.collateral_fee_per_day <= 0.0 || bank.maint_asset_weight.is_zero() { continue; } - let (token_position, raw_token_index) = account.token_position_mut(bank.token_index)?; - let token_balance = token_position.native(&bank); + let token_index_in_health_cache = health_cache + .token_infos + .iter() + .position(|x| x.token_index == bank.token_index) + .expect("missing token in health"); + let token_balance = token_balances[token_index_in_health_cache].spot_and_perp; if token_balance <= 0 { continue; } @@ -96,29 +135,66 @@ pub fn token_charge_collateral_fees(ctx: Context) -> let fee = token_balance * scaling * I80F48::from_num(bank.collateral_fee_per_day); assert!(fee <= token_balance); - let is_active = bank.withdraw_without_fee(token_position, fee, now_ts)?; - if !is_active { - account.deactivate_token_position_and_log(raw_token_index, ctx.accounts.account.key()); - } + let token_info = health_cache.token_info(bank.token_index)?; - bank.collected_fees_native += fee; - bank.collected_collateral_fees += fee; + total_collateral_fees_in_usd += fee * token_info.prices.oracle; - let token_info = health_cache.token_info(bank.token_index)?; - let token_position = account.token_position(bank.token_index)?; + bank.collected_collateral_fees += fee; emit_stack(TokenCollateralFeeLog { - mango_group: ctx.accounts.group.key(), - mango_account: ctx.accounts.account.key(), + mango_group: group_key, + mango_account: account_key, token_index: bank.token_index, fee: fee.to_bits(), asset_usage_fraction: asset_usage_scaling.to_bits(), price: token_info.prices.oracle.to_bits(), }); + } + + for bank_ai in &remaining_accounts[0..token_position_count] { + let mut bank = bank_ai.load_mut::()?; + + let token_info = health_cache.token_info(bank.token_index)?; + + let token_index_in_health_cache = health_cache + .token_infos + .iter() + .position(|x| x.token_index == bank.token_index) + .expect("missing token in health"); + + let token_balance = token_balances[token_index_in_health_cache].spot_and_perp; + let health = token_info.health_contribution(HealthType::Maint, token_balance); + + if health >= 0 { + continue; + } + + let (token_position, raw_token_index) = account.token_position_mut(bank.token_index)?; + + let borrow_scaling = (health / total_liab_health).abs(); + let fee = borrow_scaling * total_collateral_fees_in_usd / token_info.prices.oracle; + + if let Some(ref mut output) = out_fees { + output.insert( + token_info.token_index, + ( + fee, + (fee * token_info.prices.oracle).div(I80F48::from_num(1_000_000)), + ), + ); + } + + let is_active = bank.withdraw_without_fee(token_position, fee, now_ts)?; + if !is_active { + account.deactivate_token_position_and_log(raw_token_index, account_key); + } + + bank.collected_fees_native += fee; + let token_position = account.token_position(bank.token_index)?; emit_stack(TokenBalanceLog { - mango_group: ctx.accounts.group.key(), - mango_account: ctx.accounts.account.key(), + mango_group: group_key, + mango_account: account_key, token_index: bank.token_index, indexed_position: token_position.indexed_position.to_bits(), deposit_index: bank.deposit_index.to_bits(), diff --git a/programs/mango-v4/tests/cases/test_collateral_fees.rs b/programs/mango-v4/tests/cases/test_collateral_fees.rs index 1ee9d8fa52..c60ac5fc2b 100644 --- a/programs/mango-v4/tests/cases/test_collateral_fees.rs +++ b/programs/mango-v4/tests/cases/test_collateral_fees.rs @@ -1,5 +1,11 @@ #![allow(unused_assignments)] + use super::*; +use crate::cases::test_serum::SerumOrderPlacer; +use num::ToPrimitive; +use std::collections::HashMap; +use std::str::FromStr; +use std::sync::Arc; #[tokio::test] async fn test_collateral_fees() -> Result<(), TransportError> { @@ -10,11 +16,17 @@ async fn test_collateral_fees() -> Result<(), TransportError> { let owner = context.users[0].key; let payer = context.users[1].key; let mints = &context.mints[0..2]; + let mut prices = HashMap::new(); + + // 1 unit = 1$ + prices.insert(mints[0].pubkey, 1_000_000f64); + prices.insert(mints[1].pubkey, 1_000_000f64); let mango_setup::GroupWithTokens { group, tokens, .. } = mango_setup::GroupWithTokensConfig { admin, payer, mints: mints.to_vec(), + prices: prices, ..mango_setup::GroupWithTokensConfig::default() } .create(solana) @@ -73,37 +85,8 @@ async fn test_collateral_fees() -> Result<(), TransportError> { .await .unwrap(); - send_tx( - solana, - TokenEdit { - group, - admin, - mint: mints[0].pubkey, - fallback_oracle: Pubkey::default(), - options: mango_v4::instruction::TokenEdit { - collateral_fee_per_day_opt: Some(0.1), - ..token_edit_instruction_default() - }, - }, - ) - .await - .unwrap(); - - send_tx( - solana, - TokenEdit { - group, - admin, - mint: mints[1].pubkey, - fallback_oracle: Pubkey::default(), - options: mango_v4::instruction::TokenEdit { - loan_origination_fee_rate_opt: Some(0.0), - ..token_edit_instruction_default() - }, - }, - ) - .await - .unwrap(); + set_collateral_fees(solana, admin, mints, group, 0, 0.1).await; + set_loan_orig_fee(solana, admin, mints, group, 1, 0.0).await; // // TEST: It works on empty accounts @@ -157,62 +140,589 @@ async fn test_collateral_fees() -> Result<(), TransportError> { // TEST: With borrows, there's an effect depending on the time that has passed // + withdraw(&context, solana, owner, account, 500, 1).await; // maint: -1.2 * 500 = -600 (half of 1200) + + solana.set_clock_timestamp(last_time + 9 * hour).await; + + send_tx(solana, TokenChargeCollateralFeesInstruction { account }) + .await + .unwrap(); + last_time = solana.clock_timestamp().await; + + let fee = 1500.0 * (0.1 * (9.0 / 24.0) * (600.0 / 1200.0)); + println!("fee -> {}", fee); + assert_eq_f64!( + account_position_f64(solana, account, tokens[0].bank).await, + 1500.0, + 0.01 + ); + assert_eq_f64!( + account_position_f64(solana, account, tokens[1].bank).await, + -500.0 - fee, + 0.01 + ); + let last_balance = account_position_f64(solana, account, tokens[1].bank).await; + + // + // TEST: More borrows + // + + withdraw(&context, solana, owner, account, 100, 1).await; // maint: -1.2 * 600 = -720 + + solana.set_clock_timestamp(last_time + 7 * hour).await; + + send_tx(solana, TokenChargeCollateralFeesInstruction { account }) + .await + .unwrap(); + //last_time = solana.clock_timestamp().await; + let fee = 1500.0 * 0.1 * (7.0 / 24.0) * ((last_balance.abs() + 100.0) * 1.2 / (1500.0 * 0.8)); + println!("fee -> {}", fee); + assert_eq_f64!( + account_position_f64(solana, account, tokens[0].bank).await, + 1500.0, + 0.01 + ); + assert_eq_f64!( + account_position_f64(solana, account, tokens[1].bank).await, + -(last_balance.abs() + 100.0) - fee, + 0.01 + ); + + Ok(()) +} + +#[tokio::test] +async fn test_collateral_fees_multi() -> 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..4]; + let mut prices = HashMap::new(); + + prices.insert(mints[0].pubkey, 1_000_000f64); // 1 unit = 1$ + prices.insert(mints[1].pubkey, 3_000_000f64); // 1 unit = 3$ + prices.insert(mints[2].pubkey, 5_000_000f64); // 1 unit = 5$ + prices.insert(mints[3].pubkey, 20_000_000f64); // 1 unit = 20$ + + let mango_setup::GroupWithTokens { group, tokens, .. } = mango_setup::GroupWithTokensConfig { + admin, + payer, + mints: mints.to_vec(), + prices, + ..mango_setup::GroupWithTokensConfig::default() + } + .create(solana) + .await; + + // fund the vaults to allow borrowing + create_funded_account( + &solana, + group, + owner, + 0, + &context.users[1], + mints, + 1_000_000, + 0, + ) + .await; + + let account = create_funded_account( + &solana, + group, + owner, + 1, + &context.users[1], + &mints[0..2], + 1_500, // maint: 0.8 * 1500 = 1200 + 0, + ) + .await; + + let hour = 60 * 60; + send_tx( solana, - TokenWithdrawInstruction { - amount: 500, // maint: -1.2 * 500 = -600 (half of 1200) - allow_borrow: true, - account, - owner, - token_account: context.users[1].token_accounts[1], - bank_index: 0, + GroupEdit { + group, + admin, + options: mango_v4::instruction::GroupEdit { + collateral_fee_interval_opt: Some(6 * hour), + ..group_edit_instruction_default() + }, }, ) .await .unwrap(); + // Set fees + + set_collateral_fees(solana, admin, mints, group, 0, 0.1).await; + set_collateral_fees(solana, admin, mints, group, 1, 0.2).await; + set_loan_orig_fee(solana, admin, mints, group, 2, 0.0).await; + set_loan_orig_fee(solana, admin, mints, group, 3, 0.0).await; + + // + // TEST: With borrows, there's an effect depending on the time that has passed + // + + withdraw(&context, solana, owner, account, 50, 2).await; // maint: -1.2 * 50 = -60 (250$ -> 300$) + withdraw(&context, solana, owner, account, 100, 3).await; // maint: -1.2 * 100 = -120 (2000$ -> 2400$) + + send_tx(solana, TokenChargeCollateralFeesInstruction { account }) + .await + .unwrap(); + let mut last_time = solana.clock_timestamp().await; solana.set_clock_timestamp(last_time + 9 * hour).await; + // send it twice, because the first time will never charge anything send_tx(solana, TokenChargeCollateralFeesInstruction { account }) .await .unwrap(); last_time = solana.clock_timestamp().await; + + let usage_factor = (60.0 * 5.0 + 120.0 * 20.0) / ((1500.0 + 1500.0 * 3.0) * 0.8); + let time_factor = 9.0 / 24.0; + let collateral_fee_factor = 1500.0 * 0.1 + 1500.0 * 3.0 * 0.2; + let collateral_fee = collateral_fee_factor * time_factor * usage_factor; + // println!("fee -> {}", collateral_fee); 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)), + 1500.0, 0.01 ); - let last_balance = account_position_f64(solana, account, tokens[0].bank).await; + assert_eq_f64!( + account_position_f64(solana, account, tokens[1].bank).await, + 1500.0, + 0.01 + ); + assert_eq_f64!( + account_position_f64(solana, account, tokens[2].bank).await, + -50.0 - (300.0 / 2700.0) * collateral_fee / 5.0, + 0.01 + ); + assert_eq_f64!( + account_position_f64(solana, account, tokens[3].bank).await, + -100.0 - (2400.0 / 2700.0) * collateral_fee / 20.0, + 0.01 + ); + + Ok(()) +} + +// Test convention +// +// T = Token without collateral fee +// Tc = Token with collateral fee +// B_x = Balance of x +// O_x = Amount in OO for x (market will be x/T1) +// F_x = Collateral Fee charged on x +// +// Asset weight = 0.8 +// Liab weight = 1.2 +// All amounts in USD +// Base lot is 100 + +#[tokio::test] +async fn test_basics() -> Result<(), TransportError> { + let test_cases = parse_test_cases("\ + B_T1 ; B_T2 ; B_Tc1 ; B_Tc2 ; B_Tc3 ; B_Tc4 ; O_T1 ; O_T2 ; O_Tc1 ; O_Tc2 ; O_Tc3 ; O_Tc4 ; CF_T1 ; CF_T2 ; CF_Tc1 ; CF_Tc2 ; CF_Tc3 ; CF_Tc4 \r\n \ + -2000 ; 0 ; 10000 ; 0 ; 0 ; 0 ; 0 ; 0 ; 0 ; 0 ; 0 ; 0 ; -300 ; 0 ; 0 ; 0 ; 0 ; 0 \r\n \ + -2000 ; 0 ; 5000 ; 5000 ; 0 ; 0 ; 0 ; 0 ; 0 ; 0 ; 0 ; 0 ; -300 ; 0 ; 0 ; 0 ; 0 ; 0 \r\n \ + -500 ; -1500 ; 10000 ; 0 ; 0 ; 0 ; 0 ; 0 ; 0 ; 0 ; 0 ; 0 ; -75 ; -225 ; 0 ; 0 ; 0 ; 0 \r\n \ + "); + + run_scenario(test_cases).await +} + +#[tokio::test] +async fn test_creating_borrow_from_oo() -> Result<(), TransportError> { + let test_cases = parse_test_cases("\ + B_T1 ; B_T2 ; B_Tc1 ; B_Tc2 ; B_Tc3 ; B_Tc4 ; O_T1 ; O_T2 ; O_Tc1 ; O_Tc2 ; O_Tc3 ; O_Tc4 ; CF_T1 ; CF_T2 ; CF_Tc1 ; CF_Tc2 ; CF_Tc3 ; CF_Tc4 \r\n \ + -2000 ; 0 ; 10000 ; 0 ; 0 ; 0 ; 0 ; 200 ; 0 ; 0 ; 0 ; 0 ; -300 ; 0 ; 0 ; 0 ; 0 ; 0 \r\n \ + -2000 ; 0 ; 10000 ; 0 ; 0 ; 0 ; 0 ; 0 ; 300 ; 0 ; 0 ; 0 ; -300 ; 0 ; 0 ; 0 ; 0 ; 0 \r\n \ + "); + + run_scenario(test_cases).await +} + +#[tokio::test] +async fn test_hiding_collateral_using_oo() -> Result<(), TransportError> { + let test_cases = parse_test_cases("\ + B_T1 ; B_T2 ; B_Tc1 ; B_Tc2 ; B_Tc3 ; B_Tc4 ; O_T1 ; O_T2 ; O_Tc1 ; O_Tc2 ; O_Tc3 ; O_Tc4 ; CF_T1 ; CF_T2 ; CF_Tc1 ; CF_Tc2 ; CF_Tc3 ; CF_Tc4 \r\n \ + -2000 ; 0 ; 10000 ; 0 ; 0 ; 0 ; 0 ; -200 ; 0 ; 0 ; 0 ; 0 ; -300 ; 0 ; 0 ; 0 ; 0 ; 0 \r\n \ + -2000 ; 0 ; 10000 ; 0 ; 0 ; 0 ; 0 ; 0 ; -300 ; 0 ; 0 ; 0 ; -300 ; 0 ; 0 ; 0 ; 0 ; 0 \r\n \ + "); + + run_scenario(test_cases).await +} + +async fn run_scenario(test_cases: Vec>) -> Result<(), TransportError> { + for test_case in test_cases { + if test_case.len() == 0 { + continue; + } + + let mut test_builder = TestContextBuilder::new(); + test_builder.test().set_compute_max_units(200_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..6]; + let mut prices = HashMap::new(); + + // Setup prices + for i in 0..6 { + prices.insert(mints[i].pubkey, (i as f64 + 1.0) * 1_000_000f64); // 1 unit = i$ + } + + let mango_setup::GroupWithTokens { group, tokens, .. } = + mango_setup::GroupWithTokensConfig { + admin, + payer, + mints: mints.to_vec(), + prices, + ..mango_setup::GroupWithTokensConfig::default() + } + .create(solana) + .await; + + // Setup fees + set_collateral_fees(solana, admin, mints, group, 2, 0.1).await; + set_collateral_fees(solana, admin, mints, group, 3, 0.1).await; + set_collateral_fees(solana, admin, mints, group, 4, 0.1).await; + set_collateral_fees(solana, admin, mints, group, 5, 0.1).await; + for i in 0..6 { + set_loan_orig_fee(solana, admin, mints, group, i, 0.0).await; + } + + // fund the vaults to allow borrowing + create_funded_account( + &solana, + group, + owner, + 0, + &context.users[1], + mints, + 9_000_000_000, + 0, + ) + .await; + + let account = send_tx( + solana, + AccountCreateInstruction { + account_num: 1, + group, + owner, + payer: context.users[1].key, + ..Default::default() + }, + ) + .await + .unwrap() + .account; + + // For Spot order + + let hour = 60 * 60; + + send_tx( + solana, + GroupEdit { + group, + admin, + options: mango_v4::instruction::GroupEdit { + collateral_fee_interval_opt: Some(24 * hour), + ..group_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); + + // Setup balance + for (index, balance) in test_case[0..6].iter().enumerate() { + if *balance > 0.0 { + deposit( + solana, + owner, + &context.users[1], + account, + ((*balance as f64) / (index + 1) as f64).ceil() as u64, + index, + ) + .await; + } + } + for (index, balance) in test_case[0..6].iter().enumerate() { + if *balance < 0.0 { + withdraw( + &context, + solana, + owner, + account, + ((balance.abs() as f64) / (index + 1) as f64).ceil() as u64, + index, + ) + .await; + } + } + + // Setup orders + for (index, order) in test_case[6..12].iter().enumerate() { + if *order == 0.0 { + continue; + } + + create_order( + solana, + &context, + group, + admin, + owner, + &context.users[0], + account, + (index + 1) as f64, + (order / (index + 1) as f64).floor() as i64, + &tokens[index], + &tokens[0], + ) + .await; + } + + // + // TEST + // + + let mut balances = vec![]; + for i in 0..6 { + if test_case[i] == 0.0 { + balances.push(0f64); + } else { + balances.push(account_position_f64(solana, account, tokens[i].bank).await); + } + } + + send_tx(solana, TokenChargeCollateralFeesInstruction { account }) + .await + .unwrap(); + let mut last_time = solana.clock_timestamp().await; + solana.set_clock_timestamp(last_time + 24 * hour).await; + + // send it twice, because the first time will never charge anything + send_tx(solana, TokenChargeCollateralFeesInstruction { account }) + .await + .unwrap(); + last_time = solana.clock_timestamp().await; + + // Assert balance change + for (index, expected_fee) in test_case[12..].iter().enumerate() { + if test_case[index] == 0.0 { + continue; + } + + let current_balance = account_position_f64(solana, account, tokens[index].bank).await; + let previous_balance = balances[index]; + let actual_fee = (current_balance - previous_balance) * (index + 1) as f64; + + assert_eq_f64!(actual_fee, expected_fee.to_f64().unwrap(), 0.01); + } + } + + Ok(()) +} + +fn parse_test_cases(test_cases: &str) -> Vec> { + test_cases + .split("\r\n") + .skip(1) + .map(|x| { + x.split(";") + .filter_map(|y| { + let y = y.trim(); + if y.len() == 0 { + return None; + } + Some(f64::from_str(y).unwrap()) + }) + .collect_vec() + }) + .collect_vec() +} + +async fn create_order( + solana: &Arc, + context: &TestContext, + group: Pubkey, + admin: TestKeypair, + owner: TestKeypair, + payer: &UserCookie, + account: Pubkey, + price: f64, + quantity: i64, + base_token: &Token, + quote_token: &Token, +) -> Option<(u128, u64)> { + let serum_market_cookie = context + .serum + .list_spot_market(&base_token.mint, "e_token.mint) + .await; // - // TEST: More borrows + // TEST: Register a serum market + // + let serum_market = send_tx( + solana, + Serum3RegisterMarketInstruction { + group, + admin, + serum_program: context.serum.program_id, + serum_market_external: serum_market_cookie.market, + market_index: 0, + base_bank: base_token.bank, + quote_bank: quote_token.bank, + payer: payer.key, + }, + ) + .await + .unwrap() + .serum_market; + // + // TEST: Create an open orders account + // + let open_orders = send_tx( + solana, + Serum3CreateOpenOrdersInstruction { + account, + serum_market, + owner, + payer: payer.key, + }, + ) + .await + .unwrap() + .open_orders; + + let mut order_placer = SerumOrderPlacer { + solana: solana.clone(), + serum: context.serum.clone(), + account, + owner: owner.clone(), + serum_market, + open_orders, + next_client_order_id: 0, + }; + if quantity > 0 { + order_placer.bid_maker(price, quantity as u64).await + } else { + order_placer.ask(price, quantity.abs() as u64).await + } +} + +async fn withdraw( + context: &TestContext, + solana: &Arc, + owner: TestKeypair, + account: Pubkey, + amount: u64, + token_index: usize, +) { + // println!("WITHDRAWING {} - token index {}", amount, token_index); send_tx( solana, TokenWithdrawInstruction { - amount: 100, // maint: -1.2 * 600 = -720 + amount: amount, allow_borrow: true, account, owner, - token_account: context.users[1].token_accounts[1], + token_account: context.users[1].token_accounts[token_index], bank_index: 0, }, ) .await .unwrap(); +} - solana.set_clock_timestamp(last_time + 7 * hour).await; +async fn deposit( + solana: &Arc, + owner: TestKeypair, + payer: &UserCookie, + account: Pubkey, + amount: u64, + token_index: usize, +) { + // println!("DEPOSITING {} - token index {}", amount, token_index); + send_tx( + solana, + TokenDepositInstruction { + amount: amount, + reduce_only: false, + account, + owner, + token_account: payer.token_accounts[token_index], + token_authority: payer.key, + bank_index: 0, + }, + ) + .await + .unwrap(); +} - send_tx(solana, TokenChargeCollateralFeesInstruction { account }) - .await - .unwrap(); - //last_time = solana.clock_timestamp().await; - 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 - ); +async fn set_loan_orig_fee( + solana: &Arc, + admin: TestKeypair, + mints: &[MintCookie], + group: Pubkey, + token_index: usize, + rate: f32, +) { + send_tx( + solana, + TokenEdit { + group, + admin, + mint: mints[token_index].pubkey, + fallback_oracle: Pubkey::default(), + options: mango_v4::instruction::TokenEdit { + loan_origination_fee_rate_opt: Some(rate), + ..token_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); +} - Ok(()) +async fn set_collateral_fees( + solana: &Arc, + admin: TestKeypair, + mints: &[MintCookie], + group: Pubkey, + token_index: usize, + rate: f32, +) { + send_tx( + solana, + TokenEdit { + group, + admin, + mint: mints[token_index].pubkey, + fallback_oracle: Pubkey::default(), + options: mango_v4::instruction::TokenEdit { + collateral_fee_per_day_opt: Some(rate), + ..token_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); } diff --git a/programs/mango-v4/tests/cases/test_serum.rs b/programs/mango-v4/tests/cases/test_serum.rs index 27613a8305..ec8129bc51 100644 --- a/programs/mango-v4/tests/cases/test_serum.rs +++ b/programs/mango-v4/tests/cases/test_serum.rs @@ -6,14 +6,14 @@ use mango_v4::accounts_ix::{Serum3OrderType, Serum3SelfTradeBehavior, Serum3Side use mango_v4::serum3_cpi::{load_open_orders_bytes, OpenOrdersSlim}; use std::sync::Arc; -struct SerumOrderPlacer { - solana: Arc, - serum: Arc, - account: Pubkey, - owner: TestKeypair, - serum_market: Pubkey, - open_orders: Pubkey, - next_client_order_id: u64, +pub struct SerumOrderPlacer { + pub solana: Arc, + pub serum: Arc, + pub account: Pubkey, + pub owner: TestKeypair, + pub serum_market: Pubkey, + pub open_orders: Pubkey, + pub next_client_order_id: u64, } impl SerumOrderPlacer { @@ -71,7 +71,7 @@ impl SerumOrderPlacer { send_tx(&self.solana, ix).await } - async fn bid_maker(&mut self, limit_price: f64, max_base: u64) -> Option<(u128, u64)> { + pub async fn bid_maker(&mut self, limit_price: f64, max_base: u64) -> Option<(u128, u64)> { self.try_bid(limit_price, max_base, false).await.unwrap(); self.find_order_id_for_client_order_id(self.next_client_order_id - 1) .await @@ -108,7 +108,7 @@ impl SerumOrderPlacer { .await } - async fn ask(&mut self, limit_price: f64, max_base: u64) -> Option<(u128, u64)> { + pub async fn ask(&mut self, limit_price: f64, max_base: u64) -> Option<(u128, u64)> { self.try_ask(limit_price, max_base).await.unwrap(); self.find_order_id_for_client_order_id(self.next_client_order_id - 1) .await diff --git a/programs/mango-v4/tests/program_test/mango_setup.rs b/programs/mango-v4/tests/program_test/mango_setup.rs index 6afeff4f38..cf05c1d0f8 100644 --- a/programs/mango-v4/tests/program_test/mango_setup.rs +++ b/programs/mango-v4/tests/program_test/mango_setup.rs @@ -1,16 +1,18 @@ #![allow(dead_code)] use anchor_lang::prelude::*; +use std::collections::HashMap; use super::mango_client::*; use super::solana::SolanaCookie; -use super::{send_tx, MintCookie, TestKeypair, UserCookie}; +use super::{MintCookie, TestKeypair, UserCookie}; #[derive(Default)] pub struct GroupWithTokensConfig { pub admin: TestKeypair, pub payer: TestKeypair, pub mints: Vec, + pub prices: HashMap, pub zero_token_is_quote: bool, } @@ -38,6 +40,7 @@ impl<'a> GroupWithTokensConfig { admin, payer, mints, + prices, zero_token_is_quote, } = self; let create_group_accounts = send_tx( @@ -74,7 +77,7 @@ impl<'a> GroupWithTokensConfig { group, admin, mint: mint.pubkey, - price: 1.0, + price: prices.get(&mint.pubkey).copied().unwrap_or(1.0f64), oracle, }, )