diff --git a/bin/liquidator/src/liquidate.rs b/bin/liquidator/src/liquidate.rs index d754bb6cd..e551c39a6 100644 --- a/bin/liquidator/src/liquidate.rs +++ b/bin/liquidator/src/liquidate.rs @@ -309,7 +309,7 @@ impl<'a> LiquidateHelper<'a> { // TODO: This is where we could multiply in the liquidation fee factors let price = source_price / target_price; - util::max_swap_source( + util::max_swap_source_ignoring_limits( self.client, self.account_fetcher, &liqor, diff --git a/bin/liquidator/src/trigger_tcs.rs b/bin/liquidator/src/trigger_tcs.rs index f0e683b13..96e3267d9 100644 --- a/bin/liquidator/src/trigger_tcs.rs +++ b/bin/liquidator/src/trigger_tcs.rs @@ -26,10 +26,9 @@ use crate::{token_swap_info, util, ErrorTracking}; /// making the whole execution fail. const SLIPPAGE_BUFFER: f64 = 0.01; // 1% -/// If a tcs gets limited due to exhausted net borrows, don't trigger execution if -/// the possible value is below this amount. This avoids spamming executions when net -/// borrows are exhausted. -const NET_BORROW_EXECUTION_THRESHOLD: u64 = 1_000_000; // 1 USD +/// If a tcs gets limited due to exhausted net borrows or deposit limits, don't trigger execution if +/// the possible value is below this amount. This avoids spamming executions when limits are exhausted. +const EXECUTION_THRESHOLD: u64 = 1_000_000; // 1 USD #[derive(Clone, Debug, PartialEq, Eq)] pub enum Mode { @@ -440,12 +439,17 @@ impl Context { /// This includes /// - tcs restrictions (remaining buy/sell, create borrows/deposits) /// - reduce only banks - /// - net borrow limits on BOTH sides, even though the buy side is technically - /// a liqor limitation: the liqor could acquire the token before trying the - /// execution... but in practice the liqor will work on margin + /// - net borrow limits: + /// - the account may borrow the sell token (and the liqor side may not be a repay) + /// - the liqor may borrow the buy token (and the account side may not be a repay) + /// this is technically a liqor limitation: the liqor could acquire the token before trying the + /// execution... but in practice the liqor may work on margin + /// - deposit limits: + /// - the account may deposit the buy token (while the liqor borrowed it) + /// - the liqor may deposit the sell token (while the account borrowed it) /// /// Returns Some((native buy amount, native sell amount)) if execution is sensible - /// Returns None if the execution should be skipped (due to net borrow limits...) + /// Returns None if the execution should be skipped (due to limits) pub fn tcs_max_liqee_execution( &self, account: &MangoAccountValue, @@ -458,18 +462,18 @@ impl Context { let premium_price = tcs.premium_price(base_price.to_num(), self.now_ts); let maker_price = tcs.maker_price(premium_price); - let buy_position = account + let liqee_buy_position = account .token_position(tcs.buy_token_index) .map(|p| p.native(&buy_bank)) .unwrap_or(I80F48::ZERO); - let sell_position = account + let liqee_sell_position = account .token_position(tcs.sell_token_index) .map(|p| p.native(&sell_bank)) .unwrap_or(I80F48::ZERO); // this is in "buy token received per sell token given" units let swap_price = I80F48::from_num((1.0 - SLIPPAGE_BUFFER) / maker_price); - let max_sell_ignoring_net_borrows = util::max_swap_source_ignore_net_borrows( + let max_sell_ignoring_limits = util::max_swap_source_ignoring_limits( &self.mango_client, &self.account_fetcher, account, @@ -480,41 +484,31 @@ impl Context { )? .floor() .to_num::() - .min(tcs.max_sell_for_position(sell_position, &sell_bank)); + .min(tcs.max_sell_for_position(liqee_sell_position, &sell_bank)); - let max_buy_ignoring_net_borrows = tcs.max_buy_for_position(buy_position, &buy_bank); + let max_buy_ignoring_limits = tcs.max_buy_for_position(liqee_buy_position, &buy_bank); - // What follows is a complex manual handling of net borrow limits, for the following reason: + // What follows is a complex manual handling of net borrow/deposit limits, for + // the following reason: // Usually, we want to execute tcs even for small amounts because that will close the // tcs order: either due to full execution or due to the health threshold being reached. // - // However, when the net borrow limits are hit, it will not closed when no further execution - // is possible, because net borrow limit issues are considered transient. Furthermore, we - // don't even want to send a tiny tcs trigger transactions, because there's a good chance we - // would then be sending lot of those as oracle prices fluctuate. + // However, when the limits are hit, it will not closed when no further execution + // is possible, because limit issues are transient. Furthermore, we don't want to send + // tiny tcs trigger transactions, because there's a good chance we would then be sending + // lot of those as oracle prices fluctuate. // // Thus, we need to detect if the possible execution amount is tiny _because_ of the - // net borrow limits. Then skip. If it's tiny for other reasons we can proceed. + // limits. Then skip. If it's tiny for other reasons we can proceed. - fn available_borrows(bank: &Bank, price: I80F48) -> u64 { - (bank.remaining_net_borrows_quote(price) / price).clamp_to_u64() - } - let available_buy_borrows = available_borrows(&buy_bank, buy_token_price); - let available_sell_borrows = available_borrows(&sell_bank, sell_token_price); - - // New borrows if max_sell_ignoring_net_borrows was withdrawn on the liqee - let sell_borrows = (I80F48::from(max_sell_ignoring_net_borrows) - - sell_position.max(I80F48::ZERO)) - .clamp_to_u64(); - - // On the buy side, the liqor might need to borrow - let buy_borrows = match self.config.mode { + // Do the liqor buy tokens come from deposits or are they borrowed? + let mut liqor_buy_borrows = match self.config.mode { Mode::BorrowBuyToken => { // Assume that the liqor has enough buy token if it's collateral if tcs.buy_token_index == self.config.collateral_token_index { 0 } else { - max_buy_ignoring_net_borrows + max_buy_ignoring_limits } } Mode::SwapCollateralIntoBuy { .. } => 0, @@ -525,19 +519,77 @@ impl Context { } }; - // New maximums adjusted for net borrow limits - let max_sell = - max_sell_ignoring_net_borrows - sell_borrows + sell_borrows.min(available_sell_borrows); - let max_buy = - max_buy_ignoring_net_borrows - buy_borrows + buy_borrows.min(available_buy_borrows); + // First, net borrow limits + let max_sell_net_borrows; + let max_buy_net_borrows; + { + fn available_borrows(bank: &Bank, price: I80F48) -> u64 { + bank.remaining_net_borrows_quote(price) + .saturating_div(price) + .clamp_to_u64() + } + let available_buy_borrows = available_borrows(&buy_bank, buy_token_price); + let available_sell_borrows = available_borrows(&sell_bank, sell_token_price); - let tiny_due_to_net_borrows = { - let buy_threshold = I80F48::from(NET_BORROW_EXECUTION_THRESHOLD) / buy_token_price; - let sell_threshold = I80F48::from(NET_BORROW_EXECUTION_THRESHOLD) / sell_token_price; - max_buy < buy_threshold && max_buy_ignoring_net_borrows > buy_threshold - || max_sell < sell_threshold && max_sell_ignoring_net_borrows > sell_threshold + // New borrows if max_sell_ignoring_limits was withdrawn on the liqee + // We assume that on the liqor side the position is >= 0, so these are true + // new borrows. + let sell_borrows = (I80F48::from(max_sell_ignoring_limits) + - liqee_sell_position.max(I80F48::ZERO)) + .ceil() + .clamp_to_u64(); + + // On the buy side, the liqor might need to borrow, see liqor_buy_borrows. + // On the liqee side, the bought tokens may repay a borrow, reducing net borrows again + let buy_borrows = (I80F48::from(liqor_buy_borrows) + + liqee_buy_position.min(I80F48::ZERO)) + .ceil() + .clamp_to_u64(); + + // New maximums adjusted for net borrow limits + max_sell_net_borrows = max_sell_ignoring_limits + - (sell_borrows - sell_borrows.min(available_sell_borrows)); + max_buy_net_borrows = + max_buy_ignoring_limits - (buy_borrows - buy_borrows.min(available_buy_borrows)); + liqor_buy_borrows = liqor_buy_borrows.min(max_buy_net_borrows); + } + + // Second, deposit limits + let max_sell; + let max_buy; + { + let available_buy_deposits = buy_bank.remaining_deposits_until_limit().clamp_to_u64(); + let available_sell_deposits = sell_bank.remaining_deposits_until_limit().clamp_to_u64(); + + // New deposits on the liqee side (reduced by repaid borrows) + let liqee_buy_deposits = (I80F48::from(max_buy_net_borrows) + + liqee_buy_position.min(I80F48::ZERO)) + .ceil() + .clamp_to_u64(); + // the net new deposits can only be as big as the liqor borrows + // (assume no borrows, then deposits only move from liqor to liqee) + let buy_deposits = liqee_buy_deposits.min(liqor_buy_borrows); + + // We assume the liqor position is always >= 0, meaning there are new sell token deposits if + // the sell token gets borrowed on the liqee side. + let sell_deposits = (I80F48::from(max_sell_net_borrows) + - liqee_sell_position.max(I80F48::ZERO)) + .ceil() + .clamp_to_u64(); + + max_sell = + max_sell_net_borrows - (sell_deposits - sell_deposits.min(available_sell_deposits)); + max_buy = + max_buy_net_borrows - (buy_deposits - buy_deposits.min(available_buy_deposits)); + } + + let tiny_due_to_limits = { + let buy_threshold = I80F48::from(EXECUTION_THRESHOLD) / buy_token_price; + let sell_threshold = I80F48::from(EXECUTION_THRESHOLD) / sell_token_price; + max_buy < buy_threshold && max_buy_ignoring_limits > buy_threshold + || max_sell < sell_threshold && max_sell_ignoring_limits > sell_threshold }; - if tiny_due_to_net_borrows { + if tiny_due_to_limits { return Ok(None); } @@ -715,7 +767,7 @@ impl Context { .0 .native(&buy_bank); let liqor_available_buy_token = match mode { - Mode::BorrowBuyToken => util::max_swap_source( + Mode::BorrowBuyToken => util::max_swap_source_with_limits( &self.mango_client, &self.account_fetcher, &liqor, @@ -734,7 +786,7 @@ impl Context { self.token_bank_price_mint(collateral_token_index)?; let buy_per_collateral_price = (collateral_price / buy_token_price) * I80F48::from_num(jupiter_slippage_fraction); - let collateral_amount = util::max_swap_source( + let collateral_amount = util::max_swap_source_with_limits( &self.mango_client, &self.account_fetcher, &liqor, @@ -751,7 +803,7 @@ impl Context { // How big can the sell -> buy swap be? let buy_per_sell_price = (I80F48::from(1) / taker_price) * I80F48::from_num(jupiter_slippage_fraction); - let max_sell = util::max_swap_source( + let max_sell = util::max_swap_source_with_limits( &self.mango_client, &self.account_fetcher, &liqor, diff --git a/bin/liquidator/src/util.rs b/bin/liquidator/src/util.rs index 44d9348af..fbd31ba0f 100644 --- a/bin/liquidator/src/util.rs +++ b/bin/liquidator/src/util.rs @@ -38,7 +38,9 @@ pub fn is_perp_market<'a>( } /// Convenience wrapper for getting max swap amounts for a token pair -pub fn max_swap_source( +/// +/// This applies net borrow and deposit limits, which is useful for true swaps. +pub fn max_swap_source_with_limits( client: &MangoClient, account_fetcher: &chain_data::AccountFetcher, account: &MangoAccountValue, @@ -66,7 +68,7 @@ pub fn max_swap_source( let source_price = health_cache.token_info(source).unwrap().prices.oracle; let amount = health_cache - .max_swap_source_for_health_ratio( + .max_swap_source_for_health_ratio_with_limits( &account, &source_bank, source_price, @@ -79,7 +81,10 @@ pub fn max_swap_source( } /// Convenience wrapper for getting max swap amounts for a token pair -pub fn max_swap_source_ignore_net_borrows( +/// +/// This is useful for liquidations, which don't increase deposits or net borrows. +/// Tcs execution can also increase deposits/net borrows. +pub fn max_swap_source_ignoring_limits( client: &MangoClient, account_fetcher: &chain_data::AccountFetcher, account: &MangoAccountValue, @@ -99,17 +104,15 @@ pub fn max_swap_source_ignore_net_borrows( mango_v4_client::health_cache::new_sync(&client.context, account_fetcher, &account) .expect("always ok"); - let mut source_bank: Bank = + let source_bank: Bank = account_fetcher.fetch(&client.context.mint_info(source).first_bank())?; - source_bank.net_borrow_limit_per_window_quote = -1; - let mut target_bank: Bank = + let target_bank: Bank = account_fetcher.fetch(&client.context.mint_info(target).first_bank())?; - target_bank.net_borrow_limit_per_window_quote = -1; let source_price = health_cache.token_info(source).unwrap().prices.oracle; let amount = health_cache - .max_swap_source_for_health_ratio( + .max_swap_source_for_health_ratio_ignoring_limits( &account, &source_bank, source_price, diff --git a/mango_v4.json b/mango_v4.json index 0a1b19fa2..24e9a8772 100644 --- a/mango_v4.json +++ b/mango_v4.json @@ -614,6 +614,10 @@ { "name": "groupInsuranceFund", "type": "bool" + }, + { + "name": "depositLimit", + "type": "u64" } ] }, @@ -988,6 +992,12 @@ { "name": "maintWeightShiftAbort", "type": "bool" + }, + { + "name": "depositLimitOpt", + "type": { + "option": "u64" + } } ] }, @@ -2357,6 +2367,10 @@ { "name": "name", "type": "string" + }, + { + "name": "oraclePriceBand", + "type": "f32" } ] }, @@ -2400,6 +2414,12 @@ "type": { "option": "string" } + }, + { + "name": "oraclePriceBandOpt", + "type": { + "option": "f32" + } } ] }, @@ -2742,6 +2762,9 @@ }, { "name": "serum3PlaceOrderV2", + "docs": [ + "requires the receiver_bank in the health account list to be writable" + ], "accounts": [ { "name": "group", @@ -7266,11 +7289,9 @@ "name": "potentialSerumTokens", "docs": [ "Largest amount of tokens that might be added the the bank based on", - "serum open order execution.", - "", - "Can be negative with multiple banks, then it'd need to be balanced in the keeper." + "serum open order execution." ], - "type": "i64" + "type": "u64" }, { "name": "maintWeightShiftStart", @@ -7298,12 +7319,19 @@ "defined": "I80F48" } }, + { + "name": "depositLimit", + "docs": [ + "zero means none, in token native" + ], + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 2008 + 2000 ] } } @@ -8402,10 +8430,20 @@ "type": { "array": [ "u8", - 5 + 1 ] } }, + { + "name": "oraclePriceBand", + "docs": [ + "Limit orders must be <= oracle * (1+band) and >= oracle / (1+band)", + "", + "Zero value is the default due to migration and disables the limit,", + "same as f32::MAX." + ], + "type": "f32" + }, { "name": "registrationTime", "type": "u64" @@ -13467,6 +13505,16 @@ "code": 6060, "name": "HealthAccountBankNotWritable", "msg": "a bank in the health account list should be writable but is not" + }, + { + "code": 6061, + "name": "Serum3PriceBandExceeded", + "msg": "the market does not allow limit orders too far from the current oracle value" + }, + { + "code": 6062, + "name": "BankDepositLimit", + "msg": "deposit crosses the token's deposit limit" } ] } \ No newline at end of file diff --git a/programs/mango-v4/src/error.rs b/programs/mango-v4/src/error.rs index 5cf5d1eef..00911084e 100644 --- a/programs/mango-v4/src/error.rs +++ b/programs/mango-v4/src/error.rs @@ -129,6 +129,8 @@ pub enum MangoError { HealthAccountBankNotWritable, #[msg("the market does not allow limit orders too far from the current oracle value")] Serum3PriceBandExceeded, + #[msg("deposit crosses the token's deposit limit")] + BankDepositLimit, } impl MangoError { diff --git a/programs/mango-v4/src/health/client.rs b/programs/mango-v4/src/health/client.rs index d4dba0447..73b003280 100644 --- a/programs/mango-v4/src/health/client.rs +++ b/programs/mango-v4/src/health/client.rs @@ -42,7 +42,6 @@ impl HealthCache { let mut source_bank = source_bank.clone(); source_bank.withdraw_with_fee(&mut source_position, amount, now_ts)?; - source_bank.check_net_borrows(source_oracle_price)?; let mut target_bank = target_bank.clone(); target_bank.deposit(&mut target_position, target_amount, now_ts)?; @@ -52,7 +51,40 @@ impl HealthCache { Ok(resulting_cache) } - pub fn max_swap_source_for_health_ratio( + fn apply_limits_to_swap( + account: &MangoAccountValue, + source_bank: &Bank, + source_oracle_price: I80F48, + target_bank: &Bank, + price: I80F48, + source_unlimited: I80F48, + ) -> Result { + let source_pos = account + .token_position(source_bank.token_index)? + .native(source_bank); + let target_pos = account + .token_position(target_bank.token_index)? + .native(target_bank); + + // net borrow limit on source + let available_net_borrows = source_bank + .remaining_net_borrows_quote(source_oracle_price) + .saturating_div(source_oracle_price); + let potential_source = source_unlimited + .min(available_net_borrows.saturating_add(source_pos.max(I80F48::ZERO))); + + // deposit limit on target + let available_deposits = target_bank.remaining_deposits_until_limit(); + let potential_target_unlimited = potential_source.saturating_mul(price); + let potential_target = potential_target_unlimited + .min(available_deposits.saturating_add(-target_pos.min(I80F48::ZERO))); + + let source = potential_source.min(potential_target.saturating_div(price)); + Ok(source) + } + + /// Verifies neither the net borrow or deposit limits + pub fn max_swap_source_for_health_ratio_ignoring_limits( &self, account: &MangoAccountValue, source_bank: &Bank, @@ -72,7 +104,7 @@ impl HealthCache { ) } - pub fn max_swap_source_for_health( + pub fn max_swap_source_for_health_ratio_with_limits( &self, account: &MangoAccountValue, source_bank: &Bank, @@ -81,14 +113,23 @@ impl HealthCache { price: I80F48, min_ratio: I80F48, ) -> Result { - self.max_swap_source_for_health_fn( + let source_unlimited = self.max_swap_source_for_health_fn( account, source_bank, source_oracle_price, target_bank, price, min_ratio, - |cache| cache.health(HealthType::Init), + |cache| cache.health_ratio(HealthType::Init), + )?; + + Self::apply_limits_to_swap( + account, + source_bank, + source_oracle_price, + target_bank, + price, + source_unlimited, ) } @@ -707,7 +748,7 @@ mod tests { assert_eq!(health_cache.health_ratio(HealthType::Init), I80F48::MAX); assert_eq!( health_cache - .max_swap_source_for_health_ratio( + .max_swap_source_for_health_ratio_with_limits( &account, &banks[0], I80F48::from(1), @@ -748,7 +789,7 @@ mod tests { let swap_price = I80F48::from_num(price_factor) * source_price.oracle / target_price.oracle; - let source_amount = c + let source_unlimited = c .max_swap_source_for_health_fn( &account, &source_bank, @@ -759,6 +800,15 @@ mod tests { max_swap_fn, ) .unwrap(); + let source_amount = HealthCache::apply_limits_to_swap( + &account, + &source_bank, + source_price.oracle, + &target_bank, + swap_price, + source_unlimited, + ) + .unwrap(); if source_amount == I80F48::MAX { return (f64::MAX, f64::MAX, f64::MAX, f64::MAX); } @@ -865,10 +915,7 @@ mod tests { } // At this unlikely price it's healthy to swap infinitely - assert_eq!( - find_max_swap(&health_cache, 0, 1, 50.0, 1.5, banks).0, - f64::MAX - ); + assert!(find_max_swap(&health_cache, 0, 1, 50.0, 1.5, banks).0 > 1e16); } { diff --git a/programs/mango-v4/src/instructions/flash_loan.rs b/programs/mango-v4/src/instructions/flash_loan.rs index e4f5d5160..6f7403a58 100644 --- a/programs/mango-v4/src/instructions/flash_loan.rs +++ b/programs/mango-v4/src/instructions/flash_loan.rs @@ -467,6 +467,10 @@ pub fn flash_loan_end<'key, 'accounts, 'remaining, 'info>( bank.check_net_borrows(*oracle_price)?; } + if change_amount > 0 && native_after_change > 0 { + bank.check_deposit_and_oo_limit()?; + } + bank.flash_loan_approved_amount = 0; bank.flash_loan_token_account_initial = u64::MAX; diff --git a/programs/mango-v4/src/instructions/serum3_place_order.rs b/programs/mango-v4/src/instructions/serum3_place_order.rs index 2135bb926..3c2d1cbe9 100644 --- a/programs/mango-v4/src/instructions/serum3_place_order.rs +++ b/programs/mango-v4/src/instructions/serum3_place_order.rs @@ -284,7 +284,8 @@ pub fn serum3_place_order( let mut payer_bank = ctx.accounts.payer_bank.load_mut()?; - // Update the tracking in banks + // Update the potential token tracking in banks + // (for init weight scaling, deposit limit checks) if is_v2_instruction { let mut receiver_bank = receiver_bank_ai.load_mut::()?; let (base_bank, quote_bank) = match side { @@ -296,14 +297,14 @@ pub fn serum3_place_order( update_bank_potential_tokens_payer_only(serum, &mut payer_bank, &after_oo); } - // Enforce min vault to deposits ratio - let withdrawn_from_vault = I80F48::from(before_vault - after_vault); - let position_native = account + // Track position before withdraw happens + let before_position_native = account .token_position_mut(payer_bank.token_index)? .0 .native(&payer_bank); // Charge the difference in vault balance to the user's account + // (must be done before limit checks like deposit limit) let vault_difference = { apply_vault_difference( ctx.accounts.account.key(), @@ -315,10 +316,25 @@ pub fn serum3_place_order( )? }; + // Deposit limit check: Placing an order can increase deposit limit use on both + // the payer and receiver bank. Imagine placing a bid for 500 base @ 0.5: it would + // use up 1000 quote and 500 base because either could be deposit on cancel/fill. + // This is why this must happen after update_bank_potential_tokens() and any withdraws. + { + let receiver_bank = receiver_bank_ai.load::()?; + receiver_bank + .check_deposit_and_oo_limit() + .with_context(|| std::format!("on {}", receiver_bank.name()))?; + payer_bank + .check_deposit_and_oo_limit() + .with_context(|| std::format!("on {}", payer_bank.name()))?; + } + // Payer bank safety checks like reduce-only, net borrows, vault-to-deposits ratio let payer_bank_oracle = payer_bank.oracle_price(&AccountInfoRef::borrow(&ctx.accounts.payer_oracle)?, None)?; - if withdrawn_from_vault > position_native { + let withdrawn_from_vault = I80F48::from(before_vault - after_vault); + if withdrawn_from_vault > before_position_native { require_msg_typed!( !payer_bank.are_borrows_reduce_only(), MangoError::TokenInReduceOnlyMode, diff --git a/programs/mango-v4/src/instructions/token_conditional_swap_start.rs b/programs/mango-v4/src/instructions/token_conditional_swap_start.rs index c74589576..4d3624ac0 100644 --- a/programs/mango-v4/src/instructions/token_conditional_swap_start.rs +++ b/programs/mango-v4/src/instructions/token_conditional_swap_start.rs @@ -64,8 +64,13 @@ pub fn token_conditional_swap_start( // We allow the incentive to be < 1 native token because of tokens like BTC, where 1 native token // far exceeds the incentive value. let incentive = (I80F48::from(TCS_START_INCENTIVE) / sell_oracle_price) - .min(I80F48::from(tcs.remaining_sell())); - // However, the tcs tracking is in u64 units. We need to live with the fact of + .min(I80F48::from(tcs.remaining_sell())) + // Limiting to remaining deposits is too strict, since this could be a deposit + // to deposit transfer, but this is good enough to make the incentive deposit + // guaranteed to not exceed the limit. + .min(sell_bank.remaining_deposits_until_limit()) + .max(I80F48::ZERO); + // The tcs tracking is in u64 units. We need to live with the fact of // not accounting the incentive fee perfectly. let incentive_native = incentive.clamp_to_u64(); @@ -73,22 +78,21 @@ pub fn token_conditional_swap_start( let (liqor_sell_token, liqor_sell_raw_index, _) = liqor.ensure_token_position(sell_token_index)?; - sell_bank.deposit(liqor_sell_token, incentive, now_ts)?; - - // This withdraw might be a borrow, so can fail due to net borrows or reduce-only let liqee_sell_pre_balance = liqee_sell_token.native(sell_bank); - sell_bank.withdraw_with_fee(liqee_sell_token, incentive, now_ts)?; + sell_bank.checked_transfer_with_fee( + liqee_sell_token, + incentive, + liqor_sell_token, + incentive, + now_ts, + sell_oracle_price, + )?; let liqee_sell_post_balance = liqee_sell_token.native(sell_bank); if liqee_sell_post_balance < 0 { require!( tcs.allow_creating_borrows(), MangoError::TokenConditionalSwapCantPayIncentive ); - require!( - !sell_bank.are_borrows_reduce_only(), - MangoError::TokenInReduceOnlyMode - ); - sell_bank.check_net_borrows(sell_oracle_price)?; } health_cache diff --git a/programs/mango-v4/src/instructions/token_conditional_swap_trigger.rs b/programs/mango-v4/src/instructions/token_conditional_swap_trigger.rs index f906c068e..1d25846b4 100644 --- a/programs/mango-v4/src/instructions/token_conditional_swap_trigger.rs +++ b/programs/mango-v4/src/instructions/token_conditional_swap_trigger.rs @@ -294,9 +294,15 @@ fn action( let (liqee_buy_token, liqee_buy_raw_index) = liqee.token_position_mut(tcs.buy_token_index)?; let (liqor_buy_token, liqor_buy_raw_index) = liqor.token_position_mut(tcs.buy_token_index)?; - buy_bank.deposit(liqee_buy_token, buy_token_amount_i80f48, now_ts)?; - let liqor_buy_withdraw = - buy_bank.withdraw_with_fee(liqor_buy_token, buy_token_amount_i80f48, now_ts)?; + let buy_transfer = buy_bank.checked_transfer_with_fee( + liqor_buy_token, + buy_token_amount_i80f48, + liqee_buy_token, + buy_token_amount_i80f48, + now_ts, + buy_token_price, + )?; + let liqor_buy_active = buy_transfer.source_is_active; let post_liqee_buy_token = liqee_buy_token.native(&buy_bank); let post_liqor_buy_token = liqor_buy_token.native(&buy_bank); @@ -307,28 +313,18 @@ fn action( liqee.token_position_mut(tcs.sell_token_index)?; let (liqor_sell_token, liqor_sell_raw_index) = liqor.token_position_mut(tcs.sell_token_index)?; - let liqor_sell_active = sell_bank.deposit( + let sell_transfer = sell_bank.checked_transfer_with_fee( + liqee_sell_token, + I80F48::from(sell_token_amount_from_liqee), liqor_sell_token, I80F48::from(sell_token_amount_to_liqor), now_ts, + sell_token_price, )?; - let liqee_sell_withdraw = sell_bank.withdraw_with_fee( - liqee_sell_token, - I80F48::from(sell_token_amount_from_liqee), - now_ts, - )?; + let liqor_sell_active = sell_transfer.target_is_active; sell_bank.collected_fees_native += I80F48::from(maker_fee + taker_fee); - // Check net borrows on both banks. - // - // While tcs triggering doesn't cause actual tokens to leave the platform, it can increase the amount - // of borrows. For instance, if someone with USDC has a tcs to buy SOL and sell BTC, execution would - // create BTC borrows (unless the executor had BTC borrows that get repaid by the execution, but - // most executors will work on margin) - buy_bank.check_net_borrows(buy_token_price)?; - sell_bank.check_net_borrows(sell_token_price)?; - let post_liqee_sell_token = liqee_sell_token.native(&sell_bank); let post_liqor_sell_token = liqor_sell_token.native(&sell_bank); let liqee_sell_indexed_position = liqee_sell_token.indexed_position; @@ -336,7 +332,7 @@ fn action( // With a scanning account retriever, it's safe to deactivate inactive token positions immediately. // Liqee positions can only be deactivated if the tcs is closed (see below). - if !liqor_buy_withdraw.position_is_active { + if !liqor_buy_active { liqor.deactivate_token_position_and_log(liqor_buy_raw_index, liqor_key); } if !liqor_sell_active { @@ -382,24 +378,24 @@ fn action( borrow_index: sell_bank.borrow_index.to_bits(), }); - if liqor_buy_withdraw.has_loan() { + if buy_transfer.has_loan() { emit_stack(WithdrawLoanLog { mango_group: liqee.fixed.group, mango_account: liqor_key, token_index: tcs.buy_token_index, - loan_amount: liqor_buy_withdraw.loan_amount.to_bits(), - loan_origination_fee: liqor_buy_withdraw.loan_origination_fee.to_bits(), + loan_amount: buy_transfer.loan_amount.to_bits(), + loan_origination_fee: buy_transfer.loan_origination_fee.to_bits(), instruction: LoanOriginationFeeInstruction::TokenConditionalSwapTrigger, price: Some(buy_token_price.to_bits()), }); } - if liqee_sell_withdraw.has_loan() { + if sell_transfer.has_loan() { emit_stack(WithdrawLoanLog { mango_group: liqee.fixed.group, mango_account: liqee_key, token_index: tcs.sell_token_index, - loan_amount: liqee_sell_withdraw.loan_amount.to_bits(), - loan_origination_fee: liqee_sell_withdraw.loan_origination_fee.to_bits(), + loan_amount: sell_transfer.loan_amount.to_bits(), + loan_origination_fee: sell_transfer.loan_origination_fee.to_bits(), instruction: LoanOriginationFeeInstruction::TokenConditionalSwapTrigger, price: Some(sell_token_price.to_bits()), }); diff --git a/programs/mango-v4/src/instructions/token_deposit.rs b/programs/mango-v4/src/instructions/token_deposit.rs index 04e77c0e1..33fa8c6d6 100644 --- a/programs/mango-v4/src/instructions/token_deposit.rs +++ b/programs/mango-v4/src/instructions/token_deposit.rs @@ -90,11 +90,16 @@ impl<'a, 'info> DepositCommon<'a, 'info> { // Get the oracle price, even if stale or unconfident: We want to allow users // to deposit to close borrows or do other fixes even if the oracle is bad. - let unsafe_oracle_price = oracle_state_unchecked( + let unsafe_oracle_state = oracle_state_unchecked( &AccountInfoRef::borrow(self.oracle.as_ref())?, bank.mint_decimals, - )? - .price; + )?; + let unsafe_oracle_price = unsafe_oracle_state.price; + + // If increasing total deposits, check deposit limits + if indexed_position > 0 { + bank.check_deposit_and_oo_limit()?; + } // Update the net deposits - adjust by price so different tokens are on the same basis (in USD terms) let amount_usd = (amount_i80f48 * unsafe_oracle_price).to_num::(); diff --git a/programs/mango-v4/src/instructions/token_edit.rs b/programs/mango-v4/src/instructions/token_edit.rs index abab45d2d..948f7a3d5 100644 --- a/programs/mango-v4/src/instructions/token_edit.rs +++ b/programs/mango-v4/src/instructions/token_edit.rs @@ -49,6 +49,8 @@ pub fn token_edit( maint_weight_shift_asset_target_opt: Option, maint_weight_shift_liab_target_opt: Option, maint_weight_shift_abort: bool, + set_fallback_oracle: bool, // unused, introduced in v0.22 + deposit_limit_opt: Option, ) -> Result<()> { let group = ctx.accounts.group.load()?; @@ -436,6 +438,16 @@ pub fn token_edit( bank.maint_weight_shift_duration_inv.is_positive(), ); } + + if let Some(deposit_limit) = deposit_limit_opt { + msg!( + "Deposit limit old {:?}, new {:?}", + bank.deposit_limit, + deposit_limit + ); + bank.deposit_limit = deposit_limit; + require_group_admin = true; + } } // account constraint #1 diff --git a/programs/mango-v4/src/instructions/token_register.rs b/programs/mango-v4/src/instructions/token_register.rs index 014121520..7b4110363 100644 --- a/programs/mango-v4/src/instructions/token_register.rs +++ b/programs/mango-v4/src/instructions/token_register.rs @@ -41,6 +41,7 @@ pub fn token_register( interest_curve_scaling: f32, interest_target_utilization: f32, group_insurance_fund: bool, + deposit_limit: u64, ) -> Result<()> { // Require token 0 to be in the insurance token if token_index == INSURANCE_TOKEN_INDEX { @@ -119,7 +120,9 @@ pub fn token_register( maint_weight_shift_duration_inv: I80F48::ZERO, maint_weight_shift_asset_target: I80F48::ZERO, maint_weight_shift_liab_target: I80F48::ZERO, - reserved: [0; 2008], + fallback_oracle: Pubkey::default(), // unused, introduced in v0.22 + deposit_limit, + reserved: [0; 1968], }; if let Ok(oracle_price) = diff --git a/programs/mango-v4/src/instructions/token_register_trustless.rs b/programs/mango-v4/src/instructions/token_register_trustless.rs index 0a70784b1..59b65fd01 100644 --- a/programs/mango-v4/src/instructions/token_register_trustless.rs +++ b/programs/mango-v4/src/instructions/token_register_trustless.rs @@ -102,7 +102,9 @@ pub fn token_register_trustless( maint_weight_shift_duration_inv: I80F48::ZERO, maint_weight_shift_asset_target: I80F48::ZERO, maint_weight_shift_liab_target: I80F48::ZERO, - reserved: [0; 2008], + fallback_oracle: Pubkey::default(), // unused, introduced in v0.22 + deposit_limit: 0, + reserved: [0; 1968], }; if let Ok(oracle_price) = diff --git a/programs/mango-v4/src/lib.rs b/programs/mango-v4/src/lib.rs index 9e28008ab..4bbe558dc 100644 --- a/programs/mango-v4/src/lib.rs +++ b/programs/mango-v4/src/lib.rs @@ -153,6 +153,7 @@ pub mod mango_v4 { interest_curve_scaling: f32, interest_target_utilization: f32, group_insurance_fund: bool, + deposit_limit: u64, ) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::token_register( @@ -183,6 +184,7 @@ pub mod mango_v4 { interest_curve_scaling, interest_target_utilization, group_insurance_fund, + deposit_limit, )?; Ok(()) } @@ -234,6 +236,8 @@ pub mod mango_v4 { maint_weight_shift_asset_target_opt: Option, maint_weight_shift_liab_target_opt: Option, maint_weight_shift_abort: bool, + set_fallback_oracle: bool, // unused, introduced in v0.22 + deposit_limit_opt: Option, ) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::token_edit( @@ -272,6 +276,8 @@ pub mod mango_v4 { maint_weight_shift_asset_target_opt, maint_weight_shift_liab_target_opt, maint_weight_shift_abort, + set_fallback_oracle, + deposit_limit_opt, )?; Ok(()) } @@ -636,6 +642,7 @@ pub mod mango_v4 { Ok(()) } + /// requires the receiver_bank in the health account list to be writable #[allow(clippy::too_many_arguments)] pub fn serum3_place_order_v2( ctx: Context, diff --git a/programs/mango-v4/src/state/bank.rs b/programs/mango-v4/src/state/bank.rs index 26e9cffc3..7a397224f 100644 --- a/programs/mango-v4/src/state/bank.rs +++ b/programs/mango-v4/src/state/bank.rs @@ -167,8 +167,12 @@ pub struct Bank { pub maint_weight_shift_asset_target: I80F48, pub maint_weight_shift_liab_target: I80F48, - #[derivative(Debug = "ignore")] - pub reserved: [u8; 2008], + pub fallback_oracle: Pubkey, // unused, introduced in v0.22 + + /// zero means none, in token native + pub deposit_limit: u64, + + pub reserved: [u8; 1968], } const_assert_eq!( size_of::(), @@ -203,7 +207,9 @@ const_assert_eq!( + 8 * 2 + 8 * 2 + 16 * 3 - + 2008 + + 32 + + 8 + + 1968 ); const_assert_eq!(size_of::(), 3064); const_assert_eq!(size_of::() % 8, 0); @@ -220,6 +226,19 @@ impl WithdrawResult { } } +pub struct TransferResult { + pub source_is_active: bool, + pub target_is_active: bool, + pub loan_origination_fee: I80F48, + pub loan_amount: I80F48, +} + +impl TransferResult { + pub fn has_loan(&self) -> bool { + self.loan_amount.is_positive() + } +} + impl Bank { pub fn from_existing_bank( existing_bank: &Bank, @@ -292,7 +311,9 @@ impl Bank { maint_weight_shift_duration_inv: existing_bank.maint_weight_shift_duration_inv, maint_weight_shift_asset_target: existing_bank.maint_weight_shift_asset_target, maint_weight_shift_liab_target: existing_bank.maint_weight_shift_liab_target, - reserved: [0; 2008], + fallback_oracle: existing_bank.oracle, + deposit_limit: existing_bank.deposit_limit, + reserved: [0; 1968], } } @@ -724,6 +745,64 @@ impl Bank { } } + /// Generic "transfer" from source to target. + /// + /// Amounts for source and target can differ and can be zero. + /// Checks reduce-only, net borrow limits and deposit limits. + pub fn checked_transfer_with_fee( + &mut self, + source: &mut TokenPosition, + source_amount: I80F48, + target: &mut TokenPosition, + target_amount: I80F48, + now_ts: u64, + oracle_price: I80F48, + ) -> Result { + let before_borrows = self.indexed_borrows; + let before_deposits = self.indexed_deposits; + + let withdraw_result = if !source_amount.is_zero() { + let withdraw_result = self.withdraw_with_fee(source, source_amount, now_ts)?; + require!( + source.indexed_position >= 0 || !self.are_borrows_reduce_only(), + MangoError::TokenInReduceOnlyMode + ); + withdraw_result + } else { + WithdrawResult { + position_is_active: true, + loan_amount: I80F48::ZERO, + loan_origination_fee: I80F48::ZERO, + } + }; + + let target_is_active = if !target_amount.is_zero() { + let active = self.deposit(target, target_amount, now_ts)?; + require!( + target.indexed_position <= 0 || !self.are_deposits_reduce_only(), + MangoError::TokenInReduceOnlyMode + ); + active + } else { + true + }; + + // Adding DELTA here covers the case where we add slightly more than we withdraw + if self.indexed_borrows > before_borrows + I80F48::DELTA { + self.check_net_borrows(oracle_price)?; + } + if self.indexed_deposits > before_deposits + I80F48::DELTA { + self.check_deposit_and_oo_limit()?; + } + + Ok(TransferResult { + source_is_active: withdraw_result.position_is_active, + target_is_active, + loan_origination_fee: withdraw_result.loan_origination_fee, + loan_amount: withdraw_result.loan_amount, + }) + } + /// Update the bank's net_borrows fields. /// /// If oracle_price is set, also do a net borrows check and error if the threshold is exceeded. @@ -769,6 +848,49 @@ impl Bank { Ok(()) } + pub fn remaining_deposits_until_limit(&self) -> I80F48 { + if self.deposit_limit == 0 { + return I80F48::MAX; + } + + // Assuming slightly higher deposits than true allows the returned value + // to be deposit()ed safely into this bank without triggering limits. + // (because deposit() will round up in favor of the user) + let deposits = self.deposit_index * (self.indexed_deposits + I80F48::DELTA); + + let serum = I80F48::from(self.potential_serum_tokens); + let total = deposits + serum; + + I80F48::from(self.deposit_limit) - total + } + + pub fn check_deposit_and_oo_limit(&self) -> Result<()> { + if self.deposit_limit == 0 { + return Ok(()); + } + + // Intentionally does not use remaining_deposits_until_limit(): That function + // returns slightly less than the true limit to make sure depositing that amount + // will not cause a limit overrun. + let deposits = self.native_deposits(); + let serum = I80F48::from(self.potential_serum_tokens); + let total = deposits + serum; + let remaining = I80F48::from(self.deposit_limit) - total; + if remaining < 0 { + return Err(error_msg_typed!( + MangoError::BankDepositLimit, + "deposit limit exceeded: remaining: {}, total: {}, limit: {}, deposits: {}, serum: {}", + remaining, + total, + self.deposit_limit, + deposits, + serum, + )); + } + + Ok(()) + } + pub fn update_cumulative_interest( &self, position: &mut TokenPosition, @@ -1177,6 +1299,148 @@ mod tests { Ok(()) } + #[test] + fn bank_transfer() { + // + // SETUP + // + + let mut bank_proto = Bank::zeroed(); + bank_proto.net_borrow_limit_window_size_ts = 1; // dummy + bank_proto.net_borrow_limit_per_window_quote = i64::MAX; // max since we don't want this to interfere + bank_proto.deposit_index = I80F48::from(1_234_567); + bank_proto.borrow_index = I80F48::from(1_234_567); + bank_proto.loan_origination_fee_rate = I80F48::from_num(0.1); + + let account_proto = TokenPosition { + indexed_position: I80F48::ZERO, + token_index: 0, + in_use_count: 1, + cumulative_deposit_interest: 0.0, + cumulative_borrow_interest: 0.0, + previous_index: I80F48::ZERO, + padding: Default::default(), + reserved: [0; 128], + }; + + // + // TESTS + // + + // simple transfer + { + let mut bank = bank_proto.clone(); + let mut a1 = account_proto.clone(); + let mut a2 = account_proto.clone(); + + let amount = I80F48::from(100); + bank.deposit(&mut a1, amount, 0).unwrap(); + let damount = a1.native(&bank); + let r = bank + .checked_transfer_with_fee(&mut a1, amount, &mut a2, amount, 0, I80F48::ONE) + .unwrap(); + assert_eq!(a2.native(&bank), damount); + assert!(r.source_is_active); + assert!(r.target_is_active); + } + + // borrow limits + { + let mut bank = bank_proto.clone(); + bank.net_borrow_limit_per_window_quote = 100; + bank.loan_origination_fee_rate = I80F48::ZERO; + let mut a1 = account_proto.clone(); + let mut a2 = account_proto.clone(); + + { + let mut b = bank.clone(); + let amount = I80F48::from(101); + assert!(b + .checked_transfer_with_fee(&mut a1, amount, &mut a2, amount, 0, I80F48::ONE) + .is_err()); + } + + { + let mut b = bank.clone(); + let amount = I80F48::from(100); + b.checked_transfer_with_fee(&mut a1, amount, &mut a2, amount, 0, I80F48::ONE) + .unwrap(); + } + + { + let mut b = bank.clone(); + let amount = b.remaining_net_borrows_quote(I80F48::ONE); + b.checked_transfer_with_fee(&mut a1, amount, &mut a2, amount, 0, I80F48::ONE) + .unwrap(); + } + } + + // deposit limits + { + let mut bank = bank_proto.clone(); + bank.deposit_limit = 100; + let mut a1 = account_proto.clone(); + let mut a2 = account_proto.clone(); + + { + let mut b = bank.clone(); + let amount = I80F48::from(101); + assert!(b + .checked_transfer_with_fee(&mut a1, amount, &mut a2, amount, 0, I80F48::ONE) + .is_err()); + } + + { + // still bad because deposit() adds DELTA more than requested + let mut b = bank.clone(); + let amount = I80F48::from(100); + assert!(b + .checked_transfer_with_fee(&mut a1, amount, &mut a2, amount, 0, I80F48::ONE) + .is_err()); + } + + { + let mut b = bank.clone(); + let amount = I80F48::from_num(99.999); + b.checked_transfer_with_fee(&mut a1, amount, &mut a2, amount, 0, I80F48::ONE) + .unwrap(); + } + + { + let mut b = bank.clone(); + let amount = b.remaining_deposits_until_limit(); + b.checked_transfer_with_fee(&mut a1, amount, &mut a2, amount, 0, I80F48::ONE) + .unwrap(); + } + } + + // reducing transfer while limits exceeded + { + let mut bank = bank_proto.clone(); + bank.loan_origination_fee_rate = I80F48::ZERO; + + let amount = I80F48::from(100); + let mut a1 = account_proto.clone(); + bank.deposit(&mut a1, amount, 0).unwrap(); + let mut a2 = account_proto.clone(); + bank.withdraw_with_fee(&mut a2, amount, 0).unwrap(); + + bank.net_borrow_limit_per_window_quote = 100; + bank.net_borrows_in_window = 200; + bank.deposit_limit = 100; + bank.potential_serum_tokens = 200; + + let half = I80F48::from(50); + bank.checked_transfer_with_fee(&mut a1, half, &mut a2, half, 0, I80F48::ONE) + .unwrap(); + bank.checked_transfer_with_fee(&mut a1, half, &mut a2, half, 0, I80F48::ONE) + .unwrap(); + assert!(bank + .checked_transfer_with_fee(&mut a1, half, &mut a2, half, 0, I80F48::ONE) + .is_err()); + } + } + #[test] fn test_compute_new_avg_utilization() { let mut bank = Bank::zeroed(); diff --git a/programs/mango-v4/tests/cases/test_basic.rs b/programs/mango-v4/tests/cases/test_basic.rs index 6df657ec9..b8e0d5124 100644 --- a/programs/mango-v4/tests/cases/test_basic.rs +++ b/programs/mango-v4/tests/cases/test_basic.rs @@ -511,3 +511,174 @@ async fn test_bank_maint_weight_shift() -> Result<(), TransportError> { Ok(()) } + +#[tokio::test] +async fn test_bank_deposit_limit() -> 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_account = context.users[1].token_accounts[0]; + let mints = &context.mints[0..1]; + + let mango_setup::GroupWithTokens { group, .. } = mango_setup::GroupWithTokensConfig { + admin, + payer, + mints: mints.to_vec(), + zero_token_is_quote: true, + ..mango_setup::GroupWithTokensConfig::default() + } + .create(solana) + .await; + + let funding_amount = 0; + let account1 = create_funded_account( + &solana, + group, + owner, + 1, + &context.users[1], + &mints[0..0], + funding_amount, + 0, + ) + .await; + let account2 = create_funded_account( + &solana, + group, + owner, + 2, + &context.users[1], + &mints[0..0], + funding_amount, + 0, + ) + .await; + + send_tx( + solana, + TokenEdit { + group, + admin, + mint: mints[0].pubkey, + fallback_oracle: Pubkey::default(), + options: mango_v4::instruction::TokenEdit { + deposit_limit_opt: Some(2000), + ..token_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); + + let default_deposit_ix = TokenDepositInstruction { + amount: 0, + reduce_only: false, + account: Pubkey::default(), + owner, + token_account: payer_token_account, + token_authority: payer, + bank_index: 0, + }; + + send_tx_expect_error!( + solana, + TokenDepositInstruction { + amount: 2001, + account: account1, + ..default_deposit_ix + }, + MangoError::BankDepositLimit + ); + + send_tx( + solana, + TokenDepositInstruction { + amount: 1001, + account: account1, + ..default_deposit_ix + }, + ) + .await + .unwrap(); + + send_tx_expect_error!( + solana, + TokenDepositInstruction { + amount: 1000, + account: account1, + ..default_deposit_ix + }, + MangoError::BankDepositLimit + ); + + send_tx_expect_error!( + solana, + TokenDepositInstruction { + amount: 1000, + account: account2, + ..default_deposit_ix + }, + MangoError::BankDepositLimit + ); + + send_tx( + solana, + TokenDepositInstruction { + amount: 998, // 999 does not work due to rounding + account: account2, + ..default_deposit_ix + }, + ) + .await + .unwrap(); + + send_tx_expect_error!( + solana, + TokenDepositInstruction { + amount: 1, + account: account2, + ..default_deposit_ix + }, + MangoError::BankDepositLimit + ); + + send_tx( + solana, + TokenWithdrawInstruction { + amount: 5, + allow_borrow: false, + account: account2, + owner, + token_account: payer_token_account, + bank_index: 0, + }, + ) + .await + .unwrap(); + + send_tx_expect_error!( + solana, + TokenDepositInstruction { + amount: 6, + account: account2, + ..default_deposit_ix + }, + MangoError::BankDepositLimit + ); + + send_tx( + solana, + TokenDepositInstruction { + amount: 5, + account: account2, + ..default_deposit_ix + }, + ) + .await + .unwrap(); + + Ok(()) +} diff --git a/programs/mango-v4/tests/cases/test_margin_trade.rs b/programs/mango-v4/tests/cases/test_margin_trade.rs index 4c48f0727..725836a02 100644 --- a/programs/mango-v4/tests/cases/test_margin_trade.rs +++ b/programs/mango-v4/tests/cases/test_margin_trade.rs @@ -613,3 +613,118 @@ async fn test_flash_loan_creates_ata_accounts() -> Result<(), BanksClientError> Ok(()) } + +#[tokio::test] +async fn test_margin_trade_deposit_limit() -> 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; + + // + // SETUP: deposit limit + // + send_tx( + solana, + TokenEdit { + group, + admin, + mint: tokens[0].mint.pubkey, + fallback_oracle: Pubkey::default(), + options: mango_v4::instruction::TokenEdit { + deposit_limit_opt: Some(1000), + ..token_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); + + // + // create the test user account + // + + let deposit_amount_initial = 100; + let account = create_funded_account( + &solana, + group, + owner, + 0, + &context.users[1], + &mints[..1], + 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| 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(FlashLoanEndInstruction { + account, + owner, + loans, + // the test only accesses a single token: not a swap + flash_loan_type: mango_v4::accounts_ix::FlashLoanType::Unknown, + }) + .await; + tx + }; + + make_flash_loan_tx(solana, 901) + .await + .send_expect_error(MangoError::BankDepositLimit) + .await + .unwrap(); + + make_flash_loan_tx(solana, 899).await.send().await.unwrap(); + + Ok(()) +} diff --git a/programs/mango-v4/tests/cases/test_serum.rs b/programs/mango-v4/tests/cases/test_serum.rs index 64570cbdd..fb11a6cbf 100644 --- a/programs/mango-v4/tests/cases/test_serum.rs +++ b/programs/mango-v4/tests/cases/test_serum.rs @@ -1551,6 +1551,166 @@ async fn test_serum_bands() -> Result<(), TransportError> { Ok(()) } +#[tokio::test] +async fn test_serum_deposit_limits() -> 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; // for 10k tokens over both order_placers + let CommonSetup { + group_with_tokens, + mut order_placer, + quote_token, + base_token, + .. + } = common_setup2(&context, deposit_amount, 0).await; + + // + // SETUP: Set oracle price for market to 2 + // + set_bank_stub_oracle_price( + solana, + group_with_tokens.group, + &base_token, + group_with_tokens.admin, + 4.0, + ) + .await; + set_bank_stub_oracle_price( + solana, + group_with_tokens.group, + "e_token, + group_with_tokens.admin, + 2.0, + ) + .await; + + // + // SETUP: Base token: add deposit limit + // + send_tx( + solana, + TokenEdit { + group: group_with_tokens.group, + admin: group_with_tokens.admin, + mint: base_token.mint.pubkey, + fallback_oracle: Pubkey::default(), + options: mango_v4::instruction::TokenEdit { + deposit_limit_opt: Some(13000), + ..token_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); + + let solana2 = context.solana.clone(); + let base_bank = base_token.bank; + let remaining_base = { + || async { + let b: Bank = solana2.get_account(base_bank).await; + b.remaining_deposits_until_limit().round().to_num::() + } + }; + + // + // TEST: even when placing all base tokens into an ask, they still count + // + + order_placer.ask(2.0, 5000).await.unwrap(); + assert_eq!(remaining_base().await, 3000); + + // + // TEST: if we bid to buy more base, the limit reduces + // + + order_placer.bid_maker(1.5, 1000).await.unwrap(); + assert_eq!(remaining_base().await, 2000); + + // + // TEST: if we bid too much for the limit, the order does not go through + // + + let r = order_placer.try_bid(1.5, 2001, false).await; + assert_mango_error(&r, MangoError::BankDepositLimit.into(), "dep limit".into()); + order_placer.try_bid(1.5, 1999, false).await.unwrap(); // not 2000 due to rounding + + // + // SETUP: Switch deposit limit to quote token + // + + send_tx( + solana, + TokenEdit { + group: group_with_tokens.group, + admin: group_with_tokens.admin, + mint: base_token.mint.pubkey, + fallback_oracle: Pubkey::default(), + options: mango_v4::instruction::TokenEdit { + deposit_limit_opt: Some(0), + ..token_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); + send_tx( + solana, + TokenEdit { + group: group_with_tokens.group, + admin: group_with_tokens.admin, + mint: quote_token.mint.pubkey, + fallback_oracle: Pubkey::default(), + options: mango_v4::instruction::TokenEdit { + deposit_limit_opt: Some(13000), + ..token_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); + + let solana2 = context.solana.clone(); + let quote_bank = quote_token.bank; + let remaining_quote = { + || async { + let b: Bank = solana2.get_account(quote_bank).await; + b.remaining_deposits_until_limit().round().to_num::() + } + }; + + order_placer.cancel_all().await; + + // + // TEST: even when placing all quote tokens into a bid, they still count + // + + order_placer.bid_maker(2.0, 2500).await.unwrap(); + assert_eq!(remaining_quote().await, 3000); + + // + // TEST: if we ask to get more quote, the limit reduces + // + + order_placer.ask(5.0, 200).await.unwrap(); + assert_eq!(remaining_quote().await, 2000); + + // + // TEST: if we bid too much for the limit, the order does not go through + // + + let r = order_placer.try_ask(5.0, 401).await; + assert_mango_error(&r, MangoError::BankDepositLimit.into(), "dep limit".into()); + order_placer.try_ask(5.0, 399).await.unwrap(); // not 400 due to rounding + + Ok(()) +} + struct CommonSetup { group_with_tokens: GroupWithTokens, serum_market_cookie: SpotMarketCookie, @@ -1561,6 +1721,14 @@ struct CommonSetup { } async fn common_setup(context: &TestContext, deposit_amount: u64) -> CommonSetup { + common_setup2(context, deposit_amount, 10000000).await +} + +async fn common_setup2( + context: &TestContext, + deposit_amount: u64, + vault_funding: u64, +) -> CommonSetup { let admin = TestKeypair::new(); let owner = context.users[0].key; let payer = context.users[1].key; @@ -1635,17 +1803,19 @@ async fn common_setup(context: &TestContext, deposit_amount: u64) -> CommonSetup ) .await; // to have enough funds in the vaults - create_funded_account( - &solana, - group, - owner, - 3, - &context.users[1], - mints, - 10000000, - 0, - ) - .await; + if vault_funding > 0 { + create_funded_account( + &solana, + group, + owner, + 3, + &context.users[1], + mints, + 10000000, + 0, + ) + .await; + } let open_orders = send_tx( solana, 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 62827222d..a00f98ba4 100644 --- a/programs/mango-v4/tests/cases/test_token_conditional_swap.rs +++ b/programs/mango-v4/tests/cases/test_token_conditional_swap.rs @@ -1013,3 +1013,146 @@ async fn test_token_conditional_swap_premium_auction() -> Result<(), TransportEr Ok(()) } + +#[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(); + + 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, account, tokens + // + + let mango_setup::GroupWithTokens { group, tokens, .. } = mango_setup::GroupWithTokensConfig { + admin, + payer, + mints: mints.to_vec(), + ..mango_setup::GroupWithTokensConfig::default() + } + .create(solana) + .await; + let quote_token = &tokens[0]; + let base_token = &tokens[1]; + + // total deposits on quote and base is 2x this value + let deposit_amount = 1_000f64; + let account = create_funded_account( + &solana, + group, + owner, + 0, + &context.users[1], + &mints[..1], + deposit_amount as u64, + 0, + ) + .await; + let liqor = create_funded_account( + &solana, + group, + owner, + 1, + &context.users[1], + mints, + deposit_amount as u64, + 0, + ) + .await; + create_funded_account( + &solana, + group, + owner, + 99, + &context.users[1], + &mints[1..], + deposit_amount as u64, + 0, + ) + .await; + + // + // SETUP: A base deposit limit + // + send_tx( + solana, + TokenEdit { + group, + admin, + mint: base_token.mint.pubkey, + fallback_oracle: Pubkey::default(), + options: mango_v4::instruction::TokenEdit { + deposit_limit_opt: Some(2500), + ..token_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); + + // + // SETUP: Sell base token from "account" creating borrows that are deposited into liqor, + // increasing the total deposits + // + send_tx( + solana, + TokenConditionalSwapCreateInstruction { + account, + owner, + buy_mint: quote_token.mint.pubkey, + sell_mint: base_token.mint.pubkey, + max_buy: 900, + max_sell: 900, + price_lower_limit: 0.0, + price_upper_limit: 10.0, + price_premium_rate: 0.01, + allow_creating_deposits: true, + allow_creating_borrows: true, + }, + ) + .await + .unwrap(); + + // + // TEST: Large execution fails, a bit smaller is ok + // + + send_tx_expect_error!( + solana, + TokenConditionalSwapTriggerInstruction { + liqee: account, + liqor, + liqor_owner: owner, + index: 0, + max_buy_token_to_liqee: 100000, + max_sell_token_to_liqor: 501, + min_buy_token: 1, + min_taker_price: 0.0, + }, + MangoError::BankDepositLimit, + ); + + send_tx( + solana, + TokenConditionalSwapTriggerInstruction { + liqee: account, + liqor, + liqor_owner: owner, + index: 0, + max_buy_token_to_liqee: 100000, + max_sell_token_to_liqor: 499, + min_buy_token: 1, + min_taker_price: 0.0, + }, + ) + .await + .unwrap(); + + Ok(()) +} diff --git a/programs/mango-v4/tests/program_test/mango_client.rs b/programs/mango-v4/tests/program_test/mango_client.rs index 64b4dac84..99cf2c82b 100644 --- a/programs/mango-v4/tests/program_test/mango_client.rs +++ b/programs/mango-v4/tests/program_test/mango_client.rs @@ -69,6 +69,27 @@ pub async fn send_tx_get_metadata( .await } +#[macro_export] +macro_rules! send_tx_expect_error { + ($solana:expr, $ix:expr, $err:expr $(,)?) => { + let result = send_tx($solana, $ix).await; + let expected_err: u32 = $err.into(); + match result { + Ok(_) => assert!(false, "no error returned"), + Err(TransportError::TransactionError( + solana_sdk::transaction::TransactionError::InstructionError( + _, + solana_program::instruction::InstructionError::Custom(err_num), + ), + )) => { + assert_eq!(err_num, expected_err, "wrong error code"); + } + _ => assert!(false, "not a mango error"), + } + }; +} +pub use send_tx_expect_error; + /// Build a transaction from multiple instructions pub struct ClientTransaction { solana: Arc, @@ -111,6 +132,28 @@ impl<'a> ClientTransaction { Ok(()) } + pub async fn send_expect_error( + &self, + error: mango_v4::error::MangoError, + ) -> std::result::Result<(), BanksClientError> { + let tx_result = self + .solana + .process_transaction(&self.instructions, Some(&self.signers)) + .await?; + match tx_result.result { + Ok(_) => assert!(false, "no error returned"), + Err(solana_sdk::transaction::TransactionError::InstructionError( + _, + solana_program::instruction::InstructionError::Custom(err_num), + )) => { + let expected_err: u32 = error.into(); + assert_eq!(err_num, expected_err, "wrong error code"); + } + _ => assert!(false, "not a mango error"), + } + Ok(()) + } + // Tx error still returns success pub async fn send_get_metadata( &self, @@ -1011,6 +1054,7 @@ impl ClientInstruction for TokenRegisterInstruction { interest_curve_scaling: 1.0, interest_target_utilization: 0.5, group_insurance_fund: true, + deposit_limit: 0, }; let bank = Pubkey::find_program_address( @@ -1262,6 +1306,8 @@ pub fn token_edit_instruction_default() -> mango_v4::instruction::TokenEdit { maint_weight_shift_asset_target_opt: None, maint_weight_shift_liab_target_opt: None, maint_weight_shift_abort: false, + set_fallback_oracle: false, + deposit_limit_opt: None, } } diff --git a/programs/mango-v4/tests/program_test/utils.rs b/programs/mango-v4/tests/program_test/utils.rs index 216688a3f..d774f541f 100644 --- a/programs/mango-v4/tests/program_test/utils.rs +++ b/programs/mango-v4/tests/program_test/utils.rs @@ -87,15 +87,12 @@ pub fn assert_mango_error( ) { match result { Ok(_) => assert!(false, "No error returned"), - Err(TransportError::TransactionError(tx_err)) => match tx_err { - TransactionError::InstructionError(_, err) => match err { - InstructionError::Custom(err_num) => { - assert_eq!(*err_num, expected_error, "{}", comment); - } - _ => assert!(false, "Not a mango error"), - }, - _ => assert!(false, "Not a mango error"), - }, + Err(TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(err_num), + ))) => { + assert_eq!(*err_num, expected_error, "{}", comment); + } _ => assert!(false, "Not a mango error"), } } diff --git a/ts/client/src/accounts/bank.ts b/ts/client/src/accounts/bank.ts index 6b491a773..b46a521ae 100644 --- a/ts/client/src/accounts/bank.ts +++ b/ts/client/src/accounts/bank.ts @@ -132,12 +132,13 @@ export class Bank implements BankForHealth { flashLoanSwapFeeRate: number; interestTargetUtilization: number; interestCurveScaling: number; - depositsInSerum: BN; + potentialSerumTokens: BN; maintWeightShiftStart: BN; maintWeightShiftEnd: BN; maintWeightShiftDurationInv: I80F48Dto; maintWeightShiftAssetTarget: I80F48Dto; maintWeightShiftLiabTarget: I80F48Dto; + depositLimit: BN; }, ): Bank { return new Bank( @@ -191,12 +192,13 @@ export class Bank implements BankForHealth { obj.flashLoanSwapFeeRate, obj.interestTargetUtilization, obj.interestCurveScaling, - obj.depositsInSerum, + obj.potentialSerumTokens, obj.maintWeightShiftStart, obj.maintWeightShiftEnd, obj.maintWeightShiftDurationInv, obj.maintWeightShiftAssetTarget, obj.maintWeightShiftLiabTarget, + obj.depositLimit, ); } @@ -251,12 +253,13 @@ export class Bank implements BankForHealth { public flashLoanSwapFeeRate: number, public interestTargetUtilization: number, public interestCurveScaling: number, - public depositsInSerum: BN, + public potentialSerumTokens: BN, public maintWeightShiftStart: BN, public maintWeightShiftEnd: BN, maintWeightShiftDurationInv: I80F48Dto, maintWeightShiftAssetTarget: I80F48Dto, maintWeightShiftLiabTarget: I80F48Dto, + public depositLimit: BN, ) { this.name = utf8.decode(new Uint8Array(name)).split('\x00')[0]; this.oracleConfig = { diff --git a/ts/client/src/client.ts b/ts/client/src/client.ts index 2becc0a17..266c11ee0 100644 --- a/ts/client/src/client.ts +++ b/ts/client/src/client.ts @@ -419,6 +419,7 @@ export class MangoClient { params.interestCurveScaling, params.interestTargetUtilization, params.groupInsuranceFund, + params.depositLimit, ) .accounts({ group: group.publicKey, @@ -501,6 +502,8 @@ export class MangoClient { params.maintWeightShiftAssetTarget, params.maintWeightShiftLiabTarget, params.maintWeightShiftAbort ?? false, + false, // setFallbackOracle, unused + params.depositLimit, ) .accounts({ group: group.publicKey, @@ -1568,9 +1571,10 @@ export class MangoClient { quoteBank: Bank, marketIndex: number, name: string, + oraclePriceBand: number, ): Promise { const ix = await this.program.methods - .serum3RegisterMarket(marketIndex, name) + .serum3RegisterMarket(marketIndex, name, oraclePriceBand) .accounts({ group: group.publicKey, admin: (this.program.provider as AnchorProvider).wallet.publicKey, @@ -1590,11 +1594,12 @@ export class MangoClient { reduceOnly: boolean | null, forceClose: boolean | null, name: string | null, + oraclePriceBand: number | null, ): Promise { const serum3Market = group.serum3MarketsMapByMarketIndex.get(serum3MarketIndex); const ix = await this.program.methods - .serum3EditMarket(reduceOnly, forceClose, name) + .serum3EditMarket(reduceOnly, forceClose, name, oraclePriceBand) .accounts({ group: group.publicKey, admin: (this.program.provider as AnchorProvider).wallet.publicKey, diff --git a/ts/client/src/clientIxParamBuilder.ts b/ts/client/src/clientIxParamBuilder.ts index 27b2085b8..17f70963f 100644 --- a/ts/client/src/clientIxParamBuilder.ts +++ b/ts/client/src/clientIxParamBuilder.ts @@ -27,6 +27,7 @@ export interface TokenRegisterParams { flashLoanSwapFeeRate: number; interestCurveScaling: number; interestTargetUtilization: number; + depositLimit: BN; } export const DefaultTokenRegisterParams: TokenRegisterParams = { @@ -64,6 +65,7 @@ export const DefaultTokenRegisterParams: TokenRegisterParams = { flashLoanSwapFeeRate: 0.0005, interestCurveScaling: 4.0, interestTargetUtilization: 0.5, + depositLimit: new BN(0), }; export interface TokenEditParams { @@ -101,6 +103,7 @@ export interface TokenEditParams { maintWeightShiftAssetTarget: number | null; maintWeightShiftLiabTarget: number | null; maintWeightShiftAbort: boolean | null; + depositLimit: BN | null; } export const NullTokenEditParams: TokenEditParams = { @@ -138,6 +141,7 @@ export const NullTokenEditParams: TokenEditParams = { maintWeightShiftAssetTarget: null, maintWeightShiftLiabTarget: null, maintWeightShiftAbort: null, + depositLimit: null, }; export interface PerpEditParams { diff --git a/ts/client/src/mango_v4.ts b/ts/client/src/mango_v4.ts index ab190b6a7..5d5c5dbb4 100644 --- a/ts/client/src/mango_v4.ts +++ b/ts/client/src/mango_v4.ts @@ -614,6 +614,10 @@ export type MangoV4 = { { "name": "groupInsuranceFund", "type": "bool" + }, + { + "name": "depositLimit", + "type": "u64" } ] }, @@ -988,6 +992,12 @@ export type MangoV4 = { { "name": "maintWeightShiftAbort", "type": "bool" + }, + { + "name": "depositLimitOpt", + "type": { + "option": "u64" + } } ] }, @@ -2357,6 +2367,10 @@ export type MangoV4 = { { "name": "name", "type": "string" + }, + { + "name": "oraclePriceBand", + "type": "f32" } ] }, @@ -2400,6 +2414,12 @@ export type MangoV4 = { "type": { "option": "string" } + }, + { + "name": "oraclePriceBandOpt", + "type": { + "option": "f32" + } } ] }, @@ -2742,6 +2762,9 @@ export type MangoV4 = { }, { "name": "serum3PlaceOrderV2", + "docs": [ + "requires the receiver_bank in the health account list to be writable" + ], "accounts": [ { "name": "group", @@ -7266,11 +7289,9 @@ export type MangoV4 = { "name": "potentialSerumTokens", "docs": [ "Largest amount of tokens that might be added the the bank based on", - "serum open order execution.", - "", - "Can be negative with multiple banks, then it'd need to be balanced in the keeper." + "serum open order execution." ], - "type": "i64" + "type": "u64" }, { "name": "maintWeightShiftStart", @@ -7298,12 +7319,19 @@ export type MangoV4 = { "defined": "I80F48" } }, + { + "name": "depositLimit", + "docs": [ + "zero means none, in token native" + ], + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 2008 + 2000 ] } } @@ -8402,10 +8430,20 @@ export type MangoV4 = { "type": { "array": [ "u8", - 5 + 1 ] } }, + { + "name": "oraclePriceBand", + "docs": [ + "Limit orders must be <= oracle * (1+band) and >= oracle / (1+band)", + "", + "Zero value is the default due to migration and disables the limit,", + "same as f32::MAX." + ], + "type": "f32" + }, { "name": "registrationTime", "type": "u64" @@ -13467,6 +13505,16 @@ export type MangoV4 = { "code": 6060, "name": "HealthAccountBankNotWritable", "msg": "a bank in the health account list should be writable but is not" + }, + { + "code": 6061, + "name": "Serum3PriceBandExceeded", + "msg": "the market does not allow limit orders too far from the current oracle value" + }, + { + "code": 6062, + "name": "BankDepositLimit", + "msg": "deposit crosses the token's deposit limit" } ] }; @@ -14087,6 +14135,10 @@ export const IDL: MangoV4 = { { "name": "groupInsuranceFund", "type": "bool" + }, + { + "name": "depositLimit", + "type": "u64" } ] }, @@ -14461,6 +14513,12 @@ export const IDL: MangoV4 = { { "name": "maintWeightShiftAbort", "type": "bool" + }, + { + "name": "depositLimitOpt", + "type": { + "option": "u64" + } } ] }, @@ -15830,6 +15888,10 @@ export const IDL: MangoV4 = { { "name": "name", "type": "string" + }, + { + "name": "oraclePriceBand", + "type": "f32" } ] }, @@ -15873,6 +15935,12 @@ export const IDL: MangoV4 = { "type": { "option": "string" } + }, + { + "name": "oraclePriceBandOpt", + "type": { + "option": "f32" + } } ] }, @@ -16215,6 +16283,9 @@ export const IDL: MangoV4 = { }, { "name": "serum3PlaceOrderV2", + "docs": [ + "requires the receiver_bank in the health account list to be writable" + ], "accounts": [ { "name": "group", @@ -20739,11 +20810,9 @@ export const IDL: MangoV4 = { "name": "potentialSerumTokens", "docs": [ "Largest amount of tokens that might be added the the bank based on", - "serum open order execution.", - "", - "Can be negative with multiple banks, then it'd need to be balanced in the keeper." + "serum open order execution." ], - "type": "i64" + "type": "u64" }, { "name": "maintWeightShiftStart", @@ -20771,12 +20840,19 @@ export const IDL: MangoV4 = { "defined": "I80F48" } }, + { + "name": "depositLimit", + "docs": [ + "zero means none, in token native" + ], + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 2008 + 2000 ] } } @@ -21875,10 +21951,20 @@ export const IDL: MangoV4 = { "type": { "array": [ "u8", - 5 + 1 ] } }, + { + "name": "oraclePriceBand", + "docs": [ + "Limit orders must be <= oracle * (1+band) and >= oracle / (1+band)", + "", + "Zero value is the default due to migration and disables the limit,", + "same as f32::MAX." + ], + "type": "f32" + }, { "name": "registrationTime", "type": "u64" @@ -26940,6 +27026,16 @@ export const IDL: MangoV4 = { "code": 6060, "name": "HealthAccountBankNotWritable", "msg": "a bank in the health account list should be writable but is not" + }, + { + "code": 6061, + "name": "Serum3PriceBandExceeded", + "msg": "the market does not allow limit orders too far from the current oracle value" + }, + { + "code": 6062, + "name": "BankDepositLimit", + "msg": "deposit crosses the token's deposit limit" } ] };