OpenBook place order: respect bank reduce_only flags (#569)

This commit is contained in:
Christian Kamm 2023-05-04 08:02:28 +02:00 committed by GitHub
parent 6f47ad92d6
commit 0da1b6728b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 344 additions and 60 deletions

View File

@ -149,6 +149,32 @@ pub struct Serum3Info {
}
impl Serum3Info {
#[inline(always)]
fn all_reserved_as_base(
&self,
health_type: HealthType,
quote_info: &TokenInfo,
base_info: &TokenInfo,
) -> I80F48 {
let quote_asset = quote_info.prices.asset(health_type);
let base_liab = base_info.prices.liab(health_type);
// OPTIMIZATION: These divisions can be extremely expensive (up to 5k CU each)
self.reserved_base + self.reserved_quote * quote_asset / base_liab
}
#[inline(always)]
fn all_reserved_as_quote(
&self,
health_type: HealthType,
quote_info: &TokenInfo,
base_info: &TokenInfo,
) -> I80F48 {
let base_asset = base_info.prices.asset(health_type);
let quote_liab = quote_info.prices.liab(health_type);
// OPTIMIZATION: These divisions can be extremely expensive (up to 5k CU each)
self.reserved_quote + self.reserved_base * base_asset / quote_liab
}
#[inline(always)]
fn health_contribution(
&self,
@ -600,23 +626,15 @@ impl HealthCache {
let mut serum3_reserved = Vec::with_capacity(self.serum3_infos.len());
for info in self.serum3_infos.iter() {
let quote = &self.token_infos[info.quote_index];
let base = &self.token_infos[info.base_index];
let quote_info = &self.token_infos[info.quote_index];
let base_info = &self.token_infos[info.base_index];
let reserved_base = info.reserved_base;
let reserved_quote = info.reserved_quote;
let quote_asset = quote.prices.asset(health_type);
let base_liab = base.prices.liab(health_type);
// OPTIMIZATION: These divisions can be extremely expensive (up to 5k CU each)
let all_reserved_as_base = reserved_base + reserved_quote * quote_asset / base_liab;
let base_asset = base.prices.asset(health_type);
let quote_liab = quote.prices.liab(health_type);
let all_reserved_as_quote = reserved_quote + reserved_base * base_asset / quote_liab;
let all_reserved_as_base =
info.all_reserved_as_base(health_type, quote_info, base_info);
let all_reserved_as_quote =
info.all_reserved_as_quote(health_type, quote_info, base_info);
let base_max_reserved = &mut token_max_reserved[info.base_index];
// note: () does not work with mutable references
*base_max_reserved += all_reserved_as_base;
let quote_max_reserved = &mut token_max_reserved[info.quote_index];
*quote_max_reserved += all_reserved_as_quote;
@ -688,6 +706,36 @@ impl HealthCache {
}
health
}
pub fn total_serum3_potential(
&self,
health_type: HealthType,
token_index: TokenIndex,
) -> Result<I80F48> {
let target_token_info_index = self.token_info_index(token_index)?;
let total_reserved = self
.serum3_infos
.iter()
.filter_map(|info| {
if info.quote_index == target_token_info_index {
Some(info.all_reserved_as_quote(
health_type,
&self.token_infos[info.quote_index],
&self.token_infos[info.base_index],
))
} else if info.base_index == target_token_info_index {
Some(info.all_reserved_as_base(
health_type,
&self.token_infos[info.quote_index],
&self.token_infos[info.base_index],
))
} else {
None
}
})
.sum();
Ok(total_reserved)
}
}
pub(crate) fn find_token_info_index(infos: &[TokenInfo], token_index: TokenIndex) -> Result<usize> {

View File

@ -113,6 +113,7 @@ pub fn serum3_place_order(
//
// Validation
//
let receiver_token_index;
{
let account = ctx.accounts.account.load_full()?;
// account constraint #1
@ -138,23 +139,40 @@ pub fn serum3_place_order(
Serum3Side::Ask => serum_market.base_token_index,
};
require_eq!(payer_bank.token_index, payer_token_index);
receiver_token_index = match side {
Serum3Side::Bid => serum_market.base_token_index,
Serum3Side::Ask => serum_market.quote_token_index,
};
}
//
// Pre-health computation
//
let mut account = ctx.accounts.account.load_full_mut()?;
let retriever = new_fixed_order_account_retriever(ctx.remaining_accounts, &account.borrow())?;
let mut health_cache =
new_health_cache(&account.borrow(), &retriever).context("pre-withdraw init health")?;
let pre_health_opt = if !account.fixed.is_in_health_region() {
let retriever =
new_fixed_order_account_retriever(ctx.remaining_accounts, &account.borrow())?;
let health_cache =
new_health_cache(&account.borrow(), &retriever).context("pre-withdraw init health")?;
let pre_init_health = account.check_health_pre(&health_cache)?;
Some((health_cache, pre_init_health))
Some(pre_init_health)
} else {
None
};
// Check if the bank for the token whose balance is increased is in reduce-only mode
let receiver_bank_reduce_only = {
// The token position already exists, but we need the active_index.
let (_, _, active_index) = account.ensure_token_position(receiver_token_index)?;
let group_key = ctx.accounts.group.key();
let receiver_bank = retriever
.bank_and_oracle(&group_key, active_index, receiver_token_index)?
.0;
receiver_bank.are_deposits_reduce_only()
};
drop(retriever);
//
// Before-order tracking
//
@ -212,30 +230,29 @@ pub fn serum3_place_order(
};
cpi_place_order(ctx.accounts, order)?;
let oo_difference = {
let oo_ai = &ctx.accounts.open_orders.as_ref();
let open_orders = load_open_orders_ref(oo_ai)?;
let after_oo = OpenOrdersSlim::from_oo(&open_orders);
emit!(Serum3OpenOrdersBalanceLogV2 {
mango_group: ctx.accounts.group.key(),
mango_account: ctx.accounts.account.key(),
market_index: serum_market.market_index,
base_token_index: serum_market.base_token_index,
quote_token_index: serum_market.quote_token_index,
base_total: after_oo.native_coin_total,
base_free: after_oo.native_coin_free,
quote_total: after_oo.native_pc_total,
quote_free: after_oo.native_pc_free,
referrer_rebates_accrued: after_oo.referrer_rebates_accrued,
});
OODifference::new(&before_oo, &after_oo)
};
//
// After-order tracking
//
let after_oo = {
let oo_ai = &ctx.accounts.open_orders.as_ref();
let open_orders = load_open_orders_ref(oo_ai)?;
OpenOrdersSlim::from_oo(&open_orders)
};
let oo_difference = OODifference::new(&before_oo, &after_oo);
emit!(Serum3OpenOrdersBalanceLogV2 {
mango_group: ctx.accounts.group.key(),
mango_account: ctx.accounts.account.key(),
market_index: serum_market.market_index,
base_token_index: serum_market.base_token_index,
quote_token_index: serum_market.quote_token_index,
base_total: after_oo.native_coin_total,
base_free: after_oo.native_coin_free,
quote_total: after_oo.native_pc_total,
quote_free: after_oo.native_pc_free,
referrer_rebates_accrued: after_oo.referrer_rebates_accrued,
});
ctx.accounts.payer_vault.reload()?;
let after_vault = ctx.accounts.payer_vault.amount;
@ -264,23 +281,45 @@ pub fn serum3_place_order(
};
if withdrawn_from_vault > position_native {
require_msg_typed!(
!payer_bank.are_borrows_reduce_only(),
MangoError::TokenInReduceOnlyMode,
"the payer tokens cannot be borrowed"
);
let oracle_price =
payer_bank.oracle_price(&AccountInfoRef::borrow(&ctx.accounts.payer_oracle)?, None)?;
payer_bank.enforce_min_vault_to_deposits_ratio((*ctx.accounts.payer_vault).as_ref())?;
payer_bank.check_net_borrows(oracle_price)?;
}
vault_difference.adjust_health_cache_token_balance(&mut health_cache, &payer_bank)?;
oo_difference.adjust_health_cache_serum3_state(&mut health_cache, &serum_market)?;
// Check the receiver's reduce only flag.
//
// Note that all orders on the book executing can still cause a net deposit. That's because
// the total serum3 potential amount assumes all reserved amounts convert at the current
// oracle price.
if receiver_bank_reduce_only {
let balance = health_cache
.token_info(receiver_token_index)?
.balance_native;
let potential =
health_cache.total_serum3_potential(HealthType::Maint, receiver_token_index)?;
require_msg_typed!(
balance + potential < 1,
MangoError::TokenInReduceOnlyMode,
"receiver bank does not accept deposits"
);
}
//
// Health check
//
if let Some((mut health_cache, pre_init_health)) = pre_health_opt {
vault_difference.adjust_health_cache_token_balance(&mut health_cache, &payer_bank)?;
oo_difference.adjust_health_cache_serum3_state(&mut health_cache, &serum_market)?;
if let Some(pre_init_health) = pre_health_opt {
account.check_health_post(&health_cache, pre_init_health)?;
}
// TODO: enforce min_vault_to_deposits_ratio
Ok(())
}

View File

@ -4,7 +4,7 @@ use mango_v4::accounts_ix::{Serum3OrderType, Serum3SelfTradeBehavior, Serum3Side
#[tokio::test]
async fn test_health_wrap() -> Result<(), TransportError> {
let mut test_builder = TestContextBuilder::new();
test_builder.test().set_compute_max_units(135000);
test_builder.test().set_compute_max_units(140000);
let context = test_builder.start_default().await;
let solana = &context.solana.clone();

View File

@ -1,3 +1,4 @@
#![allow(dead_code)]
use super::*;
use mango_v4::accounts_ix::{Serum3OrderType, Serum3SelfTradeBehavior, Serum3Side};
@ -34,15 +35,25 @@ impl SerumOrderPlacer {
None
}
async fn bid(&mut self, limit_price: f64, max_base: u64) -> Option<(u128, u64)> {
async fn try_bid(
&mut self,
limit_price: f64,
max_base: u64,
taker: bool,
) -> Result<mango_v4::accounts::Serum3PlaceOrder, TransportError> {
let client_order_id = self.inc_client_order_id();
let fees = if taker { 0.0004 } else { 0.0 };
send_tx(
&self.solana,
Serum3PlaceOrderInstruction {
side: Serum3Side::Bid,
limit_price: (limit_price * 100.0 / 10.0) as u64, // in quote_lot (10) per base lot (100)
max_base_qty: max_base / 100, // in base lot (100)
max_native_quote_qty_including_fees: (limit_price * (max_base as f64)) as u64,
// 4 bps taker fees added in
max_native_quote_qty_including_fees: (limit_price
* (max_base as f64)
* (1.0 + fees))
.ceil() as u64,
self_trade_behavior: Serum3SelfTradeBehavior::AbortTransaction,
order_type: Serum3OrderType::Limit,
client_order_id,
@ -53,12 +64,25 @@ impl SerumOrderPlacer {
},
)
.await
.unwrap();
self.find_order_id_for_client_order_id(client_order_id)
}
async fn bid_maker(&mut self, limit_price: f64, max_base: u64) -> Option<(u128, u64)> {
self.try_bid(limit_price, max_base, false).await.unwrap();
self.find_order_id_for_client_order_id(self.next_client_order_id - 1)
.await
}
async fn ask(&mut self, limit_price: f64, max_base: u64) -> Option<(u128, u64)> {
async fn bid_taker(&mut self, limit_price: f64, max_base: u64) -> Option<(u128, u64)> {
self.try_bid(limit_price, max_base, true).await.unwrap();
self.find_order_id_for_client_order_id(self.next_client_order_id - 1)
.await
}
async fn try_ask(
&mut self,
limit_price: f64,
max_base: u64,
) -> Result<mango_v4::accounts::Serum3PlaceOrder, TransportError> {
let client_order_id = self.inc_client_order_id();
send_tx(
&self.solana,
@ -77,8 +101,11 @@ impl SerumOrderPlacer {
},
)
.await
.unwrap();
self.find_order_id_for_client_order_id(client_order_id)
}
async fn ask(&mut self, limit_price: f64, max_base: u64) -> Option<(u128, u64)> {
self.try_ask(limit_price, max_base).await.unwrap();
self.find_order_id_for_client_order_id(self.next_client_order_id - 1)
.await
}
@ -262,7 +289,7 @@ async fn test_serum_basics() -> Result<(), TransportError> {
//
// TEST: Place an order
//
let (order_id, _) = order_placer.bid(1.0, 100).await.unwrap();
let (order_id, _) = order_placer.bid_maker(1.0, 100).await.unwrap();
check_prev_instruction_post_health(&solana, account).await;
let native0 = account_position(solana, account, base_token.bank).await;
@ -362,7 +389,7 @@ async fn test_serum_loan_origination_fees() -> Result<(), TransportError> {
// TEST: Placing and canceling an order does not take loan origination fees even if borrows are needed
//
{
let (bid_order_id, _) = order_placer.bid(1.0, 200000).await.unwrap();
let (bid_order_id, _) = order_placer.bid_maker(1.0, 200000).await.unwrap();
let (ask_order_id, _) = order_placer.ask(2.0, 200000).await.unwrap();
let o = order_placer.mango_serum_orders().await;
@ -377,7 +404,7 @@ async fn test_serum_loan_origination_fees() -> Result<(), TransportError> {
assert_eq!(o.quote_borrows_without_fee, 19999);
// placing new, slightly larger orders increases the borrow_without_fee amount only by a small amount
let (bid_order_id, _) = order_placer.bid(1.0, 210000).await.unwrap();
let (bid_order_id, _) = order_placer.bid_maker(1.0, 210000).await.unwrap();
let (ask_order_id, _) = order_placer.ask(2.0, 300000).await.unwrap();
let o = order_placer.mango_serum_orders().await;
@ -429,7 +456,10 @@ async fn test_serum_loan_origination_fees() -> Result<(), TransportError> {
.collected_fees_native;
// account2 has an order on the book
order_placer2.bid(1.0, bid_amount as u64).await.unwrap();
order_placer2
.bid_maker(1.0, bid_amount as u64)
.await
.unwrap();
// account takes
order_placer.ask(1.0, ask_amount as u64).await.unwrap();
@ -566,7 +596,7 @@ async fn test_serum_settle_v1() -> Result<(), TransportError> {
let base2_start = account_position(solana, account2, base_bank).await;
// account2 has an order on the book, account takes
order_placer2.bid(1.0, amount as u64).await.unwrap();
order_placer2.bid_maker(1.0, amount as u64).await.unwrap();
order_placer.ask(1.0, amount as u64).await.unwrap();
context
@ -663,7 +693,7 @@ async fn test_serum_settle_v2_to_dao() -> Result<(), TransportError> {
let base2_start = account_position(solana, account2, base_bank).await;
// account2 has an order on the book, account takes
order_placer2.bid(1.0, amount as u64).await.unwrap();
order_placer2.bid_maker(1.0, amount as u64).await.unwrap();
order_placer.ask(1.0, amount as u64).await.unwrap();
context
@ -756,7 +786,7 @@ async fn test_serum_settle_v2_to_account() -> Result<(), TransportError> {
let base2_start = account_position(solana, account2, base_bank).await;
// account2 has an order on the book, account takes
order_placer2.bid(1.0, amount as u64).await.unwrap();
order_placer2.bid_maker(1.0, amount as u64).await.unwrap();
order_placer.ask(1.0, amount as u64).await.unwrap();
context
@ -805,6 +835,173 @@ async fn test_serum_settle_v2_to_account() -> Result<(), TransportError> {
Ok(())
}
#[tokio::test]
async fn test_serum_reduce_only_borrows() -> Result<(), TransportError> {
let mut test_builder = TestContextBuilder::new();
test_builder.test().set_compute_max_units(95_000); // Serum3PlaceOrder needs 92.8k
let context = test_builder.start_default().await;
let solana = &context.solana.clone();
//
// SETUP: Create a group, accounts, market etc
//
let deposit_amount = 1000;
let CommonSetup {
group_with_tokens,
base_token,
mut order_placer,
..
} = common_setup(&context, deposit_amount).await;
send_tx(
solana,
TokenMakeReduceOnly {
group: group_with_tokens.group,
admin: group_with_tokens.admin,
mint: base_token.mint.pubkey,
reduce_only: 2,
force_close: false,
},
)
.await
.unwrap();
//
// TEST: Cannot borrow tokens when bank is reduce only
//
let err = order_placer.try_ask(1.0, 1100).await;
assert_mango_error(&err, MangoError::TokenInReduceOnlyMode.into(), "".into());
order_placer.try_ask(0.5, 500).await.unwrap();
let err = order_placer.try_ask(1.0, 600).await;
assert_mango_error(&err, MangoError::TokenInReduceOnlyMode.into(), "".into());
order_placer.try_ask(2.0, 500).await.unwrap();
let err = order_placer.try_ask(1.0, 100).await;
assert_mango_error(&err, MangoError::TokenInReduceOnlyMode.into(), "".into());
Ok(())
}
#[tokio::test]
async fn test_serum_reduce_only_deposits1() -> Result<(), TransportError> {
let mut test_builder = TestContextBuilder::new();
test_builder.test().set_compute_max_units(95_000); // Serum3PlaceOrder needs 92.8k
let context = test_builder.start_default().await;
let solana = &context.solana.clone();
//
// SETUP: Create a group, accounts, market etc
//
let deposit_amount = 1000;
let CommonSetup {
group_with_tokens,
base_token,
mut order_placer,
mut order_placer2,
..
} = common_setup(&context, deposit_amount).await;
send_tx(
solana,
TokenMakeReduceOnly {
group: group_with_tokens.group,
admin: group_with_tokens.admin,
mint: base_token.mint.pubkey,
reduce_only: 1,
force_close: false,
},
)
.await
.unwrap();
//
// TEST: Cannot buy tokens when deposits are already >0
//
// fails to place on the book
let err = order_placer.try_bid(1.0, 1000, false).await;
assert_mango_error(&err, MangoError::TokenInReduceOnlyMode.into(), "".into());
// also fails as a taker order
order_placer2.ask(1.0, 500).await.unwrap();
let err = order_placer.try_bid(1.0, 100, true).await;
assert_mango_error(&err, MangoError::TokenInReduceOnlyMode.into(), "".into());
Ok(())
}
#[tokio::test]
async fn test_serum_reduce_only_deposits2() -> Result<(), TransportError> {
let mut test_builder = TestContextBuilder::new();
test_builder.test().set_compute_max_units(95_000); // Serum3PlaceOrder needs 92.8k
let context = test_builder.start_default().await;
let solana = &context.solana.clone();
//
// SETUP: Create a group, accounts, market etc
//
let deposit_amount = 1000;
let CommonSetup {
group_with_tokens,
base_token,
mut order_placer,
mut order_placer2,
..
} = common_setup(&context, deposit_amount).await;
// Give account some base token borrows (-500)
send_tx(
solana,
TokenWithdrawInstruction {
amount: 1500,
allow_borrow: true,
account: order_placer.account,
owner: order_placer.owner,
token_account: context.users[0].token_accounts[1],
bank_index: 0,
},
)
.await
.unwrap();
//
// TEST: Cannot buy tokens when deposits are already >0
//
send_tx(
solana,
TokenMakeReduceOnly {
group: group_with_tokens.group,
admin: group_with_tokens.admin,
mint: base_token.mint.pubkey,
reduce_only: 1,
force_close: false,
},
)
.await
.unwrap();
// cannot place a large order on the book that would deposit too much
let err = order_placer.try_bid(1.0, 600, false).await;
assert_mango_error(&err, MangoError::TokenInReduceOnlyMode.into(), "".into());
// a small order is fine
order_placer.try_bid(1.0, 100, false).await.unwrap();
// taking some is fine too
order_placer2.ask(1.0, 800).await.unwrap();
order_placer.try_bid(1.0, 100, true).await.unwrap();
// the limit for orders is reduced now, 100 received, 100 on the book
let err = order_placer.try_bid(1.0, 400, true).await;
assert_mango_error(&err, MangoError::TokenInReduceOnlyMode.into(), "".into());
Ok(())
}
struct CommonSetup {
group_with_tokens: GroupWithTokens,
serum_market_cookie: SpotMarketCookie,