diff --git a/bin/keeper/src/crank.rs b/bin/keeper/src/crank.rs index 8082f41a8..705ea8b2b 100644 --- a/bin/keeper/src/crank.rs +++ b/bin/keeper/src/crank.rs @@ -1,12 +1,27 @@ -use std::{collections::HashSet, sync::Arc, time::Duration, time::Instant}; +use std::{ + collections::HashSet, + sync::Arc, + time::Instant, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; use crate::MangoClient; +use anyhow::Context; use itertools::Itertools; -use anchor_lang::{__private::bytemuck::cast_ref, solana_program}; +use anchor_lang::{__private::bytemuck::cast_ref, solana_program, Discriminator}; use futures::Future; -use mango_v4::state::{EventQueue, EventType, FillEvent, OutEvent, TokenIndex}; -use mango_v4_client::PerpMarketContext; +use mango_v4::{ + accounts_zerocopy::AccountReader, + state::{ + EventQueue, EventType, FillEvent, Group, MangoAccount, MangoAccountValue, OutEvent, + TokenIndex, + }, +}; +use mango_v4_client::{ + account_fetcher_fetch_anchor_account, AccountFetcher, PerpMarketContext, PreparedInstructions, + RpcAccountFetcher, TransactionBuilder, +}; use prometheus::{register_histogram, Encoder, Histogram, IntCounter, Registry}; use solana_sdk::{ instruction::{AccountMeta, Instruction}, @@ -81,6 +96,7 @@ pub async fn runner( interval_consume_events: u64, interval_update_funding: u64, interval_check_for_changes_and_abort: u64, + interval_charge_collateral_fees: u64, extra_jobs: Vec>, ) -> Result<(), anyhow::Error> { let handles1 = mango_client @@ -140,6 +156,7 @@ pub async fn runner( futures::future::join_all(handles1), futures::future::join_all(handles2), futures::future::join_all(handles3), + loop_charge_collateral_fees(mango_client.clone(), interval_charge_collateral_fees), MangoClient::loop_check_for_context_changes_and_abort( mango_client.clone(), Duration::from_secs(interval_check_for_changes_and_abort), @@ -412,3 +429,122 @@ pub async fn loop_update_funding( } } } + +pub async fn loop_charge_collateral_fees(mango_client: Arc, interval: u64) { + if interval == 0 { + return; + } + + // Make a new one separate from the mango_client.account_fetcher, + // because we don't want cached responses + let fetcher = RpcAccountFetcher { + rpc: mango_client.client.new_rpc_async(), + }; + + let group: Group = account_fetcher_fetch_anchor_account(&fetcher, &mango_client.context.group) + .await + .unwrap(); + let collateral_fee_interval = group.collateral_fee_interval; + + let mut interval = mango_v4_client::delay_interval(Duration::from_secs(interval)); + loop { + interval.tick().await; + + match charge_collateral_fees_inner(&mango_client, &fetcher, collateral_fee_interval).await { + Ok(()) => {} + Err(err) => { + error!("charge_collateral_fees error: {err:?}"); + } + } + } +} + +async fn charge_collateral_fees_inner( + client: &MangoClient, + fetcher: &RpcAccountFetcher, + collateral_fee_interval: u64, +) -> anyhow::Result<()> { + let mango_accounts = fetcher + .fetch_program_accounts(&mango_v4::id(), MangoAccount::DISCRIMINATOR) + .await + .context("fetching mango accounts")? + .into_iter() + .filter_map( + |(pk, data)| match MangoAccountValue::from_bytes(&data.data()[8..]) { + Ok(acc) => Some((pk, acc)), + Err(err) => { + error!(pk=%pk, "charge_collateral_fees could not parse account: {err:?}"); + None + } + }, + ); + + let mut ix_to_send = Vec::new(); + let now_ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as u64; + for (pk, account) in mango_accounts { + let should_reset = + collateral_fee_interval == 0 && account.fixed.last_collateral_fee_charge > 0; + let should_charge = collateral_fee_interval > 0 + && now_ts > account.fixed.last_collateral_fee_charge + collateral_fee_interval; + if !(should_reset || should_charge) { + continue; + } + + let ixs = match client + .token_charge_collateral_fees_instruction((&pk, &account)) + .await + { + Ok(ixs) => ixs, + Err(err) => { + error!(pk=%pk, "charge_collateral_fees could not build instruction: {err:?}"); + continue; + } + }; + + ix_to_send.push(ixs); + } + + send_batched_log_errors_no_confirm( + client.transaction_builder().await?, + &client.client, + &ix_to_send, + ) + .await; + + Ok(()) +} + +/// Try to batch the instructions into transactions and send them +async fn send_batched_log_errors_no_confirm( + mut tx_builder: TransactionBuilder, + client: &mango_v4_client::Client, + ixs_list: &[PreparedInstructions], +) { + let mut current_batch = PreparedInstructions::new(); + for ixs in ixs_list { + let previous_batch = current_batch.clone(); + current_batch.append(ixs.clone()); + + tx_builder.instructions = current_batch.clone().to_instructions(); + if !tx_builder.transaction_size().is_ok() { + tx_builder.instructions = previous_batch.to_instructions(); + match tx_builder.send(client).await { + Err(err) => error!("could not send transaction: {err:?}"), + _ => {} + } + + current_batch = ixs.clone(); + } + } + + if !current_batch.is_empty() { + tx_builder.instructions = current_batch.to_instructions(); + match tx_builder.send(client).await { + Err(err) => error!("could not send transaction: {err:?}"), + _ => {} + } + } +} diff --git a/bin/keeper/src/main.rs b/bin/keeper/src/main.rs index dad08649e..194d5a46b 100644 --- a/bin/keeper/src/main.rs +++ b/bin/keeper/src/main.rs @@ -61,6 +61,9 @@ struct Cli { #[clap(long, env, default_value_t = 120)] interval_check_new_listings_and_abort: u64, + #[clap(long, env, default_value_t = 300)] + interval_charge_collateral_fees: u64, + #[clap(long, env, default_value_t = 10)] timeout: u64, @@ -153,6 +156,7 @@ async fn main() -> Result<(), anyhow::Error> { cli.interval_consume_events, cli.interval_update_funding, cli.interval_check_new_listings_and_abort, + cli.interval_charge_collateral_fees, prio_jobs, ) .await diff --git a/lib/client/src/client.rs b/lib/client/src/client.rs index c3795cc01..1ee3b2f90 100644 --- a/lib/client/src/client.rs +++ b/lib/client/src/client.rs @@ -1487,6 +1487,43 @@ impl MangoClient { )) } + pub async fn token_charge_collateral_fees_instruction( + &self, + account: (&Pubkey, &MangoAccountValue), + ) -> anyhow::Result { + let (mut health_remaining_ams, health_cu) = self + .derive_health_check_remaining_account_metas(account.1, vec![], vec![], vec![]) + .await + .unwrap(); + + // The instruction requires mutable banks + for am in &mut health_remaining_ams[0..account.1.active_token_positions().count()] { + am.is_writable = true; + } + + let ix = Instruction { + program_id: mango_v4::id(), + accounts: { + let mut ams = anchor_lang::ToAccountMetas::to_account_metas( + &mango_v4::accounts::TokenChargeCollateralFees { + group: self.group(), + account: *account.0, + }, + None, + ); + ams.extend(health_remaining_ams); + ams + }, + data: anchor_lang::InstructionData::data( + &mango_v4::instruction::TokenChargeCollateralFees {}, + ), + }; + Ok(PreparedInstructions::from_single( + ix, + self.instruction_cu(health_cu), + )) + } + // // Liquidation // diff --git a/mango_v4.json b/mango_v4.json index 35363b714..9c9a7bde6 100644 --- a/mango_v4.json +++ b/mango_v4.json @@ -277,6 +277,12 @@ "type": { "option": "u16" } + }, + { + "name": "collateralFeeIntervalOpt", + "type": { + "option": "u64" + } } ] }, @@ -635,6 +641,10 @@ { "name": "disableAssetLiquidation", "type": "bool" + }, + { + "name": "collateralFeePerDay", + "type": "f32" } ] }, @@ -1051,6 +1061,12 @@ "type": { "option": "bool" } + }, + { + "name": "collateralFeePerDayOpt", + "type": { + "option": "f32" + } } ] }, @@ -5963,6 +5979,25 @@ } ] }, + { + "name": "tokenChargeCollateralFees", + "accounts": [ + { + "name": "group", + "isMut": false, + "isSigner": false + }, + { + "name": "account", + "isMut": true, + "isSigner": false, + "relations": [ + "group" + ] + } + ], + "args": [] + }, { "name": "altSet", "accounts": [ @@ -7531,12 +7566,30 @@ "defined": "I80F48" } }, + { + "name": "collectedCollateralFees", + "docs": [ + "Collateral fees that have been collected (in native tokens)", + "", + "See also collected_fees_native and fees_withdrawn." + ], + "type": { + "defined": "I80F48" + } + }, + { + "name": "collateralFeePerDay", + "docs": [ + "The daily collateral fees rate for fully utilized collateral." + ], + "type": "f32" + }, { "name": "reserved", "type": { "array": [ "u8", - 1920 + 1900 ] } } @@ -7664,12 +7717,28 @@ ], "type": "u16" }, + { + "name": "padding2", + "type": { + "array": [ + "u8", + 4 + ] + } + }, + { + "name": "collateralFeeInterval", + "docs": [ + "Intervals in which collateral fee is applied" + ], + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 1812 + 1800 ] } } @@ -7791,12 +7860,27 @@ ], "type": "u64" }, + { + "name": "temporaryDelegate", + "type": "publicKey" + }, + { + "name": "temporaryDelegateExpiry", + "type": "u64" + }, + { + "name": "lastCollateralFeeCharge", + "docs": [ + "Time at which the last collateral fee was charged" + ], + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 200 + 152 ] } }, @@ -9566,12 +9650,16 @@ "name": "temporaryDelegateExpiry", "type": "u64" }, + { + "name": "lastCollateralFeeCharge", + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 160 + 152 ] } } @@ -13699,6 +13787,36 @@ "index": false } ] + }, + { + "name": "TokenCollateralFeeLog", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "mangoAccount", + "type": "publicKey", + "index": false + }, + { + "name": "tokenIndex", + "type": "u16", + "index": false + }, + { + "name": "assetUsageFraction", + "type": "i128", + "index": false + }, + { + "name": "fee", + "type": "i128", + "index": false + } + ] } ], "errors": [ diff --git a/programs/mango-v4/src/accounts_ix/mod.rs b/programs/mango-v4/src/accounts_ix/mod.rs index d2b42f23c..289430440 100644 --- a/programs/mango-v4/src/accounts_ix/mod.rs +++ b/programs/mango-v4/src/accounts_ix/mod.rs @@ -59,6 +59,7 @@ pub use stub_oracle_close::*; pub use stub_oracle_create::*; pub use stub_oracle_set::*; pub use token_add_bank::*; +pub use token_charge_collateral_fees::*; pub use token_conditional_swap_cancel::*; pub use token_conditional_swap_create::*; pub use token_conditional_swap_start::*; @@ -135,6 +136,7 @@ mod stub_oracle_close; mod stub_oracle_create; mod stub_oracle_set; mod token_add_bank; +mod token_charge_collateral_fees; mod token_conditional_swap_cancel; mod token_conditional_swap_create; mod token_conditional_swap_start; diff --git a/programs/mango-v4/src/accounts_ix/token_charge_collateral_fees.rs b/programs/mango-v4/src/accounts_ix/token_charge_collateral_fees.rs new file mode 100644 index 000000000..d6c7f218f --- /dev/null +++ b/programs/mango-v4/src/accounts_ix/token_charge_collateral_fees.rs @@ -0,0 +1,16 @@ +use crate::error::MangoError; +use crate::state::*; +use anchor_lang::prelude::*; + +/// Charges collateral fees on an account +#[derive(Accounts)] +pub struct TokenChargeCollateralFees<'info> { + pub group: AccountLoader<'info, Group>, + + #[account( + mut, + has_one = group, + constraint = account.load()?.is_operational() @ MangoError::AccountIsFrozen + )] + pub account: AccountLoader<'info, MangoAccountFixed>, +} diff --git a/programs/mango-v4/src/instructions/group_edit.rs b/programs/mango-v4/src/instructions/group_edit.rs index 7eb631f61..e74df1c2a 100644 --- a/programs/mango-v4/src/instructions/group_edit.rs +++ b/programs/mango-v4/src/instructions/group_edit.rs @@ -19,6 +19,7 @@ pub fn group_edit( mngo_token_index_opt: Option, buyback_fees_expiry_interval_opt: Option, allowed_fast_listings_per_interval_opt: Option, + collateral_fee_interval_opt: Option, ) -> Result<()> { let mut group = ctx.accounts.group.load_mut()?; @@ -116,5 +117,14 @@ pub fn group_edit( group.allowed_fast_listings_per_interval = allowed_fast_listings_per_interval; } + if let Some(collateral_fee_interval) = collateral_fee_interval_opt { + msg!( + "Collateral fee interval old {:?}, new {:?}", + group.collateral_fee_interval, + collateral_fee_interval + ); + group.collateral_fee_interval = collateral_fee_interval; + } + Ok(()) } diff --git a/programs/mango-v4/src/instructions/mod.rs b/programs/mango-v4/src/instructions/mod.rs index 297eb5611..6a6dc9220 100644 --- a/programs/mango-v4/src/instructions/mod.rs +++ b/programs/mango-v4/src/instructions/mod.rs @@ -50,6 +50,7 @@ pub use stub_oracle_close::*; pub use stub_oracle_create::*; pub use stub_oracle_set::*; pub use token_add_bank::*; +pub use token_charge_collateral_fees::*; pub use token_conditional_swap_cancel::*; pub use token_conditional_swap_create::*; pub use token_conditional_swap_start::*; @@ -117,6 +118,7 @@ mod stub_oracle_close; mod stub_oracle_create; mod stub_oracle_set; mod token_add_bank; +mod token_charge_collateral_fees; mod token_conditional_swap_cancel; mod token_conditional_swap_create; mod token_conditional_swap_start; diff --git a/programs/mango-v4/src/instructions/token_charge_collateral_fees.rs b/programs/mango-v4/src/instructions/token_charge_collateral_fees.rs new file mode 100644 index 000000000..028a287ff --- /dev/null +++ b/programs/mango-v4/src/instructions/token_charge_collateral_fees.rs @@ -0,0 +1,111 @@ +use crate::accounts_zerocopy::*; +use crate::health::*; +use crate::state::*; +use anchor_lang::prelude::*; +use fixed::types::I80F48; + +use crate::accounts_ix::*; +use crate::logs::{emit_stack, TokenCollateralFeeLog}; + +pub fn token_charge_collateral_fees(ctx: Context) -> Result<()> { + let group = ctx.accounts.group.load()?; + let mut account = ctx.accounts.account.load_full_mut()?; + let now_ts: u64 = Clock::get()?.unix_timestamp.try_into().unwrap(); + + if group.collateral_fee_interval == 0 { + // By resetting, a new enabling of collateral fees will not immediately create a charge + account.fixed.last_collateral_fee_charge = 0; + return Ok(()); + } + + // When collateral fees are enabled the first time, don't immediately charge + if account.fixed.last_collateral_fee_charge == 0 { + account.fixed.last_collateral_fee_charge = now_ts; + return Ok(()); + } + + // Is the next fee-charging due? + let last_charge_ts = account.fixed.last_collateral_fee_charge; + if now_ts < last_charge_ts + group.collateral_fee_interval { + return Ok(()); + } + account.fixed.last_collateral_fee_charge = now_ts; + + // Charge the user at most for 2x the interval. So if no one calls this for a long time + // there won't be a huge charge based only on the end state. + let charge_seconds = (now_ts - last_charge_ts).min(2 * group.collateral_fee_interval); + + // The fees are configured in "interest per day" so we need to get the fraction of days + // that has passed since the last update for scaling + let inv_seconds_per_day = I80F48::from_num(1.157407407407e-5); // 1 / (24 * 60 * 60) + let time_scaling = I80F48::from(charge_seconds) * inv_seconds_per_day; + + let health_cache = { + let retriever = + new_fixed_order_account_retriever(ctx.remaining_accounts, &account.borrow())?; + new_health_cache(&account.borrow(), &retriever, now_ts)? + }; + + // We want to find the total asset health and total liab health, but don't want + // to treat borrows that moved into open orders accounts as realized. Hence we + // pretend all spot orders are closed and settled and add their funds back to + // the token positions. + let mut token_balances = health_cache.effective_token_balances(HealthType::Maint); + for s3info in health_cache.serum3_infos.iter() { + token_balances[s3info.base_info_index].spot_and_perp += s3info.reserved_base; + token_balances[s3info.quote_info_index].spot_and_perp += s3info.reserved_quote; + } + + let mut total_liab_health = I80F48::ZERO; + let mut total_asset_health = I80F48::ZERO; + for (info, balance) in health_cache.token_infos.iter().zip(token_balances.iter()) { + let health = info.health_contribution(HealthType::Maint, balance.spot_and_perp); + if health.is_positive() { + total_asset_health += health; + } else { + total_liab_health -= health; + } + } + + // Users only pay for assets that are actively used to cover their liabilities. + let asset_usage_scaling = (total_liab_health / total_asset_health) + .max(I80F48::ZERO) + .min(I80F48::ONE); + + let scaling = asset_usage_scaling * time_scaling; + + let token_position_count = account.active_token_positions().count(); + for bank_ai in &ctx.remaining_accounts[0..token_position_count] { + let mut bank = bank_ai.load_mut::()?; + if bank.collateral_fee_per_day <= 0.0 { + continue; + } + + let (token_position, raw_token_index) = account.token_position_mut(bank.token_index)?; + let token_balance = token_position.native(&bank); + if token_balance <= 0 { + continue; + } + + let fee = token_balance * scaling * I80F48::from_num(bank.collateral_fee_per_day); + assert!(fee <= token_balance); + + let is_active = bank.withdraw_without_fee(token_position, fee, now_ts)?; + if !is_active { + account.deactivate_token_position_and_log(raw_token_index, ctx.accounts.account.key()); + } + + bank.collected_fees_native += fee; + bank.collected_collateral_fees += fee; + + emit_stack(TokenCollateralFeeLog { + mango_group: ctx.accounts.group.key(), + mango_account: ctx.accounts.account.key(), + token_index: bank.token_index, + fee: fee.to_bits(), + asset_usage_fraction: asset_usage_scaling.to_bits(), + }) + } + + Ok(()) +} diff --git a/programs/mango-v4/src/instructions/token_edit.rs b/programs/mango-v4/src/instructions/token_edit.rs index e98d65014..8cde77def 100644 --- a/programs/mango-v4/src/instructions/token_edit.rs +++ b/programs/mango-v4/src/instructions/token_edit.rs @@ -54,6 +54,7 @@ pub fn token_edit( zero_util_rate: Option, platform_liquidation_fee: Option, disable_asset_liquidation_opt: Option, + collateral_fee_per_day: Option, ) -> Result<()> { let group = ctx.accounts.group.load()?; @@ -483,7 +484,21 @@ pub fn token_edit( platform_liquidation_fee ); bank.platform_liquidation_fee = I80F48::from_num(platform_liquidation_fee); - require_group_admin = true; + if platform_liquidation_fee != 0.0 { + require_group_admin = true; + } + } + + if let Some(collateral_fee_per_day) = collateral_fee_per_day { + msg!( + "Collateral fee per day old {:?}, new {:?}", + bank.collateral_fee_per_day, + collateral_fee_per_day + ); + bank.collateral_fee_per_day = collateral_fee_per_day; + if collateral_fee_per_day != 0.0 { + require_group_admin = true; + } } if let Some(disable_asset_liquidation) = disable_asset_liquidation_opt { diff --git a/programs/mango-v4/src/instructions/token_register.rs b/programs/mango-v4/src/instructions/token_register.rs index d1a82adb8..252ca5670 100644 --- a/programs/mango-v4/src/instructions/token_register.rs +++ b/programs/mango-v4/src/instructions/token_register.rs @@ -45,6 +45,7 @@ pub fn token_register( zero_util_rate: f32, platform_liquidation_fee: f32, disable_asset_liquidation: bool, + collateral_fee_per_day: f32, ) -> Result<()> { // Require token 0 to be in the insurance token if token_index == INSURANCE_TOKEN_INDEX { @@ -129,7 +130,9 @@ pub fn token_register( zero_util_rate: I80F48::from_num(zero_util_rate), platform_liquidation_fee: I80F48::from_num(platform_liquidation_fee), collected_liquidation_fees: I80F48::ZERO, - reserved: [0; 1920], + collected_collateral_fees: I80F48::ZERO, + collateral_fee_per_day, + reserved: [0; 1900], }; let oracle_ref = &AccountInfoRef::borrow(ctx.accounts.oracle.as_ref())?; diff --git a/programs/mango-v4/src/instructions/token_register_trustless.rs b/programs/mango-v4/src/instructions/token_register_trustless.rs index 54c5fdc78..06f3526e1 100644 --- a/programs/mango-v4/src/instructions/token_register_trustless.rs +++ b/programs/mango-v4/src/instructions/token_register_trustless.rs @@ -108,7 +108,9 @@ pub fn token_register_trustless( deposit_limit: 0, zero_util_rate: I80F48::ZERO, collected_liquidation_fees: I80F48::ZERO, - reserved: [0; 1920], + collected_collateral_fees: I80F48::ZERO, + collateral_fee_per_day: 0.0, // TODO + reserved: [0; 1900], }; let oracle_ref = &AccountInfoRef::borrow(ctx.accounts.oracle.as_ref())?; if let Ok(oracle_price) = bank.oracle_price(&OracleAccountInfos::from_reader(oracle_ref), None) diff --git a/programs/mango-v4/src/lib.rs b/programs/mango-v4/src/lib.rs index e3ef47a95..73246fab7 100644 --- a/programs/mango-v4/src/lib.rs +++ b/programs/mango-v4/src/lib.rs @@ -84,6 +84,7 @@ pub mod mango_v4 { mngo_token_index_opt: Option, buyback_fees_expiry_interval_opt: Option, allowed_fast_listings_per_interval_opt: Option, + collateral_fee_interval_opt: Option, ) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::group_edit( @@ -100,6 +101,7 @@ pub mod mango_v4 { mngo_token_index_opt, buyback_fees_expiry_interval_opt, allowed_fast_listings_per_interval_opt, + collateral_fee_interval_opt, )?; Ok(()) } @@ -158,6 +160,7 @@ pub mod mango_v4 { zero_util_rate: f32, platform_liquidation_fee: f32, disable_asset_liquidation: bool, + collateral_fee_per_day: f32, ) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::token_register( @@ -192,6 +195,7 @@ pub mod mango_v4 { zero_util_rate, platform_liquidation_fee, disable_asset_liquidation, + collateral_fee_per_day, )?; Ok(()) } @@ -248,6 +252,7 @@ pub mod mango_v4 { zero_util_rate_opt: Option, platform_liquidation_fee_opt: Option, disable_asset_liquidation_opt: Option, + collateral_fee_per_day_opt: Option, ) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::token_edit( @@ -291,6 +296,7 @@ pub mod mango_v4 { zero_util_rate_opt, platform_liquidation_fee_opt, disable_asset_liquidation_opt, + collateral_fee_per_day_opt, )?; Ok(()) } @@ -1609,6 +1615,12 @@ pub mod mango_v4 { Ok(()) } + pub fn token_charge_collateral_fees(ctx: Context) -> Result<()> { + #[cfg(feature = "enable-gpl")] + instructions::token_charge_collateral_fees(ctx)?; + Ok(()) + } + pub fn alt_set(ctx: Context, index: u8) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::alt_set(ctx, index)?; diff --git a/programs/mango-v4/src/logs.rs b/programs/mango-v4/src/logs.rs index d920e04d6..bf9d699d6 100644 --- a/programs/mango-v4/src/logs.rs +++ b/programs/mango-v4/src/logs.rs @@ -779,3 +779,12 @@ pub struct TokenConditionalSwapStartLog { pub incentive_token_index: u16, pub incentive_amount: u64, } + +#[event] +pub struct TokenCollateralFeeLog { + pub mango_group: Pubkey, + pub mango_account: Pubkey, + pub token_index: u16, + pub asset_usage_fraction: i128, + pub fee: i128, +} diff --git a/programs/mango-v4/src/state/bank.rs b/programs/mango-v4/src/state/bank.rs index cafd731cb..8625d85a3 100644 --- a/programs/mango-v4/src/state/bank.rs +++ b/programs/mango-v4/src/state/bank.rs @@ -221,8 +221,16 @@ pub struct Bank { /// See also collected_fees_native and fees_withdrawn. pub collected_liquidation_fees: I80F48, + /// Collateral fees that have been collected (in native tokens) + /// + /// See also collected_fees_native and fees_withdrawn. + pub collected_collateral_fees: I80F48, + + /// The daily collateral fees rate for fully utilized collateral. + pub collateral_fee_per_day: f32, + #[derivative(Debug = "ignore")] - pub reserved: [u8; 1920], + pub reserved: [u8; 1900], } const_assert_eq!( size_of::(), @@ -259,8 +267,9 @@ const_assert_eq!( + 16 * 3 + 32 + 8 - + 16 * 3 - + 1920 + + 16 * 4 + + 4 + + 1900 ); const_assert_eq!(size_of::(), 3064); const_assert_eq!(size_of::() % 8, 0); @@ -304,6 +313,7 @@ impl Bank { indexed_borrows: I80F48::ZERO, collected_fees_native: I80F48::ZERO, collected_liquidation_fees: I80F48::ZERO, + collected_collateral_fees: I80F48::ZERO, fees_withdrawn: 0, dust: I80F48::ZERO, flash_loan_approved_amount: 0, @@ -368,7 +378,8 @@ impl Bank { deposit_limit: existing_bank.deposit_limit, zero_util_rate: existing_bank.zero_util_rate, platform_liquidation_fee: existing_bank.platform_liquidation_fee, - reserved: [0; 1920], + collateral_fee_per_day: existing_bank.collateral_fee_per_day, + reserved: [0; 1900], } } @@ -405,6 +416,7 @@ impl Bank { require!(self.are_borrows_reduce_only(), MangoError::SomeError); require_eq!(self.maint_asset_weight, I80F48::ZERO); } + require_gte!(self.collateral_fee_per_day, 0.0); Ok(()) } diff --git a/programs/mango-v4/src/state/group.rs b/programs/mango-v4/src/state/group.rs index b0e55a987..60812b280 100644 --- a/programs/mango-v4/src/state/group.rs +++ b/programs/mango-v4/src/state/group.rs @@ -98,11 +98,32 @@ pub struct Group { /// Number of fast listings that are allowed per interval pub allowed_fast_listings_per_interval: u16, - pub reserved: [u8; 1812], + pub padding2: [u8; 4], + + /// Intervals in which collateral fee is applied + pub collateral_fee_interval: u64, + + pub reserved: [u8; 1800], } const_assert_eq!( size_of::(), - 32 + 4 + 32 * 2 + 4 + 32 * 2 + 4 + 4 + 20 * 32 + 32 + 8 + 16 + 32 + 8 + 8 + 2 * 2 + 1812 + 32 + 4 + + 32 * 2 + + 4 + + 32 * 2 + + 4 + + 4 + + 20 * 32 + + 32 + + 8 + + 16 + + 32 + + 8 + + 8 + + 2 * 2 + + 4 + + 8 + + 1800 ); const_assert_eq!(size_of::(), 2736); const_assert_eq!(size_of::() % 8, 0); diff --git a/programs/mango-v4/src/state/mango_account.rs b/programs/mango-v4/src/state/mango_account.rs index ac8c215ef..fbfbe1bf6 100644 --- a/programs/mango-v4/src/state/mango_account.rs +++ b/programs/mango-v4/src/state/mango_account.rs @@ -151,8 +151,14 @@ pub struct MangoAccount { /// Next id to use when adding a token condition swap pub next_token_conditional_swap_id: u64, + pub temporary_delegate: Pubkey, + pub temporary_delegate_expiry: u64, + + /// Time at which the last collateral fee was charged + pub last_collateral_fee_charge: u64, + #[derivative(Debug = "ignore")] - pub reserved: [u8; 200], + pub reserved: [u8; 152], // dynamic pub header_version: u8, @@ -203,7 +209,10 @@ impl MangoAccount { buyback_fees_accrued_previous: 0, buyback_fees_expiry_timestamp: 0, next_token_conditional_swap_id: 0, - reserved: [0; 200], + temporary_delegate: Pubkey::default(), + temporary_delegate_expiry: 0, + last_collateral_fee_charge: 0, + reserved: [0; 152], header_version: DEFAULT_MANGO_ACCOUNT_VERSION, padding3: Default::default(), padding4: Default::default(), @@ -327,11 +336,12 @@ pub struct MangoAccountFixed { pub next_token_conditional_swap_id: u64, pub temporary_delegate: Pubkey, pub temporary_delegate_expiry: u64, - pub reserved: [u8; 160], + pub last_collateral_fee_charge: u64, + pub reserved: [u8; 152], } const_assert_eq!( size_of::(), - 32 * 4 + 8 + 8 * 8 + 32 + 8 + 160 + 32 * 4 + 8 + 8 * 8 + 32 + 8 + 8 + 152 ); const_assert_eq!(size_of::(), 400); const_assert_eq!(size_of::() % 8, 0); diff --git a/programs/mango-v4/tests/cases/mod.rs b/programs/mango-v4/tests/cases/mod.rs index 3b6d5324c..3d2c3b175 100644 --- a/programs/mango-v4/tests/cases/mod.rs +++ b/programs/mango-v4/tests/cases/mod.rs @@ -17,6 +17,7 @@ mod test_bankrupt_tokens; mod test_basic; mod test_benchmark; mod test_borrow_limits; +mod test_collateral_fees; mod test_delegate; mod test_fees_buyback_with_mngo; mod test_force_close; diff --git a/programs/mango-v4/tests/cases/test_collateral_fees.rs b/programs/mango-v4/tests/cases/test_collateral_fees.rs new file mode 100644 index 000000000..b7fbb3d6c --- /dev/null +++ b/programs/mango-v4/tests/cases/test_collateral_fees.rs @@ -0,0 +1,171 @@ +use super::*; + +#[tokio::test] +async fn test_collateral_fees() -> Result<(), TransportError> { + let context = TestContext::new().await; + let solana = &context.solana.clone(); + + let admin = TestKeypair::new(); + let owner = context.users[0].key; + let payer = context.users[1].key; + let mints = &context.mints[0..2]; + + let mango_setup::GroupWithTokens { group, tokens, .. } = mango_setup::GroupWithTokensConfig { + admin, + payer, + mints: mints.to_vec(), + ..mango_setup::GroupWithTokensConfig::default() + } + .create(solana) + .await; + + // fund the vaults to allow borrowing + create_funded_account( + &solana, + group, + owner, + 0, + &context.users[1], + mints, + 1_000_000, + 0, + ) + .await; + + let account = create_funded_account( + &solana, + group, + owner, + 1, + &context.users[1], + &mints[0..1], + 1_500, // maint: 0.8 * 1500 = 1200 + 0, + ) + .await; + + let hour = 60 * 60; + + send_tx( + solana, + GroupEdit { + group, + admin, + options: mango_v4::instruction::GroupEdit { + collateral_fee_interval_opt: Some(6 * hour), + ..group_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); + + send_tx( + solana, + TokenEdit { + group, + admin, + mint: mints[0].pubkey, + fallback_oracle: Pubkey::default(), + options: mango_v4::instruction::TokenEdit { + collateral_fee_per_day_opt: Some(0.1), + ..token_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); + + send_tx( + solana, + TokenEdit { + group, + admin, + mint: mints[1].pubkey, + fallback_oracle: Pubkey::default(), + options: mango_v4::instruction::TokenEdit { + loan_origination_fee_rate_opt: Some(0.0), + ..token_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); + + // + // TEST: Without borrows, charging collateral fees has no effect + // + + send_tx(solana, TokenChargeCollateralFeesInstruction { account }) + .await + .unwrap(); + let mut last_time = solana.clock_timestamp().await; + // no effect + assert_eq!( + account_position(solana, account, tokens[0].bank).await, + 1_500 + ); + + // + // TEST: With borrows, there's an effect depending on the time that has passed + // + + send_tx( + solana, + TokenWithdrawInstruction { + amount: 500, // maint: -1.2 * 500 = -600 (half of 1200) + allow_borrow: true, + account, + owner, + token_account: context.users[1].token_accounts[1], + bank_index: 0, + }, + ) + .await + .unwrap(); + + solana.set_clock_timestamp(last_time + 9 * hour).await; + + send_tx(solana, TokenChargeCollateralFeesInstruction { account }) + .await + .unwrap(); + last_time = solana.clock_timestamp().await; + assert!(assert_equal_f64_f64( + account_position_f64(solana, account, tokens[0].bank).await, + 1500.0 * (1.0 - 0.1 * (9.0 / 24.0) * (600.0 / 1200.0)), + 0.01 + )); + let last_balance = account_position_f64(solana, account, tokens[0].bank).await; + + // + // TEST: More borrows + // + + send_tx( + solana, + TokenWithdrawInstruction { + amount: 100, // maint: -1.2 * 600 = -720 + allow_borrow: true, + account, + owner, + token_account: context.users[1].token_accounts[1], + bank_index: 0, + }, + ) + .await + .unwrap(); + + solana.set_clock_timestamp(last_time + 7 * hour).await; + + send_tx(solana, TokenChargeCollateralFeesInstruction { account }) + .await + .unwrap(); + //last_time = solana.clock_timestamp().await; + assert!(assert_equal_f64_f64( + account_position_f64(solana, account, tokens[0].bank).await, + last_balance * (1.0 - 0.1 * (7.0 / 24.0) * (720.0 / (last_balance * 0.8))), + 0.01 + )); + + Ok(()) +} diff --git a/programs/mango-v4/tests/program_test/mango_client.rs b/programs/mango-v4/tests/program_test/mango_client.rs index e3a3c23cd..178508b4b 100644 --- a/programs/mango-v4/tests/program_test/mango_client.rs +++ b/programs/mango-v4/tests/program_test/mango_client.rs @@ -1078,6 +1078,7 @@ impl ClientInstruction for TokenRegisterInstruction { zero_util_rate: 0.0, platform_liquidation_fee: self.platform_liquidation_fee, disable_asset_liquidation: false, + collateral_fee_per_day: 0.0, }; let bank = Pubkey::find_program_address( @@ -1326,6 +1327,7 @@ pub fn token_edit_instruction_default() -> mango_v4::instruction::TokenEdit { zero_util_rate_opt: None, platform_liquidation_fee_opt: None, disable_asset_liquidation_opt: None, + collateral_fee_per_day_opt: None, } } @@ -1844,6 +1846,7 @@ pub fn group_edit_instruction_default() -> mango_v4::instruction::GroupEdit { mngo_token_index_opt: None, buyback_fees_expiry_interval_opt: None, allowed_fast_listings_per_interval_opt: None, + collateral_fee_interval_opt: None, } } @@ -5038,3 +5041,48 @@ impl ClientInstruction for TokenConditionalSwapStartInstruction { vec![self.liqor_owner] } } + +#[derive(Clone)] +pub struct TokenChargeCollateralFeesInstruction { + pub account: Pubkey, +} +#[async_trait::async_trait(?Send)] +impl ClientInstruction for TokenChargeCollateralFeesInstruction { + type Accounts = mango_v4::accounts::TokenChargeCollateralFees; + type Instruction = mango_v4::instruction::TokenChargeCollateralFees; + async fn to_instruction( + &self, + account_loader: impl ClientAccountLoader + 'async_trait, + ) -> (Self::Accounts, instruction::Instruction) { + let program_id = mango_v4::id(); + + let account = account_loader + .load_mango_account(&self.account) + .await + .unwrap(); + + let instruction = Self::Instruction {}; + + let health_check_metas = derive_health_check_remaining_account_metas( + &account_loader, + &account, + None, + true, + None, + ) + .await; + + let accounts = Self::Accounts { + group: account.fixed.group, + account: self.account, + }; + + let mut instruction = make_instruction(program_id, &accounts, &instruction); + instruction.accounts.extend(health_check_metas.into_iter()); + (accounts, instruction) + } + + fn signers(&self) -> Vec { + vec![] + } +} diff --git a/ts/client/src/accounts/bank.ts b/ts/client/src/accounts/bank.ts index 9f4408c09..d88b24ae1 100644 --- a/ts/client/src/accounts/bank.ts +++ b/ts/client/src/accounts/bank.ts @@ -83,6 +83,7 @@ export class Bank implements BankForHealth { public zeroUtilRate: I80F48; public platformLiquidationFee: I80F48; public collectedLiquidationFees: I80F48; + public collectedCollateralFees: I80F48; static from( publicKey: PublicKey, @@ -148,6 +149,8 @@ export class Bank implements BankForHealth { zeroUtilRate: I80F48Dto; platformLiquidationFee: I80F48Dto; collectedLiquidationFees: I80F48Dto; + collectedCollateralFees: I80F48Dto; + collateralFeePerDay: number; }, ): Bank { return new Bank( @@ -213,6 +216,8 @@ export class Bank implements BankForHealth { obj.platformLiquidationFee, obj.collectedLiquidationFees, obj.disableAssetLiquidation == 0, + obj.collectedCollateralFees, + obj.collateralFeePerDay, ); } @@ -279,6 +284,8 @@ export class Bank implements BankForHealth { platformLiquidationFee: I80F48Dto, collectedLiquidationFees: I80F48Dto, public allowAssetLiquidation: boolean, + collectedCollateralFees: I80F48Dto, + public collateralFeePerDay: number, ) { this.name = utf8.decode(new Uint8Array(name)).split('\x00')[0]; this.oracleConfig = { @@ -311,6 +318,7 @@ export class Bank implements BankForHealth { this.zeroUtilRate = I80F48.from(zeroUtilRate); this.platformLiquidationFee = I80F48.from(platformLiquidationFee); this.collectedLiquidationFees = I80F48.from(collectedLiquidationFees); + this.collectedCollateralFees = I80F48.from(collectedCollateralFees); this._price = undefined; this._uiPrice = undefined; this._oracleLastUpdatedSlot = undefined; diff --git a/ts/client/src/accounts/group.ts b/ts/client/src/accounts/group.ts index 9873a9e77..9524094dd 100644 --- a/ts/client/src/accounts/group.ts +++ b/ts/client/src/accounts/group.ts @@ -50,6 +50,7 @@ export class Group { fastListingIntervalStart: BN; fastListingsInInterval: number; allowedFastListingsPerInterval: number; + collateralFeeInterval: BN; }, ): Group { return new Group( @@ -74,6 +75,7 @@ export class Group { obj.fastListingIntervalStart, obj.fastListingsInInterval, obj.allowedFastListingsPerInterval, + obj.collateralFeeInterval, [], // addressLookupTablesList new Map(), // banksMapByName new Map(), // banksMapByMint @@ -113,6 +115,7 @@ export class Group { public fastListingIntervalStart: BN, public fastListingsInInterval: number, public allowedFastListingsPerInterval: number, + public collateralFeeInterval: BN, public addressLookupTablesList: AddressLookupTableAccount[], public banksMapByName: Map, public banksMapByMint: Map, diff --git a/ts/client/src/client.ts b/ts/client/src/client.ts index 24067689a..1ffc79c50 100644 --- a/ts/client/src/client.ts +++ b/ts/client/src/client.ts @@ -304,6 +304,7 @@ export class MangoClient { feesMngoTokenIndex?: TokenIndex, feesExpiryInterval?: BN, allowedFastListingsPerInterval?: number, + collateralFeeInterval?: BN, ): Promise { const ix = await this.program.methods .groupEdit( @@ -319,6 +320,7 @@ export class MangoClient { feesMngoTokenIndex ?? null, feesExpiryInterval ?? null, allowedFastListingsPerInterval ?? null, + collateralFeeInterval ?? null, ) .accounts({ group: group.publicKey, @@ -462,6 +464,7 @@ export class MangoClient { params.zeroUtilRate, params.platformLiquidationFee, params.disableAssetLiquidation, + params.collateralFeePerDay, ) .accounts({ group: group.publicKey, @@ -550,6 +553,7 @@ export class MangoClient { params.zeroUtilRate, params.platformLiquidationFee, params.disableAssetLiquidation, + params.collateralFeePerDay, ) .accounts({ group: group.publicKey, diff --git a/ts/client/src/clientIxParamBuilder.ts b/ts/client/src/clientIxParamBuilder.ts index 20b83fe33..dc140db2e 100644 --- a/ts/client/src/clientIxParamBuilder.ts +++ b/ts/client/src/clientIxParamBuilder.ts @@ -31,6 +31,7 @@ export interface TokenRegisterParams { zeroUtilRate: number; platformLiquidationFee: number; disableAssetLiquidation: boolean; + collateralFeePerDay: number; } export const DefaultTokenRegisterParams: TokenRegisterParams = { @@ -72,6 +73,7 @@ export const DefaultTokenRegisterParams: TokenRegisterParams = { zeroUtilRate: 0.0, platformLiquidationFee: 0.0, disableAssetLiquidation: false, + collateralFeePerDay: 0.0, }; export interface TokenEditParams { @@ -114,6 +116,7 @@ export interface TokenEditParams { zeroUtilRate: number | null; platformLiquidationFee: number | null; disableAssetLiquidation: boolean | null; + collateralFeePerDay: number | null; } export const NullTokenEditParams: TokenEditParams = { @@ -156,6 +159,7 @@ export const NullTokenEditParams: TokenEditParams = { zeroUtilRate: null, platformLiquidationFee: null, disableAssetLiquidation: null, + collateralFeePerDay: null, }; export interface PerpEditParams { diff --git a/ts/client/src/mango_v4.ts b/ts/client/src/mango_v4.ts index ee363dabb..33e07c805 100644 --- a/ts/client/src/mango_v4.ts +++ b/ts/client/src/mango_v4.ts @@ -277,6 +277,12 @@ export type MangoV4 = { "type": { "option": "u16" } + }, + { + "name": "collateralFeeIntervalOpt", + "type": { + "option": "u64" + } } ] }, @@ -635,6 +641,10 @@ export type MangoV4 = { { "name": "disableAssetLiquidation", "type": "bool" + }, + { + "name": "collateralFeePerDay", + "type": "f32" } ] }, @@ -1051,6 +1061,12 @@ export type MangoV4 = { "type": { "option": "bool" } + }, + { + "name": "collateralFeePerDayOpt", + "type": { + "option": "f32" + } } ] }, @@ -5963,6 +5979,25 @@ export type MangoV4 = { } ] }, + { + "name": "tokenChargeCollateralFees", + "accounts": [ + { + "name": "group", + "isMut": false, + "isSigner": false + }, + { + "name": "account", + "isMut": true, + "isSigner": false, + "relations": [ + "group" + ] + } + ], + "args": [] + }, { "name": "altSet", "accounts": [ @@ -7531,12 +7566,30 @@ export type MangoV4 = { "defined": "I80F48" } }, + { + "name": "collectedCollateralFees", + "docs": [ + "Collateral fees that have been collected (in native tokens)", + "", + "See also collected_fees_native and fees_withdrawn." + ], + "type": { + "defined": "I80F48" + } + }, + { + "name": "collateralFeePerDay", + "docs": [ + "The daily collateral fees rate for fully utilized collateral." + ], + "type": "f32" + }, { "name": "reserved", "type": { "array": [ "u8", - 1920 + 1900 ] } } @@ -7664,12 +7717,28 @@ export type MangoV4 = { ], "type": "u16" }, + { + "name": "padding2", + "type": { + "array": [ + "u8", + 4 + ] + } + }, + { + "name": "collateralFeeInterval", + "docs": [ + "Intervals in which collateral fee is applied" + ], + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 1812 + 1800 ] } } @@ -7791,12 +7860,27 @@ export type MangoV4 = { ], "type": "u64" }, + { + "name": "temporaryDelegate", + "type": "publicKey" + }, + { + "name": "temporaryDelegateExpiry", + "type": "u64" + }, + { + "name": "lastCollateralFeeCharge", + "docs": [ + "Time at which the last collateral fee was charged" + ], + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 200 + 152 ] } }, @@ -9566,12 +9650,16 @@ export type MangoV4 = { "name": "temporaryDelegateExpiry", "type": "u64" }, + { + "name": "lastCollateralFeeCharge", + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 160 + 152 ] } } @@ -13699,6 +13787,36 @@ export type MangoV4 = { "index": false } ] + }, + { + "name": "TokenCollateralFeeLog", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "mangoAccount", + "type": "publicKey", + "index": false + }, + { + "name": "tokenIndex", + "type": "u16", + "index": false + }, + { + "name": "assetUsageFraction", + "type": "i128", + "index": false + }, + { + "name": "fee", + "type": "i128", + "index": false + } + ] } ], "errors": [ @@ -14334,6 +14452,12 @@ export const IDL: MangoV4 = { "type": { "option": "u16" } + }, + { + "name": "collateralFeeIntervalOpt", + "type": { + "option": "u64" + } } ] }, @@ -14692,6 +14816,10 @@ export const IDL: MangoV4 = { { "name": "disableAssetLiquidation", "type": "bool" + }, + { + "name": "collateralFeePerDay", + "type": "f32" } ] }, @@ -15108,6 +15236,12 @@ export const IDL: MangoV4 = { "type": { "option": "bool" } + }, + { + "name": "collateralFeePerDayOpt", + "type": { + "option": "f32" + } } ] }, @@ -20020,6 +20154,25 @@ export const IDL: MangoV4 = { } ] }, + { + "name": "tokenChargeCollateralFees", + "accounts": [ + { + "name": "group", + "isMut": false, + "isSigner": false + }, + { + "name": "account", + "isMut": true, + "isSigner": false, + "relations": [ + "group" + ] + } + ], + "args": [] + }, { "name": "altSet", "accounts": [ @@ -21588,12 +21741,30 @@ export const IDL: MangoV4 = { "defined": "I80F48" } }, + { + "name": "collectedCollateralFees", + "docs": [ + "Collateral fees that have been collected (in native tokens)", + "", + "See also collected_fees_native and fees_withdrawn." + ], + "type": { + "defined": "I80F48" + } + }, + { + "name": "collateralFeePerDay", + "docs": [ + "The daily collateral fees rate for fully utilized collateral." + ], + "type": "f32" + }, { "name": "reserved", "type": { "array": [ "u8", - 1920 + 1900 ] } } @@ -21721,12 +21892,28 @@ export const IDL: MangoV4 = { ], "type": "u16" }, + { + "name": "padding2", + "type": { + "array": [ + "u8", + 4 + ] + } + }, + { + "name": "collateralFeeInterval", + "docs": [ + "Intervals in which collateral fee is applied" + ], + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 1812 + 1800 ] } } @@ -21848,12 +22035,27 @@ export const IDL: MangoV4 = { ], "type": "u64" }, + { + "name": "temporaryDelegate", + "type": "publicKey" + }, + { + "name": "temporaryDelegateExpiry", + "type": "u64" + }, + { + "name": "lastCollateralFeeCharge", + "docs": [ + "Time at which the last collateral fee was charged" + ], + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 200 + 152 ] } }, @@ -23623,12 +23825,16 @@ export const IDL: MangoV4 = { "name": "temporaryDelegateExpiry", "type": "u64" }, + { + "name": "lastCollateralFeeCharge", + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 160 + 152 ] } } @@ -27756,6 +27962,36 @@ export const IDL: MangoV4 = { "index": false } ] + }, + { + "name": "TokenCollateralFeeLog", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "mangoAccount", + "type": "publicKey", + "index": false + }, + { + "name": "tokenIndex", + "type": "u16", + "index": false + }, + { + "name": "assetUsageFraction", + "type": "i128", + "index": false + }, + { + "name": "fee", + "type": "i128", + "index": false + } + ] } ], "errors": [