Deposit limits (#806)

- limit deposits (via deposit, flash loan, tcs)
- limit potential deposits via openbook settle
  by restricting placable orders via potential_serum_tokens
- introduce Serum3PlaceOrderV2 for this purpose
- account for new limits in liquidator, max_swap

(cherry picked from commit 42e31ae859)
This commit is contained in:
Christian Kamm 2023-12-05 15:43:38 +01:00
parent f533d65a58
commit e7bfa4e03e
26 changed files with 1375 additions and 160 deletions

View File

@ -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,

View File

@ -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::<u64>()
.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,

View File

@ -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,

View File

@ -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"
}
]
}

View File

@ -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 {

View File

@ -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<I80F48> {
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<I80F48> {
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);
}
{

View File

@ -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;

View File

@ -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::<Bank>()?;
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::<Bank>()?;
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,

View File

@ -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

View File

@ -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()),
});

View File

@ -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::<i64>();

View File

@ -49,6 +49,8 @@ pub fn token_edit(
maint_weight_shift_asset_target_opt: Option<f32>,
maint_weight_shift_liab_target_opt: Option<f32>,
maint_weight_shift_abort: bool,
set_fallback_oracle: bool, // unused, introduced in v0.22
deposit_limit_opt: Option<u64>,
) -> 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

View File

@ -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) =

View File

@ -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) =

View File

@ -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<f32>,
maint_weight_shift_liab_target_opt: Option<f32>,
maint_weight_shift_abort: bool,
set_fallback_oracle: bool, // unused, introduced in v0.22
deposit_limit_opt: Option<u64>,
) -> 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<Serum3PlaceOrder>,

View File

@ -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::<Bank>(),
@ -203,7 +207,9 @@ const_assert_eq!(
+ 8 * 2
+ 8 * 2
+ 16 * 3
+ 2008
+ 32
+ 8
+ 1968
);
const_assert_eq!(size_of::<Bank>(), 3064);
const_assert_eq!(size_of::<Bank>() % 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<TransferResult> {
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();

View File

@ -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(())
}

View File

@ -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(())
}

View File

@ -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,
&quote_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::<u64>()
}
};
//
// 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::<u64>()
}
};
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,

View File

@ -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(())
}

View File

@ -69,6 +69,27 @@ pub async fn send_tx_get_metadata<CI: ClientInstruction>(
.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<SolanaCookie>,
@ -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,
}
}

View File

@ -87,15 +87,12 @@ pub fn assert_mango_error<T>(
) {
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"),
}
}

View File

@ -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 = {

View File

@ -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<MangoSignatureStatus> {
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<MangoSignatureStatus> {
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,

View File

@ -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 {

View File

@ -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"
}
]
};