Merge pull request #677 from blockworks-foundation/ckamm/tcs-net-borrow

Tcs and liquidator and net borrow limits
This commit is contained in:
Christian Kamm 2023-08-18 15:35:16 +02:00 committed by GitHub
commit 0c26977ec9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 330 additions and 153 deletions

View File

@ -7,9 +7,8 @@ use anchor_client::Cluster;
use clap::Parser;
use mango_v4::state::{PerpMarketIndex, TokenIndex};
use mango_v4_client::{
account_update_stream, chain_data, keypair_from_cli, snapshot_source, websocket_source,
AsyncChannelSendUnlessFull, Client, MangoClient, MangoClientError, MangoGroupContext,
TransactionBuilderConfig,
account_update_stream, chain_data, keypair_from_cli, snapshot_source, websocket_source, Client,
MangoClient, MangoClientError, MangoGroupContext, TransactionBuilderConfig,
};
use itertools::Itertools;
@ -25,7 +24,7 @@ pub mod token_swap_info;
pub mod trigger_tcs;
pub mod util;
use crate::util::{is_mango_account, is_mango_bank, is_mint_info, is_perp_market};
use crate::util::{is_mango_account, is_mint_info, is_perp_market};
// jemalloc seems to be better at keeping the memory footprint reasonable over
// longer periods of time
@ -317,9 +316,6 @@ async fn main() -> anyhow::Result<()> {
last_persistent_error_report: Instant::now(),
});
let (liquidation_trigger_sender, liquidation_trigger_receiver) =
async_channel::bounded::<()>(1);
info!("main loop");
// Job to update chain_data and notify the liquidation job when a new check is needed.
@ -359,29 +355,6 @@ async fn main() -> anyhow::Result<()> {
// Track all MangoAccounts: we need to iterate over them later
state.mango_accounts.insert(account_write.pubkey);
metric_mango_accounts.set(state.mango_accounts.len() as u64);
if !state.health_check_all {
state.health_check_accounts.push(account_write.pubkey);
}
liquidation_trigger_sender.send_unless_full(()).unwrap();
} else {
let mut must_check_all = false;
if is_mango_bank(&account_write.account, &mango_group).is_some() {
debug!("change to bank {}", &account_write.pubkey);
must_check_all = true;
}
if is_perp_market(&account_write.account, &mango_group).is_some() {
debug!("change to perp market {}", &account_write.pubkey);
must_check_all = true;
}
if oracles.contains(&account_write.pubkey) {
debug!("change to oracle {}", &account_write.pubkey);
must_check_all = true;
}
if must_check_all {
state.health_check_all = true;
liquidation_trigger_sender.send_unless_full(()).unwrap();
}
}
}
Message::Snapshot(snapshot) => {
@ -404,9 +377,6 @@ async fn main() -> anyhow::Result<()> {
metric_mango_accounts.set(state.mango_accounts.len() as u64);
state.one_snapshot_done = true;
state.health_check_all = true;
liquidation_trigger_sender.send_unless_full(()).unwrap();
}
_ => {}
}
@ -438,25 +408,20 @@ async fn main() -> anyhow::Result<()> {
});
let liquidation_job = tokio::spawn({
// TODO: configurable interval
let mut interval = tokio::time::interval(Duration::from_secs(5));
let shared_state = shared_state.clone();
async move {
loop {
liquidation_trigger_receiver.recv().await.unwrap();
interval.tick().await;
let account_addresses;
{
let mut state = shared_state.write().unwrap();
let account_addresses = {
let state = shared_state.write().unwrap();
if !state.one_snapshot_done {
continue;
}
account_addresses = if state.health_check_all {
state.mango_accounts.iter().cloned().collect()
} else {
state.health_check_accounts.clone()
};
state.health_check_all = false;
state.health_check_accounts = vec![];
}
state.mango_accounts.iter().cloned().collect_vec()
};
liquidation.log_persistent_errors();
@ -482,10 +447,12 @@ async fn main() -> anyhow::Result<()> {
let shared_state = shared_state.clone();
async move {
loop {
interval.tick().await;
min_delay.tick().await;
if !shared_state.read().unwrap().one_snapshot_done {
continue;
}
interval.tick().await;
let token_indexes = token_swap_info_updater
.mango_client()
.context
@ -499,8 +466,7 @@ async fn main() -> anyhow::Result<()> {
Ok(()) => {}
Err(err) => {
warn!(
"failed to update token swap info for token {token_index}: {:?}",
err
"failed to update token swap info for token {token_index}: {err:?}",
);
}
}
@ -540,12 +506,6 @@ struct SharedState {
/// Is the first snapshot done? Only start checking account health when it is.
one_snapshot_done: bool,
/// Accounts whose health might have changed
health_check_accounts: Vec<Pubkey>,
/// Check all accounts?
health_check_all: bool,
}
#[derive(Clone)]

View File

@ -12,14 +12,16 @@ use {anyhow::Context, fixed::types::I80F48, solana_sdk::pubkey::Pubkey};
use crate::{token_swap_info, util};
// The liqee health ratio to aim for when executing tcs orders that are bigger
// than the liqee can support.
//
// The background here is that the program considers bringing the liqee health ratio
// below 1% as "the tcs was completely fulfilled" and then closes the tcs.
// Choosing a value too close to 0 is problematic, since then small oracle fluctuations
// could bring the final health below 0 and make the triggering invalid!
const TARGET_HEALTH_RATIO: f64 = 0.5;
/// When computing the max possible swap for a liqee, assume the price is this fraction worse for them.
///
/// That way when executing the swap, the prices may move this much against the liqee without
/// 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
pub struct Config {
pub min_health_ratio: f64,
@ -39,11 +41,7 @@ fn tcs_is_in_price_range(
let buy_token_price = account_fetcher.fetch_bank_price(&buy_bank)?;
let sell_token_price = account_fetcher.fetch_bank_price(&sell_bank)?;
let base_price = (buy_token_price / sell_token_price).to_num();
if !tcs.price_in_range(base_price) {
return Ok(false);
}
return Ok(true);
Ok(tcs.price_in_range(base_price))
}
fn tcs_has_plausible_premium(
@ -154,27 +152,21 @@ async fn execute_token_conditional_swap(
let base_price = buy_token_price / sell_token_price;
let premium_price = tcs.premium_price(base_price.to_num());
let maker_price = I80F48::from_num(tcs.maker_price(premium_price));
let taker_price = I80F48::from_num(tcs.taker_price(premium_price));
let max_take_quote = I80F48::from(config.max_trigger_quote_amount);
let liqee_target_health_ratio = I80F48::from_num(TARGET_HEALTH_RATIO);
let max_sell_token_to_liqor = util::max_swap_source(
mango_client,
account_fetcher,
&liqee,
tcs.sell_token_index,
tcs.buy_token_index,
I80F48::ONE / maker_price,
liqee_target_health_ratio,
)?
.min(max_take_quote / sell_token_price)
.floor()
.to_num::<u64>()
.min(tcs.remaining_sell());
let (liqee_max_buy, liqee_max_sell) =
match tcs_max_liqee_execution(liqee, mango_client, account_fetcher, tcs)? {
Some(v) => v,
None => return Ok(false),
};
let max_sell_token_to_liqor = liqee_max_sell;
// In addition to the liqee's requirements, the liqor also has requirements:
// - only swap while the health ratio stays high enough
// - possible net borrow limit restrictions from the liqor borrowing the buy token
// - liqor has a max_take_quote
let max_buy_token_to_liqee = util::max_swap_source(
mango_client,
account_fetcher,
@ -187,7 +179,7 @@ async fn execute_token_conditional_swap(
.min(max_take_quote / buy_token_price)
.floor()
.to_num::<u64>()
.min(tcs.remaining_buy());
.min(liqee_max_buy);
if max_sell_token_to_liqor == 0 || max_buy_token_to_liqee == 0 {
return Ok(false);
@ -332,8 +324,46 @@ fn tcs_max_volume(
mango_client: &MangoClient,
account_fetcher: &chain_data::AccountFetcher,
tcs: &TokenConditionalSwap,
) -> anyhow::Result<u64> {
// Compute the max viable swap (for liqor and liqee) and min it
) -> anyhow::Result<Option<u64>> {
let buy_bank_pk = mango_client
.context
.mint_info(tcs.buy_token_index)
.first_bank();
let sell_bank_pk = mango_client
.context
.mint_info(tcs.sell_token_index)
.first_bank();
let buy_token_price = account_fetcher.fetch_bank_price(&buy_bank_pk)?;
let sell_token_price = account_fetcher.fetch_bank_price(&sell_bank_pk)?;
let (max_buy, max_sell) =
match tcs_max_liqee_execution(account, mango_client, account_fetcher, tcs)? {
Some(v) => v,
None => return Ok(None),
};
let max_quote =
(I80F48::from(max_buy) * buy_token_price).min(I80F48::from(max_sell) * sell_token_price);
Ok(Some(max_quote.floor().clamp_to_u64()))
}
/// Compute the max viable swap for liqee
/// 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
///
/// 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...)
fn tcs_max_liqee_execution(
account: &MangoAccountValue,
mango_client: &MangoClient,
account_fetcher: &chain_data::AccountFetcher,
tcs: &TokenConditionalSwap,
) -> anyhow::Result<Option<(u64, u64)>> {
let buy_bank_pk = mango_client
.context
.mint_info(tcs.buy_token_index)
@ -347,6 +377,10 @@ fn tcs_max_volume(
let buy_token_price = account_fetcher.fetch_bank_price(&buy_bank_pk)?;
let sell_token_price = account_fetcher.fetch_bank_price(&sell_bank_pk)?;
let base_price = buy_token_price / sell_token_price;
let premium_price = tcs.premium_price(base_price.to_num());
let maker_price = tcs.maker_price(premium_price);
let buy_position = account
.token_position(tcs.buy_token_index)
.map(|p| p.native(&buy_bank))
@ -356,31 +390,69 @@ fn tcs_max_volume(
.map(|p| p.native(&sell_bank))
.unwrap_or(I80F48::ZERO);
let base_price = buy_token_price / sell_token_price;
let premium_price = tcs.premium_price(base_price.to_num());
let maker_price = tcs.maker_price(premium_price);
let liqee_target_health_ratio = I80F48::from_num(TARGET_HEALTH_RATIO);
let max_sell = util::max_swap_source(
// 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(
mango_client,
account_fetcher,
&account,
tcs.sell_token_index,
tcs.buy_token_index,
I80F48::from_num(1.0 / maker_price),
liqee_target_health_ratio,
swap_price,
I80F48::ZERO,
)?
.floor()
.to_num::<u64>()
.min(tcs.max_sell_for_position(sell_position, &sell_bank));
let max_buy = tcs.max_buy_for_position(buy_position, &buy_bank);
let max_buy_ignoring_net_borrows = tcs.max_buy_for_position(buy_position, &buy_bank);
let max_quote =
(I80F48::from(max_buy) * buy_token_price).min(I80F48::from(max_sell) * sell_token_price);
// What follows is a complex manual handling of net borrow limits, for the following reason:
// Usually, we _do_ 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, we do _not_ want to close the tcs order
// even though no further execution is possible at that time. Furthermore, we don't even
// want to send a too-tiny tcs execution transaction, 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.
Ok(max_quote.floor().clamp_to_u64())
fn available_borrows(bank: &Bank, price: I80F48) -> u64 {
if bank.net_borrow_limit_per_window_quote < 0 {
u64::MAX
} else {
let limit = (I80F48::from(bank.net_borrow_limit_per_window_quote) / price)
.floor()
.clamp_to_i64();
(limit - bank.net_borrows_in_window).max(0) as u64
}
}
let available_buy_borrows = available_borrows(&buy_bank, buy_token_price);
let available_sell_borrows = available_borrows(&sell_bank, sell_token_price);
// This technically depends on the liqor's buy token position, but we
// just assume it'll be fully margined here
let max_buy = max_buy_ignoring_net_borrows.min(available_buy_borrows);
let sell_borrows = (I80F48::from(max_sell_ignoring_net_borrows)
- sell_position.max(I80F48::ZERO))
.clamp_to_u64();
let max_sell =
max_sell_ignoring_net_borrows - sell_borrows + sell_borrows.min(available_sell_borrows);
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
};
if tiny_due_to_net_borrows {
return Ok(None);
}
Ok(Some((max_buy, max_sell)))
}
pub fn find_interesting_tcs_for_account(
@ -401,8 +473,12 @@ pub fn find_interesting_tcs_for_account(
now_ts,
) {
Ok(true) => {
let volume_result = tcs_max_volume(&liqee, mango_client, account_fetcher, tcs);
Some(volume_result.map(|v| (*pubkey, tcs.id, v)))
// Filter out Ok(None) resuts of tcs that shouldn't be executed right now
match tcs_max_volume(&liqee, mango_client, account_fetcher, tcs) {
Ok(Some(v)) => Some(Ok((*pubkey, tcs.id, v))),
Ok(None) => None,
Err(e) => Some(Err(e)),
}
}
Ok(false) => None,
Err(e) => Some(Err(e)),

View File

@ -144,3 +144,46 @@ pub fn max_swap_source(
.context("getting max_swap_source")?;
Ok(amount)
}
/// Convenience wrapper for getting max swap amounts for a token pair
pub fn max_swap_source_ignore_net_borrows(
client: &MangoClient,
account_fetcher: &chain_data::AccountFetcher,
account: &MangoAccountValue,
source: TokenIndex,
target: TokenIndex,
price: I80F48,
min_health_ratio: I80F48,
) -> anyhow::Result<I80F48> {
let mut account = account.clone();
// Ensure the tokens are activated, so they appear in the health cache and
// max_swap_source() will work.
account.ensure_token_position(source)?;
account.ensure_token_position(target)?;
let health_cache =
mango_v4_client::health_cache::new_sync(&client.context, account_fetcher, &account)
.expect("always ok");
let mut 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 =
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(
&account,
&source_bank,
source_price,
&target_bank,
price,
min_health_ratio,
)
.context("getting max_swap_source")?;
Ok(amount)
}

View File

@ -12,6 +12,12 @@ use crate::logs::{
};
use crate::state::*;
/// If init health is reduced below this number, the tcs is considered done.
///
/// This avoids a situation where the same tcs can be triggered again and again
/// for small amounts every time the init health increases by small amounts.
const TCS_TRIGGER_INIT_HEALTH_THRESHOLD: u64 = 1_000_000;
#[allow(clippy::too_many_arguments)]
pub fn token_conditional_swap_trigger(
ctx: Context<TokenConditionalSwapTrigger>,
@ -76,8 +82,6 @@ pub fn token_conditional_swap_trigger(
return Ok(());
}
let liqee_pre_init_health = liqee.check_health_pre(&liqee_health_cache)?;
let (liqee_buy_change, liqee_sell_change) = action(
&mut liqor.borrow_mut(),
liqor_key,
@ -94,8 +98,7 @@ pub fn token_conditional_swap_trigger(
now_ts,
)?;
// Check liqee and liqor health
liqee.check_health_post(&liqee_health_cache, liqee_pre_init_health)?;
// Check liqor health, liqee health is checked inside (has to be, since tcs closure depends on it)
let liqor_health = compute_health(&liqor.borrow(), HealthType::Init, &account_retriever)
.context("compute liqor health")?;
require!(liqor_health >= 0, MangoError::HealthMustBePositive);
@ -187,6 +190,8 @@ fn action(
max_sell_token_to_liqor: u64,
now_ts: u64,
) -> Result<(I80F48, I80F48)> {
let liqee_pre_init_health = liqee.check_health_pre(&liqee_health_cache)?;
let tcs = liqee
.token_conditional_swap_by_index(token_conditional_swap_index)?
.clone();
@ -275,10 +280,14 @@ fn action(
sell_bank.collected_fees_native += I80F48::from(maker_fee + taker_fee);
// No need to check net borrow limits on buy_bank or sell_bank, because this mostly transfers
// tokens between two accounts. For the sell token, the withdraw is higher than the deposit
// due to fees, so net borrows can technically increase a bit: but the difference gets "deposited"
// into collected_fees_native.
// 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);
@ -368,6 +377,9 @@ fn action(
liqee_health_cache.adjust_token_balance(&buy_bank, liqee_buy_change)?;
liqee_health_cache.adjust_token_balance(&sell_bank, liqee_sell_change)?;
let liqee_post_init_health =
liqee.check_health_post(&liqee_health_cache, liqee_pre_init_health)?;
// update tcs information on the account
let closed = {
// record amount
@ -399,14 +411,9 @@ fn action(
sell_bank,
);
// The health check depends on the account's health _ratio_ because it needs to work
// with liquidators trying to trigger tcs maximally: they can't bring the health exactly
// to 0 or even very close to it, because oracles will change before the transaction
// is executed. So instead, they will target a certain health ratio.
// This says, that as long as they bring the account's health ratio below 1%, we will
// consider the tcs as fully executed.
let liqee_health_is_low =
liqee_health_cache.health_ratio(HealthType::Init) < I80F48::from(1);
// If the health is low enough, close the trigger. Otherwise it'd trigger repeatedly
// as oracle prices fluctuate.
let liqee_health_is_low = liqee_post_init_health < TCS_TRIGGER_INIT_HEALTH_THRESHOLD;
if future_buy == 0 || future_sell == 0 || liqee_health_is_low {
*tcs = TokenConditionalSwap::default();
@ -758,12 +765,14 @@ mod tests {
fn test_token_conditional_swap_trigger() {
let mut setup = TestSetup::new();
let asset_pos = 100_000_000;
setup
.asset_bank
.data()
.deposit(
&mut setup.liqee.token_position_mut(0).unwrap().0,
I80F48::from(1000),
I80F48::from(asset_pos),
0,
)
.unwrap();
@ -801,7 +810,7 @@ mod tests {
assert_eq!(tcs.sold, 88);
assert_eq!(setup.liqee_liab_pos().round(), 40);
assert_eq!(setup.liqee_asset_pos().round(), 1000 - 88);
assert_eq!(setup.liqee_asset_pos().round(), asset_pos - 88);
assert_eq!(setup.liqor_liab_pos().round(), -40);
assert_eq!(setup.liqor_asset_pos().round(), 88);
@ -812,7 +821,7 @@ mod tests {
assert_eq!(setup.liqee.active_token_conditional_swaps().count(), 0);
assert_eq!(setup.liqee_liab_pos().round(), 45);
assert_eq!(setup.liqee_asset_pos().round(), 1000 - 99);
assert_eq!(setup.liqee_asset_pos().round(), asset_pos - 99);
assert_eq!(setup.liqor_liab_pos().round(), -45);
assert_eq!(setup.liqor_asset_pos().round(), 99);
}
@ -826,14 +835,14 @@ mod tests {
.data()
.deposit(
&mut setup.liqee.token_position_mut(0).unwrap().0,
I80F48::from(100),
I80F48::from(100_000_000),
0,
)
.unwrap();
let tcs = TokenConditionalSwap {
max_buy: 10000,
max_sell: 10000,
max_buy: 10_000_000_000,
max_sell: 10_000_000_000,
price_lower_limit: 1.0,
price_upper_limit: 3.0,
price_premium_rate: 0.0,
@ -845,21 +854,40 @@ mod tests {
..Default::default()
};
*setup.liqee.free_token_conditional_swap_mut().unwrap() = tcs.clone();
let (buy_change, sell_change) = setup.trigger(2.0, 1000, 1.0, 1000).unwrap();
assert_eq!(buy_change.round(), 500);
assert_eq!(sell_change.round(), -1000);
// Overall health went negative, causing the tcs to close (even though max_buy/max_sell aren't reached)
let (buy_change, sell_change) = setup.trigger(2.0, 50_000_000, 1.0, 50_000_000).unwrap();
assert_eq!(buy_change.round(), 25_000_000);
assert_eq!(sell_change.round(), -50_000_000);
// Not closed yet, health still good
assert_eq!(setup.liqee.active_token_conditional_swaps().count(), 1);
let (buy_change, sell_change) = setup.trigger(2.0, 150_000_000, 1.0, 150_000_000).unwrap();
assert_eq!(buy_change.round(), 75_000_000);
assert_eq!(sell_change.round(), -150_000_000);
// Health is 0
assert_eq!(setup.liqee.active_token_conditional_swaps().count(), 0);
assert_eq!(setup.liqee_liab_pos().round(), 500);
assert_eq!(setup.liqee_asset_pos().round(), -900);
assert_eq!(setup.liqee_liab_pos().round(), 100_000_000);
assert_eq!(setup.liqee_asset_pos().round(), -100_000_000);
}
#[test]
fn test_token_conditional_swap_trigger_fees() {
let mut setup = TestSetup::new();
let asset_pos = 100_000_000;
setup
.asset_bank
.data()
.deposit(
&mut setup.liqee.token_position_mut(0).unwrap().0,
I80F48::from(asset_pos),
0,
)
.unwrap();
let tcs = TokenConditionalSwap {
max_buy: 1000,
max_sell: 1000,
@ -883,7 +911,7 @@ mod tests {
assert_eq!(sell_change.round(), -1000);
assert_eq!(setup.liqee_liab_pos().round(), 952);
assert_eq!(setup.liqee_asset_pos().round(), -1000);
assert_eq!(setup.liqee_asset_pos().round(), asset_pos - 1000);
assert_eq!(setup.liqor_liab_pos().round(), -952);
assert_eq!(setup.liqor_asset_pos().round(), 923); // floor(952*1.02*0.95)

View File

@ -27,7 +27,7 @@ async fn test_token_conditional_swap() -> Result<(), TransportError> {
let quote_token = &tokens[0];
let base_token = &tokens[1];
let deposit_amount = 1000;
let deposit_amount = 1_000_000_000f64;
let account = create_funded_account(
&solana,
group,
@ -35,7 +35,7 @@ async fn test_token_conditional_swap() -> Result<(), TransportError> {
0,
&context.users[1],
mints,
deposit_amount,
deposit_amount as u64,
0,
)
.await;
@ -46,7 +46,7 @@ async fn test_token_conditional_swap() -> Result<(), TransportError> {
1,
&context.users[1],
mints,
deposit_amount,
deposit_amount as u64,
0,
)
.await;
@ -113,7 +113,7 @@ async fn test_token_conditional_swap() -> Result<(), TransportError> {
assert_eq!(account_data.header.token_conditional_swap_count, 2);
//
// TEST: Can create tsls until all slots are filled
// TEST: Can create tcs until all slots are filled
//
let tcs_ix = TokenConditionalSwapCreateInstruction {
account,
@ -246,15 +246,15 @@ async fn test_token_conditional_swap() -> Result<(), TransportError> {
let liqee_base = account_position_f64(solana, account, base_token.bank).await;
assert!(assert_equal_f_f(
liqee_quote,
1000.0 + 42.0, // roughly 50 / (1.1 * 1.1)
deposit_amount + 42.0, // roughly 50 / (1.1 * 1.1)
0.01
));
assert!(assert_equal_f_f(liqee_base, 1000.0 - 50.0, 0.01));
assert!(assert_equal_f_f(liqee_base, deposit_amount - 50.0, 0.01));
let liqor_quote = account_position_f64(solana, liqor, quote_token.bank).await;
let liqor_base = account_position_f64(solana, liqor, base_token.bank).await;
assert!(assert_equal_f_f(liqor_quote, 1000.0 - 42.0, 0.01));
assert!(assert_equal_f_f(liqor_base, 1000.0 + 44.0, 0.01)); // roughly 42*1.1*0.95
assert!(assert_equal_f_f(liqor_quote, deposit_amount - 42.0, 0.01));
assert!(assert_equal_f_f(liqor_base, deposit_amount + 44.0, 0.01)); // roughly 42*1.1*0.95
//
// TEST: trigger fully
@ -275,13 +275,13 @@ async fn test_token_conditional_swap() -> Result<(), TransportError> {
let liqee_quote = account_position_f64(solana, account, quote_token.bank).await;
let liqee_base = account_position_f64(solana, account, base_token.bank).await;
assert!(assert_equal_f_f(liqee_quote, 1000.0 + 84.0, 0.01));
assert!(assert_equal_f_f(liqee_base, 1000.0 - 100.0, 0.01));
assert!(assert_equal_f_f(liqee_quote, deposit_amount + 84.0, 0.01));
assert!(assert_equal_f_f(liqee_base, deposit_amount - 100.0, 0.01));
let liqor_quote = account_position_f64(solana, liqor, quote_token.bank).await;
let liqor_base = account_position_f64(solana, liqor, base_token.bank).await;
assert!(assert_equal_f_f(liqor_quote, 1000.0 - 84.0, 0.01));
assert!(assert_equal_f_f(liqor_base, 1000.0 + 88.0, 0.01));
assert!(assert_equal_f_f(liqor_quote, deposit_amount - 84.0, 0.01));
assert!(assert_equal_f_f(liqor_base, deposit_amount + 88.0, 0.01));
let account_data = get_mango_account(solana, account).await;
assert!(!account_data

View File

@ -1,5 +1,6 @@
import { AnchorProvider, BN, Wallet } from '@coral-xyz/anchor';
import { Cluster, Connection, Keypair, PublicKey } from '@solana/web3.js';
import * as splToken from '@solana/spl-token';
import fs from 'fs';
import { Bank } from '../../src/accounts/bank';
import {
@ -11,6 +12,7 @@ import { PerpMarket } from '../../src/accounts/perp';
import { Builder } from '../../src/builder';
import { MangoClient } from '../../src/client';
import {
DefaultTokenRegisterParams,
NullPerpEditParams,
NullTokenEditParams,
} from '../../src/clientIxParamBuilder';
@ -33,7 +35,7 @@ const PRICES = {
const TOKEN_SCENARIOS: [string, [string, number][], [string, number][]][] = [
[
'LIQTEST, FUNDING',
'TCS, FUNDING',
[
['USDC', 5000000],
['ETH', 100000],
@ -41,10 +43,11 @@ const TOKEN_SCENARIOS: [string, [string, number][], [string, number][]][] = [
],
[],
],
['LIQTEST, LIQOR', [['USDC', 1000000]], []],
['LIQTEST, LIQEE1', [['USDC', 1000]], []],
['LIQTEST, LIQEE2', [['USDC', 1000000]], []],
['LIQTEST, LIQEE3', [['USDC', 1000000]], []],
['TCS, LIQOR', [['USDC', 1000000]], []],
['TCS, HEALTH', [['USDC', 1000]], []],
['TCS, FULL+CLOSE', [['USDC', 1000000]], []],
['TCS, EXPIRE', [['USDC', 1000000]], []],
['TCS, NET-BORROW', [['USDC', 10000000]], []],
];
async function main() {
@ -172,9 +175,8 @@ async function main() {
// Case LIQEE1: The liqee does not have enough health for the tcs
{
const account = ensure(
accounts2.find((account) => account.name == 'LIQTEST, LIQEE1'),
accounts2.find((account) => account.name == 'TCS, HEALTH'),
);
await client.accountExpandV2(group, account, 4, 4, 4, 4, 4);
await client.tokenConditionalSwapCreateRaw(
group,
account,
@ -196,9 +198,8 @@ async function main() {
// Case LIQEE2: Full execution - tcs closes afterward
{
const account = ensure(
accounts2.find((account) => account.name == 'LIQTEST, LIQEE2'),
accounts2.find((account) => account.name == 'TCS, FULL+CLOSE'),
);
await client.accountExpandV2(group, account, 4, 4, 4, 4, 4);
await client.tokenConditionalSwapCreateRaw(
group,
account,
@ -220,9 +221,8 @@ async function main() {
// Case LIQEE3: Create a tcs that will expire very soon
{
const account = ensure(
accounts2.find((account) => account.name == 'LIQTEST, LIQEE3'),
accounts2.find((account) => account.name == 'TCS, EXPIRE'),
);
await client.accountExpandV2(group, account, 4, 4, 4, 4, 4);
await client.tokenConditionalSwapCreateRaw(
group,
account,
@ -241,6 +241,76 @@ async function main() {
);
}
// Case LIQEE4: Create a tcs that hits net borrow limits
{
const account = ensure(
accounts2.find((account) => account.name == 'TCS, NET-BORROW'),
);
// To do this, first make a new mint and register it as a new token with tight net borrow limits
const newMint = await splToken.createMint(
connection,
admin,
admin.publicKey,
null,
6,
);
const tokenAccount = await splToken.createAssociatedTokenAccountIdempotent(
connection,
admin,
newMint,
admin.publicKey,
);
await splToken.mintTo(
connection,
admin,
newMint,
tokenAccount,
admin,
1e15,
);
await client.stubOracleCreate(group, newMint, 1.0);
const newOracle = (await client.getStubOracle(group, newMint))[0];
const newTokenIndex = Math.max(...group.banksMapByTokenIndex.keys()) + 1;
await client.tokenRegister(
group,
newMint,
newOracle.publicKey,
newTokenIndex,
'TMP',
{
...DefaultTokenRegisterParams,
loanOriginationFeeRate: 0,
loanFeeRate: 0,
initAssetWeight: 1,
maintAssetWeight: 1,
initLiabWeight: 1,
maintLiabWeight: 1,
liquidationFee: 0,
netBorrowLimitPerWindowQuote: 1500000, // less than the $2 of the tcs
},
);
await group.reloadAll(client);
await client.tokenConditionalSwapCreateRaw(
group,
account,
newMint,
MINTS.get('USDC')!,
new BN(2000000), // $2
new BN(2000000),
null,
0.0,
1000000.0,
0.01,
true,
true,
TokenConditionalSwapDisplayPriceStyle.buyTokenPerSellToken,
TokenConditionalSwapIntention.unknown,
);
}
process.exit();
}