diff --git a/.github/workflows/ci-docker-heroku-deploy.yml b/.github/workflows/ci-docker-heroku-deploy.yml index 911f669b6..65673fd27 100644 --- a/.github/workflows/ci-docker-heroku-deploy.yml +++ b/.github/workflows/ci-docker-heroku-deploy.yml @@ -11,6 +11,11 @@ on: description: 'Docker Image Name' required: true type: string + imageTag: + description: 'Docker Image Tag' + required: true + type: string + default: 'latest' jobs: deploy: @@ -27,7 +32,7 @@ jobs: - name: Push env: HEROKU_API_KEY: ${{ secrets.HEROKU_API_KEY }} - run: heroku container:push ${{ inputs.imageName }} -a ${{ inputs.appName }} --recursive + run: heroku container:push ${{ inputs.imageName }} -a ${{ inputs.appName }} --recursive --arg BASE_TAG=${{ inputs.imageTag }} - name: Release env: diff --git a/.github/workflows/ci-docker-publish.yml b/.github/workflows/ci-docker-publish.yml index ddbf396dd..8a606530c 100644 --- a/.github/workflows/ci-docker-publish.yml +++ b/.github/workflows/ci-docker-publish.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v2 with: submodules: recursive - + # Use docker buildx - name: Use docker buildx uses: docker/setup-buildx-action@v2 @@ -47,8 +47,8 @@ jobs: username: oauth2accesstoken password: ${{ steps.auth.outputs.access_token }} - # Build and push the image, leveraging layer caching - - name: Build and Push + # Build and push the base image, leveraging layer caching + - name: Build and Push Base Image uses: docker/build-push-action@v2 with: context: . @@ -58,3 +58,23 @@ jobs: us-docker.pkg.dev/${{ env.PROJECT_ID }}/gcr.io/${{ env.IMAGE }}:latest cache-from: type=registry,ref=us-docker.pkg.dev/${{ env.PROJECT_ID }}/gcr.io/${{ env.IMAGE }}:buildcache cache-to: type=registry,ref=us-docker.pkg.dev/${{ env.PROJECT_ID }}/gcr.io/${{ env.IMAGE }}:buildcache,mode=max + # Build and push the liquidator runtime image + - name: Build and Push Liquidator + uses: docker/build-push-action@v2 + with: + file: liquidator/Dockerfile.liquidator + context: . + push: true + tags: | + us-docker.pkg.dev/${{ env.PROJECT_ID }}/gcr.io/${{ env.IMAGE }}-liquidator:${{ github.sha }} + us-docker.pkg.dev/${{ env.PROJECT_ID }}/gcr.io/${{ env.IMAGE }}-liquidator:latest + # Build and push the keeper runtime image + - name: Build and Push Keeper + uses: docker/build-push-action@v2 + with: + file: keeper/Dockerfile.keeper + context: . + push: true + tags: | + us-docker.pkg.dev/${{ env.PROJECT_ID }}/gcr.io/${{ env.IMAGE }}-keeper:${{ github.sha }} + us-docker.pkg.dev/${{ env.PROJECT_ID }}/gcr.io/${{ env.IMAGE }}-keeper:latest diff --git a/.gitignore b/.gitignore index dd40eca29..b3e84ddc6 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ ts/client/**/*.js ts/client/**/*.js.map migrations/*.js migrations/*.js.map + +ts/client/src/scripts/archive/ts.ts \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 69e58551f..a42f430da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ Update this for each mainnet deployment. ## not on mainnet +- Add HealthRegionBegin, -End instructions +- Add explicit "oracle" account argument for TokenDeposit and TokenWithdraw instructions + ## mainnet Aug 20, 2022 at 19:58:29 Central European Summer Time diff --git a/Cargo.lock b/Cargo.lock index 2a5d75251..e29b18d00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3208,6 +3208,7 @@ dependencies = [ "fixed", "fixed-macro", "itertools 0.10.3", + "lazy_static", "log 0.4.17", "mango-macro", "margin-trade", diff --git a/Dockerfile b/Dockerfile index 27e720e1c..70d397b39 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,12 +9,12 @@ COPY ./ . RUN sed -i 's|lib/\*|lib/checked_math|' Cargo.toml # Mount cache for downloaded and compiled dependencies -RUN --mount=type=cache,target=/usr/local/cargo,from=rust,source=/usr/local/cargo \ - --mount=type=cache,target=target \ +RUN --mount=type=cache,mode=0777,target=/usr/local/cargo,from=rust,source=/usr/local/cargo \ + --mount=type=cache,mode=0777,target=target \ cargo build --release --bins # Copy bins out of cache -RUN --mount=type=cache,target=target mkdir .bin && cp target/release/keeper target/release/liquidator .bin/ +RUN --mount=type=cache,mode=0777,target=target mkdir .bin && cp target/release/keeper target/release/liquidator .bin/ FROM debian:bullseye-slim as run RUN apt-get update && apt-get -y install ca-certificates libc6 diff --git a/Program b/Program new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/chain_data.rs b/client/src/chain_data.rs index b2580c7e4..1241bd274 100644 --- a/client/src/chain_data.rs +++ b/client/src/chain_data.rs @@ -203,7 +203,7 @@ impl ChainData { .iter() .rev() .find(|w| self.is_account_write_live(w))?; - Some((pubkey.clone(), latest_good_write.clone())) + Some((*pubkey, latest_good_write.clone())) }) .collect() } diff --git a/client/src/chain_data_fetcher.rs b/client/src/chain_data_fetcher.rs index 1def80c58..0749d4837 100644 --- a/client/src/chain_data_fetcher.rs +++ b/client/src/chain_data_fetcher.rs @@ -28,11 +28,10 @@ impl AccountFetcher { &self, address: &Pubkey, ) -> anyhow::Result { - Ok(self + Ok(*self .fetch_raw(address)? .load::() - .with_context(|| format!("loading account {}", address))? - .clone()) + .with_context(|| format!("loading account {}", address))?) } pub fn fetch_mango_account(&self, address: &Pubkey) -> anyhow::Result { @@ -40,12 +39,12 @@ impl AccountFetcher { let data = acc.data(); let disc_bytes = &data[0..8]; - if disc_bytes != &MangoAccount::discriminator() { + if disc_bytes != MangoAccount::discriminator() { anyhow::bail!("not a mango account at {}", address); } - Ok(MangoAccountValue::from_bytes(&data[8..]) - .with_context(|| format!("loading mango account {}", address))?) + MangoAccountValue::from_bytes(&data[8..]) + .with_context(|| format!("loading mango account {}", address)) } // fetches via RPC, stores in ChainData, returns new version @@ -73,7 +72,7 @@ impl AccountFetcher { pub fn refresh_account_via_rpc(&self, address: &Pubkey) -> anyhow::Result { let response = self .rpc - .get_account_with_commitment(&address, self.rpc.commitment()) + .get_account_with_commitment(address, self.rpc.commitment()) .with_context(|| format!("refresh account {} via rpc", address))?; let slot = response.context.slot; let account = response diff --git a/client/src/client.rs b/client/src/client.rs index c7815874a..b79965317 100644 --- a/client/src/client.rs +++ b/client/src/client.rs @@ -374,6 +374,7 @@ impl MangoClient { account: self.mango_account_address, bank: mint_info.first_bank(), vault: mint_info.first_vault(), + oracle: mint_info.oracle, token_account: get_associated_token_address( &self.owner(), &mint_info.mint, @@ -425,6 +426,7 @@ impl MangoClient { owner: self.owner(), bank: mint_info.first_bank(), vault: mint_info.first_vault(), + oracle: mint_info.oracle, token_account: get_associated_token_address( &self.owner(), &mint_info.mint, @@ -603,6 +605,10 @@ impl MangoClient { let rates = get_fee_rates(fee_tier); (s3.market.pc_lot_size as f64 * (1f64 + rates.0)) as u64 * (limit_price * max_base_qty) }; + let payer_mint_info = match side { + Serum3Side::Bid => s3.quote.mint_info, + Serum3Side::Ask => s3.base.mint_info, + }; self.program() .request() @@ -614,10 +620,8 @@ impl MangoClient { group: self.group(), account: self.mango_account_address, open_orders, - quote_bank: s3.quote.mint_info.first_bank(), - quote_vault: s3.quote.mint_info.first_vault(), - base_bank: s3.base.mint_info.first_bank(), - base_vault: s3.base.mint_info.first_vault(), + payer_bank: payer_mint_info.first_bank(), + payer_vault: payer_mint_info.first_vault(), serum_market: s3.market.address, serum_program: s3.market.market.serum_program, serum_market_external: s3.market.market.serum_market_external, diff --git a/client/src/context.rs b/client/src/context.rs index 73e35ec13..6aa12fbf4 100644 --- a/client/src/context.rs +++ b/client/src/context.rs @@ -217,10 +217,9 @@ impl MangoGroupContext { oracles.push(mint_info.oracle); } for affected_token_index in affected_tokens { - if account + if !account .active_token_positions() - .find(|p| p.token_index == affected_token_index) - .is_none() + .any(|p| p.token_index == affected_token_index) { // If there is not yet an active position for the token, we need to pass // the bank/oracle for health check anyway. diff --git a/keeper/Dockerfile.keeper b/keeper/Dockerfile.keeper index 5770c5476..5aee43836 100644 --- a/keeper/Dockerfile.keeper +++ b/keeper/Dockerfile.keeper @@ -1,6 +1,7 @@ # Dockerfile for keeper service in Heroku # heroku container:push keeper -R -a HEROKU_APP_NAME # heroku container:release -a HEROKU_APP_NAME -FROM us-docker.pkg.dev/mango-markets/gcr.io/mango-v4:latest +ARG BASE_TAG=latest +FROM us-docker.pkg.dev/mango-markets/gcr.io/mango-v4:$BASE_TAG ENTRYPOINT ["keeper"] CMD ["crank"] diff --git a/keeper/src/crank.rs b/keeper/src/crank.rs index 430fc6ecb..0fdf8c56a 100644 --- a/keeper/src/crank.rs +++ b/keeper/src/crank.rs @@ -1,4 +1,4 @@ -use std::{sync::Arc, time::Duration}; +use std::{sync::Arc, time::Duration, time::Instant}; use crate::MangoClient; use itertools::Itertools; @@ -76,7 +76,7 @@ pub async fn loop_update_index_and_rate( let token_names = token_indices_clone .iter() .map(|token_index| client.context.token(*token_index).name.to_owned()) - .join(", "); + .join(","); let program = client.program(); let mut req = program.request(); @@ -112,16 +112,24 @@ pub async fn loop_update_index_and_rate( ix.accounts.append(&mut banks); req = req.instruction(ix); } + let pre = Instant::now(); let sig_result = req.send().map_err(prettify_client_error); if let Err(e) = sig_result { + log::info!( + "metricName=UpdateTokensV4Failure tokens={} durationMs={} error={}", + token_names, + pre.elapsed().as_millis(), + e + ); log::error!("{:?}", e) } else { log::info!( - "update_index_and_rate {} {:?}", + "metricName=UpdateTokensV4Success tokens={} durationMs={}", token_names, - sig_result.unwrap() - ) + pre.elapsed().as_millis(), + ); + log::info!("{:?}", sig_result); } Ok(()) @@ -191,6 +199,7 @@ pub async fn loop_consume_events( event_queue.pop_front()?; } + let pre = Instant::now(); let sig_result = client .program() .request() @@ -216,13 +225,22 @@ pub async fn loop_consume_events( .map_err(prettify_client_error); if let Err(e) = sig_result { + log::info!( + "metricName=ConsumeEventsV4Failure market={} durationMs={} consumed={} error={}", + perp_market.name(), + pre.elapsed().as_millis(), + ams_.len(), + e.to_string() + ); log::error!("{:?}", e) } else { log::info!( - "consume_event {} {:?}", + "metricName=ConsumeEventsV4Success market={} durationMs={} consumed={}", perp_market.name(), - sig_result.unwrap() - ) + pre.elapsed().as_millis(), + ams_.len(), + ); + log::info!("{:?}", sig_result); } Ok(()) @@ -253,6 +271,7 @@ pub async fn loop_update_funding( let client = mango_client.clone(); let res = tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + let pre = Instant::now(); let sig_result = client .program() .request() @@ -275,13 +294,20 @@ pub async fn loop_update_funding( .send() .map_err(prettify_client_error); if let Err(e) = sig_result { + log::error!( + "metricName=UpdateFundingV4Error market={} durationMs={} error={}", + perp_market.name(), + pre.elapsed().as_millis(), + e.to_string() + ); log::error!("{:?}", e) } else { log::info!( - "update_funding {} {:?}", + "metricName=UpdateFundingV4Success market={} durationMs={}", perp_market.name(), - sig_result.unwrap() - ) + pre.elapsed().as_millis(), + ); + log::info!("{:?}", sig_result); } Ok(()) diff --git a/keeper/src/taker.rs b/keeper/src/taker.rs index 8feaedf58..d6919e8a5 100644 --- a/keeper/src/taker.rs +++ b/keeper/src/taker.rs @@ -27,7 +27,7 @@ pub async fn runner( market_name .split('/') .collect::>() - .get(0) + .first() .unwrap(), ) .unwrap(); @@ -77,7 +77,7 @@ fn ensure_oo(mango_client: &Arc) -> Result<(), anyhow::Error> { let account = mango_client.mango_account()?; for (market_index, serum3_market) in mango_client.context.serum3_markets.iter() { - if account.serum3_orders(*market_index).is_none() { + if account.serum3_orders(*market_index).is_err() { mango_client.serum3_create_open_orders(serum3_market.market.name())?; } } diff --git a/liquidator/Dockerfile.liquidator b/liquidator/Dockerfile.liquidator index 9935d6b37..c4e15b6b2 100644 --- a/liquidator/Dockerfile.liquidator +++ b/liquidator/Dockerfile.liquidator @@ -1,5 +1,6 @@ # Dockerfile for keeper service in Heroku # heroku container:push keeper -R -a HEROKU_APP_NAME # heroku container:release -a HEROKU_APP_NAME -FROM us-docker.pkg.dev/mango-markets/gcr.io/mango-v4:latest +ARG BASE_TAG=latest +FROM us-docker.pkg.dev/mango-markets/gcr.io/mango-v4:$BASE_TAG CMD ["liquidator"] diff --git a/liquidator/src/liquidate.rs b/liquidator/src/liquidate.rs index fd6219558..8ef545450 100644 --- a/liquidator/src/liquidate.rs +++ b/liquidator/src/liquidate.rs @@ -4,8 +4,8 @@ use crate::account_shared_data::KeyedAccountSharedData; use client::{chain_data, AccountFetcher, MangoClient, MangoClientError, MangoGroupContext}; use mango_v4::state::{ - new_health_cache, oracle_price, Bank, FixedOrderAccountRetriever, HealthCache, HealthType, - MangoAccountValue, TokenIndex, QUOTE_TOKEN_INDEX, + new_health_cache, Bank, FixedOrderAccountRetriever, HealthCache, HealthType, MangoAccountValue, + TokenIndex, QUOTE_TOKEN_INDEX, }; use {anyhow::Context, fixed::types::I80F48, solana_sdk::pubkey::Pubkey}; @@ -143,11 +143,10 @@ pub fn maybe_liquidate_account( let token = mango_client.context.token(token_position.token_index); let bank = account_fetcher.fetch::(&token.mint_info.first_bank())?; let oracle = account_fetcher.fetch_raw_account(token.mint_info.oracle)?; - let price = oracle_price( - &KeyedAccountSharedData::new(token.mint_info.oracle, oracle.into()), - bank.oracle_config.conf_filter, - bank.mint_decimals, - )?; + let price = bank.oracle_price(&KeyedAccountSharedData::new( + token.mint_info.oracle, + oracle.into(), + ))?; Ok(( token_position.token_index, price, diff --git a/liquidator/src/main.rs b/liquidator/src/main.rs index 7c1e5741a..76bae3d9e 100644 --- a/liquidator/src/main.rs +++ b/liquidator/src/main.rs @@ -332,13 +332,13 @@ fn liquidate<'a>( config: &liquidate::Config, rebalance_config: &rebalance::Config, ) -> anyhow::Result<()> { - if !liquidate::maybe_liquidate_one(&mango_client, &account_fetcher, accounts, &config) { + if !liquidate::maybe_liquidate_one(mango_client, account_fetcher, accounts, config) { return Ok(()); } let liqor = &mango_client.mango_account_address; if let Err(err) = - rebalance::zero_all_non_quote(mango_client, account_fetcher, liqor, &rebalance_config) + rebalance::zero_all_non_quote(mango_client, account_fetcher, liqor, rebalance_config) { log::error!("failed to rebalance liqor: {:?}", err); } diff --git a/liquidator/src/rebalance.rs b/liquidator/src/rebalance.rs index 34e59c6df..28dad5162 100644 --- a/liquidator/src/rebalance.rs +++ b/liquidator/src/rebalance.rs @@ -1,7 +1,7 @@ use crate::{account_shared_data::KeyedAccountSharedData, AnyhowWrap}; use client::{chain_data, AccountFetcher, MangoClient, TokenContext}; -use mango_v4::state::{oracle_price, Bank, TokenIndex, TokenPosition, QUOTE_TOKEN_INDEX}; +use mango_v4::state::{Bank, TokenIndex, TokenPosition, QUOTE_TOKEN_INDEX}; use {fixed::types::I80F48, solana_sdk::pubkey::Pubkey}; @@ -44,11 +44,10 @@ impl TokenState { account_fetcher: &chain_data::AccountFetcher, ) -> anyhow::Result { let oracle = account_fetcher.fetch_raw_account(token.mint_info.oracle)?; - oracle_price( - &KeyedAccountSharedData::new(token.mint_info.oracle, oracle.into()), - bank.oracle_config.conf_filter, - bank.mint_decimals, - ) + bank.oracle_price(&KeyedAccountSharedData::new( + token.mint_info.oracle, + oracle.into(), + )) .map_err_anyhow() } } diff --git a/programs/mango-v4/Cargo.toml b/programs/mango-v4/Cargo.toml index bdfa792d3..b693ad05d 100644 --- a/programs/mango-v4/Cargo.toml +++ b/programs/mango-v4/Cargo.toml @@ -58,3 +58,4 @@ async-trait = "0.1.52" margin-trade = { path = "../margin-trade", features = ["cpi"] } itertools = "0.10.3" rand = "0.8.4" +lazy_static = "1.4.0" diff --git a/programs/mango-v4/src/error.rs b/programs/mango-v4/src/error.rs index 65a216383..84c1d55d6 100644 --- a/programs/mango-v4/src/error.rs +++ b/programs/mango-v4/src/error.rs @@ -19,6 +19,8 @@ pub enum MangoError { InvalidFlashLoanTargetCpiProgram, #[msg("health must be positive")] HealthMustBePositive, + #[msg("health must be positive or increase")] + HealthMustBePositiveOrIncrease, #[msg("health must be negative")] HealthMustBeNegative, #[msg("the account is bankrupt")] @@ -35,6 +37,8 @@ pub enum MangoError { Serum3OpenOrdersExistAlready, #[msg("bank vault has insufficent funds")] InsufficentBankVaultFunds, + #[msg("account is currently being liquidated")] + BeingLiquidated, } pub trait Contextable { diff --git a/programs/mango-v4/src/instructions/flash_loan.rs b/programs/mango-v4/src/instructions/flash_loan.rs index 2616ff5a0..b71455a84 100644 --- a/programs/mango-v4/src/instructions/flash_loan.rs +++ b/programs/mango-v4/src/instructions/flash_loan.rs @@ -4,8 +4,8 @@ use crate::group_seeds; use crate::logs::{FlashLoanLog, FlashLoanTokenDetail, TokenBalanceLog}; use crate::state::MangoAccount; use crate::state::{ - compute_health, compute_health_from_fixed_accounts, new_fixed_order_account_retriever, - AccountLoaderDynamic, AccountRetriever, Bank, Group, HealthType, TokenIndex, + new_fixed_order_account_retriever, new_health_cache, AccountLoaderDynamic, AccountRetriever, + Bank, Group, TokenIndex, }; use crate::util::checked_math as cm; use anchor_lang::prelude::*; @@ -72,7 +72,7 @@ pub fn flash_loan_begin<'key, 'accounts, 'remaining, 'info>( let token_accounts = &ctx.remaining_accounts[2 * num_loans..3 * num_loans]; let group_ai = &ctx.remaining_accounts[3 * num_loans]; - let group_al = AccountLoader::::try_from(&group_ai)?; + let group_al = AccountLoader::::try_from(group_ai)?; let group = group_al.load()?; let group_seeds = group_seeds!(group); let seeds = [&group_seeds[..]]; @@ -316,7 +316,8 @@ pub fn flash_loan_end<'key, 'accounts, 'remaining, 'info>( // Check health before balance adjustments let retriever = new_fixed_order_account_retriever(health_ais, &account.borrow())?; - let _pre_health = compute_health(&account.borrow(), HealthType::Init, &retriever)?; + let health_cache = new_health_cache(&account.borrow(), &retriever)?; + let pre_health = account.check_health_pre(&health_cache)?; // Prices for logging let mut prices = vec![]; @@ -388,13 +389,9 @@ pub fn flash_loan_end<'key, 'accounts, 'remaining, 'info>( }); // Check health after account position changes - let post_health = - compute_health_from_fixed_accounts(&account.borrow(), HealthType::Init, health_ais)?; - msg!("post_cpi_health {:?}", post_health); - require!(post_health >= 0, MangoError::HealthMustBePositive); - account - .fixed - .maybe_recover_from_being_liquidated(post_health); + let retriever = new_fixed_order_account_retriever(health_ais, &account.borrow())?; + let health_cache = new_health_cache(&account.borrow(), &retriever)?; + account.check_health_post(&health_cache, pre_health)?; // Deactivate inactive token accounts after health check for raw_token_index in deactivated_token_positions { diff --git a/programs/mango-v4/src/instructions/health_region.rs b/programs/mango-v4/src/instructions/health_region.rs new file mode 100644 index 000000000..05d10c67d --- /dev/null +++ b/programs/mango-v4/src/instructions/health_region.rs @@ -0,0 +1,110 @@ +use crate::error::*; +use crate::state::*; +use anchor_lang::prelude::*; +use anchor_lang::solana_program::sysvar::instructions as tx_instructions; +use anchor_lang::Discriminator; +use fixed::types::I80F48; + +/// Sets up for a health region +/// +/// The same transaction must have the corresponding HealthRegionEnd call. +/// +/// remaining_accounts: health accounts for account +#[derive(Accounts)] +pub struct HealthRegionBegin<'info> { + /// Instructions Sysvar for instruction introspection + /// CHECK: fixed instructions sysvar account + #[account(address = tx_instructions::ID)] + pub instructions: UncheckedAccount<'info>, + + #[account(mut)] + pub account: AccountLoaderDynamic<'info, MangoAccount>, +} + +/// Ends a health region. +/// +/// remaining_accounts: health accounts for account +#[derive(Accounts)] +pub struct HealthRegionEnd<'info> { + #[account(mut)] + pub account: AccountLoaderDynamic<'info, MangoAccount>, +} + +pub fn health_region_begin<'key, 'accounts, 'remaining, 'info>( + ctx: Context<'key, 'accounts, 'remaining, 'info, HealthRegionBegin<'info>>, +) -> Result<()> { + // Check if the other instructions in the transactions are compatible + { + let ixs = ctx.accounts.instructions.as_ref(); + let current_index = tx_instructions::load_current_index_checked(ixs)? as usize; + + // There must be a matching HealthRegionEnd instruction + let mut index = current_index + 1; + let mut found_end = false; + loop { + let ix = match tx_instructions::load_instruction_at_checked(index, ixs) { + Ok(ix) => ix, + Err(ProgramError::InvalidArgument) => break, // past the last instruction + Err(e) => return Err(e.into()), + }; + index += 1; + + if ix.program_id != crate::id() { + continue; + } + if ix.data[0..8] != crate::instruction::HealthRegionEnd::discriminator() { + continue; + } + + // check that it's for the same account + require_keys_eq!(ix.accounts[0].pubkey, ctx.accounts.account.key()); + + found_end = true; + index += 1; + } + require_msg!( + found_end, + "found no HealthRegionEnd instruction in transaction" + ); + } + + let mut account = ctx.accounts.account.load_mut()?; + require_msg!( + !account.fixed.is_in_health_region(), + "account must not already be health wrapped" + ); + account.fixed.set_in_health_region(true); + + let group = account.fixed.group; + let account_retriever = ScanningAccountRetriever::new(ctx.remaining_accounts, &group) + .context("create account retriever")?; + + // Compute pre-health and store it on the account + let health_cache = new_health_cache(&account.borrow(), &account_retriever)?; + let pre_health = account.check_health_pre(&health_cache)?; + account.fixed.health_region_begin_init_health = pre_health.ceil().checked_to_num().unwrap(); + + Ok(()) +} + +pub fn health_region_end<'key, 'accounts, 'remaining, 'info>( + ctx: Context<'key, 'accounts, 'remaining, 'info, HealthRegionEnd<'info>>, +) -> Result<()> { + let mut account = ctx.accounts.account.load_mut()?; + require_msg!( + account.fixed.is_in_health_region(), + "account must be health wrapped" + ); + account.fixed.set_in_health_region(false); + + let group = account.fixed.group; + let account_retriever = ScanningAccountRetriever::new(ctx.remaining_accounts, &group) + .context("create account retriever")?; + let health_cache = new_health_cache(&account.borrow(), &account_retriever)?; + + let pre_health = I80F48::from(account.fixed.health_region_begin_init_health); + account.check_health_post(&health_cache, pre_health)?; + account.fixed.health_region_begin_init_health = 0; + + Ok(()) +} diff --git a/programs/mango-v4/src/instructions/liq_token_bankruptcy.rs b/programs/mango-v4/src/instructions/liq_token_bankruptcy.rs index c65282b34..75b56ded3 100644 --- a/programs/mango-v4/src/instructions/liq_token_bankruptcy.rs +++ b/programs/mango-v4/src/instructions/liq_token_bankruptcy.rs @@ -89,6 +89,7 @@ pub fn liq_token_bankruptcy( .is_owner_or_delegate(ctx.accounts.liqor_owner.key()), MangoError::SomeError ); + require!(!liqor.fixed.being_liquidated(), MangoError::BeingLiquidated); let mut account_retriever = ScanningAccountRetriever::new(health_ais, group_pk)?; @@ -106,7 +107,7 @@ pub fn liq_token_bankruptcy( let mut liab_deposit_index = liab_bank.deposit_index; let liab_borrow_index = liab_bank.borrow_index; let (liqee_liab, liqee_raw_token_index) = liqee.token_position_mut(liab_token_index)?; - let initial_liab_native = liqee_liab.native(&liab_bank); + let initial_liab_native = liqee_liab.native(liab_bank); let mut remaining_liab_loss = -initial_liab_native; require_gt!(remaining_liab_loss, I80F48::ZERO); @@ -178,9 +179,11 @@ pub fn liq_token_bankruptcy( liab_bank.withdraw_with_fee(liqor_liab, liab_transfer)?; // Check liqor's health - let liqor_health = - compute_health(&liqor.borrow(), HealthType::Init, &account_retriever)?; - require!(liqor_health >= 0, MangoError::HealthMustBePositive); + if !liqor.fixed.is_in_health_region() { + let liqor_health = + compute_health(&liqor.borrow(), HealthType::Init, &account_retriever)?; + require!(liqor_health >= 0, MangoError::HealthMustBePositive); + } // liqor quote emit!(TokenBalanceLog { @@ -304,7 +307,7 @@ pub fn liq_token_bankruptcy( mango_group: ctx.accounts.group.key(), liqee: ctx.accounts.liqee.key(), liqor: ctx.accounts.liqor.key(), - liab_token_index: liab_token_index, + liab_token_index, initial_liab_native: initial_liab_native.to_bits(), liab_price: liab_price.to_bits(), insurance_token_index: QUOTE_TOKEN_INDEX, diff --git a/programs/mango-v4/src/instructions/liq_token_with_token.rs b/programs/mango-v4/src/instructions/liq_token_with_token.rs index 46fa2d59b..d4205fbb1 100644 --- a/programs/mango-v4/src/instructions/liq_token_with_token.rs +++ b/programs/mango-v4/src/instructions/liq_token_with_token.rs @@ -50,6 +50,7 @@ pub fn liq_token_with_token( .is_owner_or_delegate(ctx.accounts.liqor_owner.key()), MangoError::SomeError ); + require!(!liqor.fixed.being_liquidated(), MangoError::BeingLiquidated); let mut liqee = ctx.accounts.liqee.load_mut()?; @@ -62,8 +63,7 @@ pub fn liq_token_with_token( // we want to allow liquidation to continue until init_health is positive, // to prevent constant oscillation between the two states if liqee.being_liquidated() { - if init_health > I80F48::ZERO { - liqee.fixed.set_being_liquidated(false); + if liqee.fixed.maybe_recover_from_being_liquidated(init_health) { msg!("Liqee init_health above zero"); return Ok(()); } @@ -150,7 +150,7 @@ pub fn liq_token_with_token( let (liqor_liab_active, loan_origination_fee) = liab_bank.withdraw_with_fee(liqor_liab_position, liab_transfer)?; let liqor_liab_position_indexed = liqor_liab_position.indexed_position; - let liqee_liab_native_after = liqee_liab_position.native(&liab_bank); + let liqee_liab_native_after = liqee_liab_position.native(liab_bank); let (liqor_asset_position, liqor_asset_raw_index, _) = liqor.ensure_token_position(asset_token_index)?; @@ -161,7 +161,7 @@ pub fn liq_token_with_token( let liqee_asset_active = asset_bank.withdraw_without_fee_with_dusting(liqee_asset_position, asset_transfer)?; let liqee_asset_position_indexed = liqee_asset_position.indexed_position; - let liqee_assets_native_after = liqee_asset_position.native(&asset_bank); + let liqee_assets_native_after = liqee_asset_position.native(asset_bank); // Update the health cache liqee_health_cache.adjust_token_balance( @@ -251,9 +251,11 @@ pub fn liq_token_with_token( .maybe_recover_from_being_liquidated(liqee_init_health); // Check liqor's health - let liqor_health = compute_health(&liqor.borrow(), HealthType::Init, &account_retriever) - .context("compute liqor health")?; - require!(liqor_health >= 0, MangoError::HealthMustBePositive); + if !liqor.fixed.is_in_health_region() { + let liqor_health = compute_health(&liqor.borrow(), HealthType::Init, &account_retriever) + .context("compute liqor health")?; + require!(liqor_health >= 0, MangoError::HealthMustBePositive); + } emit!(LiquidateTokenAndTokenLog { mango_group: ctx.accounts.group.key(), diff --git a/programs/mango-v4/src/instructions/mod.rs b/programs/mango-v4/src/instructions/mod.rs index 7d8dece86..03283ea81 100644 --- a/programs/mango-v4/src/instructions/mod.rs +++ b/programs/mango-v4/src/instructions/mod.rs @@ -8,6 +8,7 @@ pub use flash_loan::*; pub use group_close::*; pub use group_create::*; pub use group_edit::*; +pub use health_region::*; pub use liq_token_bankruptcy::*; pub use liq_token_with_token::*; pub use perp_cancel_all_orders::*; @@ -51,6 +52,7 @@ mod flash_loan; mod group_close; mod group_create; mod group_edit; +mod health_region; mod liq_token_bankruptcy; mod liq_token_with_token; mod perp_cancel_all_orders; diff --git a/programs/mango-v4/src/instructions/perp_place_order.rs b/programs/mango-v4/src/instructions/perp_place_order.rs index 3602fe30a..32abc1619 100644 --- a/programs/mango-v4/src/instructions/perp_place_order.rs +++ b/programs/mango-v4/src/instructions/perp_place_order.rs @@ -4,8 +4,8 @@ use crate::accounts_zerocopy::*; use crate::error::*; use crate::state::MangoAccount; use crate::state::{ - compute_health, new_fixed_order_account_retriever, oracle_price, AccountLoaderDynamic, Book, - BookSide, EventQueue, Group, HealthType, OrderType, PerpMarket, Side, + new_fixed_order_account_retriever, new_health_cache, oracle_price, AccountLoaderDynamic, Book, + BookSide, EventQueue, Group, OrderType, PerpMarket, Side, }; #[derive(Accounts)] @@ -83,60 +83,81 @@ pub fn perp_place_order( let account_pk = ctx.accounts.account.key(); - { - let mut perp_market = ctx.accounts.perp_market.load_mut()?; - let bids = ctx.accounts.bids.load_mut()?; - let asks = ctx.accounts.asks.load_mut()?; - let mut book = Book::new(bids, asks); + let perp_market_index = { + let perp_market = ctx.accounts.perp_market.load()?; + perp_market.perp_market_index + }; + let (_, perp_position_raw_index) = account.ensure_perp_position(perp_market_index)?; - let mut event_queue = ctx.accounts.event_queue.load_mut()?; + // + // Pre-health computation, _after_ perp position is created + // + 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_health = account.check_health_pre(&health_cache)?; + Some((health_cache, pre_health)) + } else { + None + }; - let oracle_price = oracle_price( - &AccountInfoRef::borrow(ctx.accounts.oracle.as_ref())?, - perp_market.oracle_config.conf_filter, - perp_market.base_token_decimals, - )?; + let mut perp_market = ctx.accounts.perp_market.load_mut()?; + let bids = ctx.accounts.bids.load_mut()?; + let asks = ctx.accounts.asks.load_mut()?; + let mut book = Book::new(bids, asks); - let now_ts = Clock::get()?.unix_timestamp as u64; - let time_in_force = if expiry_timestamp != 0 { - // If expiry is far in the future, clamp to 255 seconds - let tif = expiry_timestamp.saturating_sub(now_ts).min(255); - if tif == 0 { - // If expiry is in the past, ignore the order - msg!("Order is already expired"); - return Ok(()); - } - tif as u8 - } else { - // Never expire - 0 - }; + let mut event_queue = ctx.accounts.event_queue.load_mut()?; - // TODO reduce_only based on event queue + let oracle_price = oracle_price( + &AccountInfoRef::borrow(ctx.accounts.oracle.as_ref())?, + perp_market.oracle_config.conf_filter, + perp_market.base_token_decimals, + )?; - book.new_order( - side, - &mut perp_market, - &mut event_queue, - oracle_price, - &mut account.borrow_mut(), - &account_pk, - price_lots, - max_base_lots, - max_quote_lots, - order_type, - time_in_force, - client_order_id, - now_ts, - limit, - )?; + let now_ts = Clock::get()?.unix_timestamp as u64; + let time_in_force = if expiry_timestamp != 0 { + // If expiry is far in the future, clamp to 255 seconds + let tif = expiry_timestamp.saturating_sub(now_ts).min(255); + if tif == 0 { + // If expiry is in the past, ignore the order + msg!("Order is already expired"); + return Ok(()); + } + tif as u8 + } else { + // Never expire + 0 + }; + + // TODO reduce_only based on event queue + + book.new_order( + side, + &mut perp_market, + &mut event_queue, + oracle_price, + &mut account.borrow_mut(), + &account_pk, + price_lots, + max_base_lots, + max_quote_lots, + order_type, + time_in_force, + client_order_id, + now_ts, + limit, + )?; + + // + // Health check + // + if let Some((mut health_cache, pre_health)) = pre_health_opt { + let perp_position = account.perp_position_by_raw_index(perp_position_raw_index); + health_cache.recompute_perp_info(perp_position, &perp_market)?; + account.check_health_post(&health_cache, pre_health)?; } - let retriever = new_fixed_order_account_retriever(ctx.remaining_accounts, &account.borrow())?; - let health = compute_health(&account.borrow(), HealthType::Init, &retriever)?; - msg!("health: {}", health); - require!(health >= 0, MangoError::HealthMustBePositive); - account.fixed.maybe_recover_from_being_liquidated(health); - Ok(()) } diff --git a/programs/mango-v4/src/instructions/serum3_cancel_all_orders.rs b/programs/mango-v4/src/instructions/serum3_cancel_all_orders.rs index 48ba91d2e..0ac3b5ae0 100644 --- a/programs/mango-v4/src/instructions/serum3_cancel_all_orders.rs +++ b/programs/mango-v4/src/instructions/serum3_cancel_all_orders.rs @@ -7,12 +7,15 @@ use crate::state::*; pub struct Serum3CancelAllOrders<'info> { pub group: AccountLoader<'info, Group>, - #[account(has_one = group)] + #[account( + has_one = group + // owner is checked at #1 + )] pub account: AccountLoaderDynamic<'info, MangoAccount>, pub owner: Signer<'info>, #[account(mut)] - /// CHECK: Validated inline by checking against the pubkey stored in the account + /// CHECK: Validated inline by checking against the pubkey stored in the account at #2 pub open_orders: UncheckedAccount<'info>, #[account( @@ -46,6 +49,7 @@ pub fn serum3_cancel_all_orders(ctx: Context, limit: u8) // { let account = ctx.accounts.account.load()?; + // account constraint #1 require!( account.fixed.is_owner_or_delegate(ctx.accounts.owner.key()), MangoError::SomeError @@ -53,11 +57,10 @@ pub fn serum3_cancel_all_orders(ctx: Context, limit: u8) let serum_market = ctx.accounts.serum_market.load()?; - // Validate open_orders + // Validate open_orders #2 require!( account - .serum3_orders(serum_market.market_index) - .ok_or_else(|| error!(MangoError::SomeError))? + .serum3_orders(serum_market.market_index)? .open_orders == ctx.accounts.open_orders.key(), MangoError::SomeError diff --git a/programs/mango-v4/src/instructions/serum3_cancel_order.rs b/programs/mango-v4/src/instructions/serum3_cancel_order.rs index c55c8590d..7b4af32f5 100644 --- a/programs/mango-v4/src/instructions/serum3_cancel_order.rs +++ b/programs/mango-v4/src/instructions/serum3_cancel_order.rs @@ -3,23 +3,24 @@ use anchor_lang::prelude::*; use serum_dex::instruction::CancelOrderInstructionV2; use crate::error::*; -use crate::serum3_cpi::load_open_orders_ref; use crate::state::*; -use super::OpenOrdersSlim; use super::Serum3Side; -use checked_math as cm; #[derive(Accounts)] pub struct Serum3CancelOrder<'info> { pub group: AccountLoader<'info, Group>, - #[account(mut, has_one = group)] + #[account( + mut, + has_one = group + // owner is checked at #1 + )] pub account: AccountLoaderDynamic<'info, MangoAccount>, pub owner: Signer<'info>, #[account(mut)] - /// CHECK: Validated inline by checking against the pubkey stored in the account + /// CHECK: Validated inline by checking against the pubkey stored in the account at #2 pub open_orders: UncheckedAccount<'info>, #[account( @@ -59,16 +60,16 @@ pub fn serum3_cancel_order( // { let account = ctx.accounts.account.load()?; + // account constraint #1 require!( account.fixed.is_owner_or_delegate(ctx.accounts.owner.key()), MangoError::SomeError ); - // Validate open_orders + // Validate open_orders #2 require!( account - .serum3_orders(serum_market.market_index) - .ok_or_else(|| error!(MangoError::SomeError))? + .serum3_orders(serum_market.market_index)? .open_orders == ctx.accounts.open_orders.key(), MangoError::SomeError @@ -78,55 +79,15 @@ pub fn serum3_cancel_order( // // Cancel // - let before_oo = { - let open_orders = load_open_orders_ref(ctx.accounts.open_orders.as_ref())?; - OpenOrdersSlim::from_oo(&open_orders) - }; let order = serum_dex::instruction::CancelOrderInstructionV2 { side: u8::try_from(side).unwrap().try_into().unwrap(), order_id, }; cpi_cancel_order(ctx.accounts, order)?; - { - let open_orders = load_open_orders_ref(ctx.accounts.open_orders.as_ref())?; - let after_oo = OpenOrdersSlim::from_oo(&open_orders); - let mut account = ctx.accounts.account.load_mut()?; - decrease_maybe_loan( - serum_market.market_index, - &mut account.borrow_mut(), - &before_oo, - &after_oo, - ); - }; - Ok(()) } -// if free has increased, the free increase is reduction in reserved, reduce this from -// the cached -pub fn decrease_maybe_loan( - market_index: Serum3MarketIndex, - account: &mut MangoAccountRefMut, - before_oo: &OpenOrdersSlim, - after_oo: &OpenOrdersSlim, -) { - let serum3_account = account.serum3_orders_mut(market_index).unwrap(); - - if after_oo.native_coin_free > before_oo.native_coin_free { - let native_coin_free_increase = after_oo.native_coin_free - before_oo.native_coin_free; - serum3_account.previous_native_coin_reserved = - cm!(serum3_account.previous_native_coin_reserved - native_coin_free_increase); - } - - // pc - if after_oo.native_pc_free > before_oo.native_pc_free { - let free_pc_increase = after_oo.native_pc_free - before_oo.native_pc_free; - serum3_account.previous_native_pc_reserved = - cm!(serum3_account.previous_native_pc_reserved - free_pc_increase); - } -} - fn cpi_cancel_order(ctx: &Serum3CancelOrder, order: CancelOrderInstructionV2) -> Result<()> { use crate::serum3_cpi; let group = ctx.group.load()?; diff --git a/programs/mango-v4/src/instructions/serum3_close_open_orders.rs b/programs/mango-v4/src/instructions/serum3_close_open_orders.rs index a457b6661..7d0cebe91 100644 --- a/programs/mango-v4/src/instructions/serum3_close_open_orders.rs +++ b/programs/mango-v4/src/instructions/serum3_close_open_orders.rs @@ -7,7 +7,11 @@ use crate::state::*; pub struct Serum3CloseOpenOrders<'info> { pub group: AccountLoader<'info, Group>, - #[account(mut, has_one = group)] + #[account( + mut, + has_one = group + // owner is checked at #1 + )] pub account: AccountLoaderDynamic<'info, MangoAccount>, pub owner: Signer<'info>, @@ -23,7 +27,7 @@ pub struct Serum3CloseOpenOrders<'info> { pub serum_market_external: UncheckedAccount<'info>, #[account(mut)] - /// CHECK: Validated inline by checking against the pubkey stored in the account + /// CHECK: Validated inline by checking against the pubkey stored in the account at #2 pub open_orders: UncheckedAccount<'info>, #[account(mut)] @@ -36,6 +40,7 @@ pub fn serum3_close_open_orders(ctx: Context) -> Result<( // Validation // let mut account = ctx.accounts.account.load_mut()?; + // account constraint #1 require!( account.fixed.is_owner_or_delegate(ctx.accounts.owner.key()), MangoError::SomeError @@ -43,11 +48,10 @@ pub fn serum3_close_open_orders(ctx: Context) -> Result<( let serum_market = ctx.accounts.serum_market.load()?; - // Validate open_orders + // Validate open_orders #2 require!( account - .serum3_orders(serum_market.market_index) - .ok_or_else(|| error!(MangoError::SomeError))? + .serum3_orders(serum_market.market_index)? .open_orders == ctx.accounts.open_orders.key(), MangoError::SomeError @@ -58,7 +62,14 @@ pub fn serum3_close_open_orders(ctx: Context) -> Result<( // cpi_close_open_orders(ctx.accounts)?; - // TODO: decrement in_use_count on the base token and quote token + // Reduce the in_use_count on the token positions - they no longer need to be forced open. + // We cannot immediately dust tiny positions because we don't have the banks. + let (base_position, _) = account.token_position_mut(serum_market.base_token_index)?; + base_position.in_use_count = base_position.in_use_count.saturating_sub(1); + let (quote_position, _) = account.token_position_mut(serum_market.quote_token_index)?; + quote_position.in_use_count = quote_position.in_use_count.saturating_sub(1); + + // Deactivate the serum open orders account itself account.deactivate_serum3_orders(serum_market.market_index)?; Ok(()) diff --git a/programs/mango-v4/src/instructions/serum3_create_open_orders.rs b/programs/mango-v4/src/instructions/serum3_create_open_orders.rs index e2dfd3ab4..ff6e34e97 100644 --- a/programs/mango-v4/src/instructions/serum3_create_open_orders.rs +++ b/programs/mango-v4/src/instructions/serum3_create_open_orders.rs @@ -7,7 +7,11 @@ use crate::state::*; pub struct Serum3CreateOpenOrders<'info> { pub group: AccountLoader<'info, Group>, - #[account(mut, has_one = group)] + #[account( + mut, + has_one = group + // owner is checked at #1 + )] pub account: AccountLoaderDynamic<'info, MangoAccount>, pub owner: Signer<'info>, @@ -48,6 +52,7 @@ pub fn serum3_create_open_orders(ctx: Context) -> Result let serum_market = ctx.accounts.serum_market.load()?; let mut account = ctx.accounts.account.load_mut()?; + // account constraint #1 require!( account.fixed.is_owner_or_delegate(ctx.accounts.owner.key()), MangoError::SomeError diff --git a/programs/mango-v4/src/instructions/serum3_liq_force_cancel_orders.rs b/programs/mango-v4/src/instructions/serum3_liq_force_cancel_orders.rs index 165f85ada..448c44afd 100644 --- a/programs/mango-v4/src/instructions/serum3_liq_force_cancel_orders.rs +++ b/programs/mango-v4/src/instructions/serum3_liq_force_cancel_orders.rs @@ -1,12 +1,14 @@ use anchor_lang::prelude::*; use anchor_spl::token::{Token, TokenAccount}; +use fixed::types::I80F48; use crate::error::*; -use crate::instructions::apply_vault_difference; +use crate::instructions::{ + apply_vault_difference, charge_loan_origination_fees, OODifference, OpenOrdersSlim, +}; +use crate::serum3_cpi::load_open_orders_ref; use crate::state::*; -use crate::logs::{LoanOriginationFeeInstruction, WithdrawLoanOriginationFeeLog}; - #[derive(Accounts)] pub struct Serum3LiqForceCancelOrders<'info> { pub group: AccountLoader<'info, Group>, @@ -15,7 +17,7 @@ pub struct Serum3LiqForceCancelOrders<'info> { pub account: AccountLoaderDynamic<'info, MangoAccount>, #[account(mut)] - /// CHECK: Validated inline by checking against the pubkey stored in the account + /// CHECK: Validated inline by checking against the pubkey stored in the account at #2 pub open_orders: UncheckedAccount<'info>, #[account( @@ -50,7 +52,7 @@ pub struct Serum3LiqForceCancelOrders<'info> { /// CHECK: Validated by the serum cpi call pub market_vault_signer: UncheckedAccount<'info>, - // token_index and bank.vault == vault is validated inline + // token_index and bank.vault == vault is validated inline at #3 #[account(mut, has_one = group)] pub quote_bank: AccountLoader<'info, Bank>, #[account(mut)] @@ -74,17 +76,16 @@ pub fn serum3_liq_force_cancel_orders( { let account = ctx.accounts.account.load()?; - // Validate open_orders + // Validate open_orders #2 require!( account - .serum3_orders(serum_market.market_index) - .ok_or_else(|| error!(MangoError::SomeError))? + .serum3_orders(serum_market.market_index)? .open_orders == ctx.accounts.open_orders.key(), MangoError::SomeError ); - // Validate banks and vaults + // Validate banks and vaults #3 let quote_bank = ctx.accounts.quote_bank.load()?; require!( quote_bank.vault == ctx.accounts.quote_vault.key(), @@ -105,16 +106,58 @@ pub fn serum3_liq_force_cancel_orders( ); } - // TODO: do the correct health / being_liquidated check - { - let account = ctx.accounts.account.load()?; - + // + // Check liqee health if liquidation is allowed + // + let mut health_cache = { + let mut account = ctx.accounts.account.load_mut()?; let retriever = new_fixed_order_account_retriever(ctx.remaining_accounts, &account.borrow())?; - let health = compute_health(&account.borrow(), HealthType::Maint, &retriever)?; - msg!("health: {}", health); - require!(health < 0, MangoError::SomeError); - } + let health_cache = + new_health_cache(&account.borrow(), &retriever).context("create health cache")?; + + if account.being_liquidated() { + let init_health = health_cache.health(HealthType::Init); + if account + .fixed + .maybe_recover_from_being_liquidated(init_health) + { + msg!("Liqee init_health above zero"); + return Ok(()); + } + } else { + let maint_health = health_cache.health(HealthType::Maint); + require!( + maint_health < I80F48::ZERO, + MangoError::HealthMustBeNegative + ); + account.fixed.set_being_liquidated(true); + } + + health_cache + }; + + // + // Charge any open loan origination fees + // + let before_oo = { + let open_orders = load_open_orders_ref(ctx.accounts.open_orders.as_ref())?; + let before_oo = OpenOrdersSlim::from_oo(&open_orders); + let mut account = ctx.accounts.account.load_mut()?; + let mut base_bank = ctx.accounts.base_bank.load_mut()?; + let mut quote_bank = ctx.accounts.quote_bank.load_mut()?; + charge_loan_origination_fees( + &ctx.accounts.group.key(), + &ctx.accounts.account.key(), + serum_market.market_index, + &mut base_bank, + &mut quote_bank, + &mut account.borrow_mut(), + &before_oo, + )?; + + before_oo + }; // // Before-settle tracking @@ -131,45 +174,51 @@ pub fn serum3_liq_force_cancel_orders( // // After-settle tracking // + { + 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); + OODifference::new(&before_oo, &after_oo) + .adjust_health_cache(&mut health_cache, &serum_market)?; + }; + ctx.accounts.base_vault.reload()?; ctx.accounts.quote_vault.reload()?; let after_base_vault = ctx.accounts.base_vault.amount; let after_quote_vault = ctx.accounts.quote_vault.amount; - // Charge the difference in vault balances to the user's account + // Settle cannot decrease vault balances + require_gte!(after_base_vault, before_base_vault); + require_gte!(after_quote_vault, before_quote_vault); + + // Credit the difference in vault balances to the user's account let mut account = ctx.accounts.account.load_mut()?; let mut base_bank = ctx.accounts.base_bank.load_mut()?; let mut quote_bank = ctx.accounts.quote_bank.load_mut()?; - let (vault_difference_result, base_loan_origination_fee, quote_loan_origination_fee) = - apply_vault_difference( - &mut account.borrow_mut(), - &mut base_bank, - after_base_vault, - before_base_vault, - &mut quote_bank, - after_quote_vault, - before_quote_vault, - )?; - vault_difference_result.deactivate_inactive_token_accounts(&mut account.borrow_mut()); + apply_vault_difference( + &mut account.borrow_mut(), + serum_market.market_index, + &mut base_bank, + after_base_vault, + before_base_vault, + )? + .adjust_health_cache(&mut health_cache)?; + apply_vault_difference( + &mut account.borrow_mut(), + serum_market.market_index, + &mut quote_bank, + after_quote_vault, + before_quote_vault, + )? + .adjust_health_cache(&mut health_cache)?; - if base_loan_origination_fee.is_positive() { - emit!(WithdrawLoanOriginationFeeLog { - mango_group: ctx.accounts.group.key(), - mango_account: ctx.accounts.account.key(), - token_index: serum_market.base_token_index, - loan_origination_fee: base_loan_origination_fee.to_bits(), - instruction: LoanOriginationFeeInstruction::Serum3LiqForceCancelOrders - }); - } - if quote_loan_origination_fee.is_positive() { - emit!(WithdrawLoanOriginationFeeLog { - mango_group: ctx.accounts.group.key(), - mango_account: ctx.accounts.account.key(), - token_index: serum_market.quote_token_index, - loan_origination_fee: quote_loan_origination_fee.to_bits(), - instruction: LoanOriginationFeeInstruction::Serum3LiqForceCancelOrders - }); - } + // + // Health check at the end + // + let init_health = health_cache.health(HealthType::Init); + account + .fixed + .maybe_recover_from_being_liquidated(init_health); Ok(()) } diff --git a/programs/mango-v4/src/instructions/serum3_place_order.rs b/programs/mango-v4/src/instructions/serum3_place_order.rs index 3b787185d..e3c60d397 100644 --- a/programs/mango-v4/src/instructions/serum3_place_order.rs +++ b/programs/mango-v4/src/instructions/serum3_place_order.rs @@ -1,6 +1,6 @@ use crate::error::*; -use crate::serum3_cpi::load_open_orders_ref; +use crate::serum3_cpi::{load_market_state, load_open_orders_ref}; use crate::state::*; use anchor_lang::prelude::*; use anchor_spl::token::{Token, TokenAccount}; @@ -9,17 +9,16 @@ use fixed::types::I80F48; use num_enum::IntoPrimitive; use num_enum::TryFromPrimitive; use serum_dex::instruction::NewOrderInstructionV3; -use serum_dex::matching::Side; use serum_dex::state::OpenOrders; -use crate::logs::{LoanOriginationFeeInstruction, WithdrawLoanOriginationFeeLog}; - /// For loan origination fees bookkeeping purposes +#[derive(Debug)] pub struct OpenOrdersSlim { - pub native_coin_free: u64, - pub native_coin_total: u64, - pub native_pc_free: u64, - pub native_pc_total: u64, + native_coin_free: u64, + native_coin_total: u64, + native_pc_free: u64, + native_pc_total: u64, + referrer_rebates_accrued: u64, } impl OpenOrdersSlim { pub fn from_oo(oo: &OpenOrders) -> Self { @@ -28,30 +27,66 @@ impl OpenOrdersSlim { native_coin_total: oo.native_coin_total, native_pc_free: oo.native_pc_free, native_pc_total: oo.native_pc_total, + referrer_rebates_accrued: oo.referrer_rebates_accrued, } } } -pub trait OpenOrdersReserved { - fn native_coin_reserved(&self) -> u64; - fn native_pc_reserved(&self) -> u64; +pub trait OpenOrdersAmounts { + fn native_base_reserved(&self) -> u64; + fn native_quote_reserved(&self) -> u64; + fn native_base_free(&self) -> u64; + fn native_quote_free(&self) -> u64; + fn native_quote_free_plus_rebates(&self) -> u64; + fn native_base_total(&self) -> u64; + fn native_quote_total_plus_rebates(&self) -> u64; } -impl OpenOrdersReserved for OpenOrdersSlim { - fn native_coin_reserved(&self) -> u64 { - self.native_coin_total - self.native_coin_free +impl OpenOrdersAmounts for OpenOrdersSlim { + fn native_base_reserved(&self) -> u64 { + cm!(self.native_coin_total - self.native_coin_free) } - fn native_pc_reserved(&self) -> u64 { - self.native_pc_total - self.native_pc_free + fn native_quote_reserved(&self) -> u64 { + cm!(self.native_pc_total - self.native_pc_free) + } + fn native_base_free(&self) -> u64 { + self.native_coin_free + } + fn native_quote_free(&self) -> u64 { + self.native_pc_free + } + fn native_quote_free_plus_rebates(&self) -> u64 { + cm!(self.native_pc_free + self.referrer_rebates_accrued) + } + fn native_base_total(&self) -> u64 { + self.native_coin_total + } + fn native_quote_total_plus_rebates(&self) -> u64 { + cm!(self.native_pc_total + self.referrer_rebates_accrued) } } -impl OpenOrdersReserved for OpenOrders { - fn native_coin_reserved(&self) -> u64 { - self.native_coin_total - self.native_coin_free +impl OpenOrdersAmounts for OpenOrders { + fn native_base_reserved(&self) -> u64 { + cm!(self.native_coin_total - self.native_coin_free) } - fn native_pc_reserved(&self) -> u64 { - self.native_pc_total - self.native_pc_free + fn native_quote_reserved(&self) -> u64 { + cm!(self.native_pc_total - self.native_pc_free) + } + fn native_base_free(&self) -> u64 { + self.native_coin_free + } + fn native_quote_free(&self) -> u64 { + self.native_pc_free + } + fn native_quote_free_plus_rebates(&self) -> u64 { + cm!(self.native_pc_free + self.referrer_rebates_accrued) + } + fn native_base_total(&self) -> u64 { + self.native_coin_total + } + fn native_quote_total_plus_rebates(&self) -> u64 { + cm!(self.native_pc_total + self.referrer_rebates_accrued) } } @@ -85,12 +120,16 @@ pub enum Serum3Side { pub struct Serum3PlaceOrder<'info> { pub group: AccountLoader<'info, Group>, - #[account(mut, has_one = group)] + #[account( + mut, + has_one = group + // owner is checked at #1 + )] pub account: AccountLoaderDynamic<'info, MangoAccount>, pub owner: Signer<'info>, #[account(mut)] - /// CHECK: Validated inline by checking against the pubkey stored in the account + /// CHECK: Validated inline by checking against the pubkey stored in the account at #2 pub open_orders: UncheckedAccount<'info>, #[account( @@ -129,20 +168,13 @@ pub struct Serum3PlaceOrder<'info> { /// CHECK: Validated by the serum cpi call pub market_vault_signer: UncheckedAccount<'info>, - // TODO: do we need to pass both, or just payer? - // TODO: if we potentially settle immediately, they all need to be mut? - // TODO: Can we reduce the number of accounts by requiring the banks - // to be in the remainingAccounts (where they need to be anyway, for - // health checks - but they need to be mut) - // token_index and bank.vault == vault is validated inline + /// The bank that pays for the order, if necessary + // token_index and payer_bank.vault == payer_vault is validated inline at #3 #[account(mut, has_one = group)] - pub quote_bank: AccountLoader<'info, Bank>, + pub payer_bank: AccountLoader<'info, Bank>, + /// The bank vault that pays for the order, if necessary #[account(mut)] - pub quote_vault: Box>, - #[account(mut, has_one = group)] - pub base_bank: AccountLoader<'info, Bank>, - #[account(mut)] - pub base_vault: Box>, + pub payer_vault: Box>, pub token_program: Program<'info, Token>, } @@ -166,68 +198,86 @@ pub fn serum3_place_order( // { let account = ctx.accounts.account.load()?; + // account constraint #1 require!( account.fixed.is_owner_or_delegate(ctx.accounts.owner.key()), MangoError::SomeError ); - // Validate open_orders + // Validate open_orders #2 require!( account - .serum3_orders(serum_market.market_index) - .ok_or_else(|| error!(MangoError::SomeError))? + .serum3_orders(serum_market.market_index)? .open_orders == ctx.accounts.open_orders.key(), MangoError::SomeError ); - // Validate banks and vaults - let quote_bank = ctx.accounts.quote_bank.load()?; - require!( - quote_bank.vault == ctx.accounts.quote_vault.key(), - MangoError::SomeError - ); - require!( - quote_bank.token_index == serum_market.quote_token_index, - MangoError::SomeError - ); - let base_bank = ctx.accounts.base_bank.load()?; - require!( - base_bank.vault == ctx.accounts.base_vault.key(), - MangoError::SomeError - ); - require!( - base_bank.token_index == serum_market.base_token_index, - MangoError::SomeError - ); + // Validate bank and vault #3 + let payer_bank = ctx.accounts.payer_bank.load()?; + require_keys_eq!(payer_bank.vault, ctx.accounts.payer_vault.key()); + let payer_token_index = match side { + Serum3Side::Bid => serum_market.quote_token_index, + Serum3Side::Ask => serum_market.base_token_index, + }; + require_eq!(payer_bank.token_index, payer_token_index); } + // + // Pre-health computation + // + let mut account = ctx.accounts.account.load_mut()?; + 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_health = account.check_health_pre(&health_cache)?; + Some((health_cache, pre_health)) + } else { + None + }; + // // Before-order tracking // - let before_base_vault = ctx.accounts.base_vault.amount; - let before_quote_vault = ctx.accounts.quote_vault.amount; + let before_vault = ctx.accounts.payer_vault.amount; + + let before_oo = { + let oo_ai = &ctx.accounts.open_orders.as_ref(); + let open_orders = load_open_orders_ref(oo_ai)?; + OpenOrdersSlim::from_oo(&open_orders) + }; // Provide a readable error message in case the vault doesn't have enough tokens - let (vault_amount, needed_amount) = match side { - Serum3Side::Ask => (before_base_vault, max_base_qty), - Serum3Side::Bid => (before_quote_vault, max_native_quote_qty_including_fees), - }; - if vault_amount < needed_amount { - return err!(MangoError::InsufficentBankVaultFunds).with_context(|| { - format!( - "bank vault does not have enough tokens, need {} but have {}", - needed_amount, vault_amount - ) - }); + { + let base_lot_size = load_market_state( + &ctx.accounts.serum_market_external, + &ctx.accounts.serum_program.key(), + )? + .coin_lot_size; + + let needed_amount = match side { + Serum3Side::Ask => { + cm!(max_base_qty * base_lot_size).saturating_sub(before_oo.native_base_free()) + } + Serum3Side::Bid => { + max_native_quote_qty_including_fees.saturating_sub(before_oo.native_quote_free()) + } + }; + if before_vault < needed_amount { + return err!(MangoError::InsufficentBankVaultFunds).with_context(|| { + format!( + "bank vault does not have enough tokens, need {} but have {}", + needed_amount, before_vault + ) + }); + } } - // TODO: pre-health check - // - // Apply the order to serum. Also immediately settle, in case the order - // matched against an existing other order. + // Apply the order to serum // let order = serum_dex::instruction::NewOrderInstructionV3 { side: u8::try_from(side).unwrap().try_into().unwrap(), @@ -242,171 +292,149 @@ pub fn serum3_place_order( client_order_id, limit, }; - - let before_oo = { - let oo_ai = &ctx.accounts.open_orders.as_ref(); - let open_orders = load_open_orders_ref(oo_ai)?; - OpenOrdersSlim::from_oo(&open_orders) - }; 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); - let mut account = ctx.accounts.account.load_mut()?; - inc_maybe_loan( - serum_market.market_index, - &mut account.borrow_mut(), - &before_oo, - &after_oo, - ); - } - - cpi_settle_funds(ctx.accounts)?; + OODifference::new(&before_oo, &after_oo) + }; // // After-order tracking // - ctx.accounts.base_vault.reload()?; - ctx.accounts.quote_vault.reload()?; - let after_base_vault = ctx.accounts.base_vault.amount; - let after_quote_vault = ctx.accounts.quote_vault.amount; + ctx.accounts.payer_vault.reload()?; + let after_vault = ctx.accounts.payer_vault.amount; - // Charge the difference in vault balances to the user's account - let mut account = ctx.accounts.account.load_mut()?; - let (vault_difference_result, base_loan_origination_fee, quote_loan_origination_fee) = { - let mut base_bank = ctx.accounts.base_bank.load_mut()?; - let mut quote_bank = ctx.accounts.quote_bank.load_mut()?; + // Placing an order cannot increase vault balance + require_gte!(before_vault, after_vault); + // Charge the difference in vault balance to the user's account + let vault_difference = { + let mut payer_bank = ctx.accounts.payer_bank.load_mut()?; apply_vault_difference( &mut account.borrow_mut(), - &mut base_bank, - after_base_vault, - before_base_vault, - &mut quote_bank, - after_quote_vault, - before_quote_vault, + serum_market.market_index, + &mut payer_bank, + after_vault, + before_vault, )? }; // // Health check // - let retriever = new_fixed_order_account_retriever(ctx.remaining_accounts, &account.borrow())?; - let health = compute_health(&account.borrow(), HealthType::Init, &retriever)?; - msg!("health: {}", health); - require!(health >= 0, MangoError::HealthMustBePositive); - account.fixed.maybe_recover_from_being_liquidated(health); - - vault_difference_result.deactivate_inactive_token_accounts(&mut account.borrow_mut()); - - if base_loan_origination_fee.is_positive() { - emit!(WithdrawLoanOriginationFeeLog { - mango_group: ctx.accounts.group.key(), - mango_account: ctx.accounts.account.key(), - token_index: serum_market.base_token_index, - loan_origination_fee: base_loan_origination_fee.to_bits(), - instruction: LoanOriginationFeeInstruction::Serum3PlaceOrder - }); - } - if quote_loan_origination_fee.is_positive() { - emit!(WithdrawLoanOriginationFeeLog { - mango_group: ctx.accounts.group.key(), - mango_account: ctx.accounts.account.key(), - token_index: serum_market.quote_token_index, - loan_origination_fee: quote_loan_origination_fee.to_bits(), - instruction: LoanOriginationFeeInstruction::Serum3PlaceOrder - }); + if let Some((mut health_cache, pre_health)) = pre_health_opt { + vault_difference.adjust_health_cache(&mut health_cache)?; + oo_difference.adjust_health_cache(&mut health_cache, &serum_market)?; + account.check_health_post(&health_cache, pre_health)?; } Ok(()) } -// if reserved has increased, then increase cached value by the increase in reserved -pub fn inc_maybe_loan( - market_index: Serum3MarketIndex, - account: &mut MangoAccountRefMut, - before_oo: &OpenOrdersSlim, - after_oo: &OpenOrdersSlim, -) { - let serum3_account = account.serum3_orders_mut(market_index).unwrap(); - - if after_oo.native_coin_reserved() > before_oo.native_coin_reserved() { - let native_coin_reserved_increase = - after_oo.native_coin_reserved() - before_oo.native_coin_reserved(); - serum3_account.previous_native_coin_reserved = - cm!(serum3_account.previous_native_coin_reserved + native_coin_reserved_increase); - } - - if after_oo.native_pc_reserved() > before_oo.native_pc_reserved() { - let reserved_pc_increase = after_oo.native_pc_reserved() - before_oo.native_pc_reserved(); - serum3_account.previous_native_pc_reserved = - cm!(serum3_account.previous_native_pc_reserved + reserved_pc_increase); - } +pub struct OODifference { + reserved_base_change: I80F48, + reserved_quote_change: I80F48, + free_base_change: I80F48, + free_quote_change: I80F48, } -pub struct VaultDifferenceResult { - base_raw_index: usize, - base_active: bool, - quote_raw_index: usize, - quote_active: bool, -} - -impl VaultDifferenceResult { - pub fn deactivate_inactive_token_accounts(&self, account: &mut MangoAccountRefMut) { - if !self.base_active { - account.deactivate_token_position(self.base_raw_index); - } - if !self.quote_active { - account.deactivate_token_position(self.quote_raw_index); +impl OODifference { + pub fn new(before_oo: &OpenOrdersSlim, after_oo: &OpenOrdersSlim) -> Self { + Self { + reserved_base_change: cm!(I80F48::from(after_oo.native_base_reserved()) + - I80F48::from(before_oo.native_base_reserved())), + reserved_quote_change: cm!(I80F48::from(after_oo.native_quote_reserved()) + - I80F48::from(before_oo.native_quote_reserved())), + free_base_change: cm!(I80F48::from(after_oo.native_base_free()) + - I80F48::from(before_oo.native_base_free())), + free_quote_change: cm!(I80F48::from(after_oo.native_quote_free_plus_rebates()) + - I80F48::from(before_oo.native_quote_free_plus_rebates())), } } + + pub fn adjust_health_cache( + &self, + health_cache: &mut HealthCache, + market: &Serum3Market, + ) -> Result<()> { + health_cache.adjust_serum3_reserved( + market.market_index, + market.base_token_index, + self.reserved_base_change, + self.free_base_change, + market.quote_token_index, + self.reserved_quote_change, + self.free_quote_change, + ) + } } +pub struct VaultDifference { + token_index: TokenIndex, + native_change: I80F48, +} + +impl VaultDifference { + pub fn adjust_health_cache(&self, health_cache: &mut HealthCache) -> Result<()> { + health_cache.adjust_token_balance(self.token_index, self.native_change)?; + Ok(()) + } +} + +/// Called in settle_funds, place_order, liq_force_cancel to adjust token positions after +/// changing the vault balances pub fn apply_vault_difference( account: &mut MangoAccountRefMut, - base_bank: &mut Bank, - after_base_vault: u64, - before_base_vault: u64, - quote_bank: &mut Bank, - after_quote_vault: u64, - before_quote_vault: u64, -) -> Result<(VaultDifferenceResult, I80F48, I80F48)> { - // TODO: Applying the loan origination fee here may be too early: it should only be - // charged if an order executes and the loan materializes? Otherwise MMs that place - // an order without having the funds will be charged for each place_order! + serum_market_index: Serum3MarketIndex, + bank: &mut Bank, + vault_after: u64, + vault_before: u64, +) -> Result { + let needed_change = cm!(I80F48::from(vault_after) - I80F48::from(vault_before)); - let (base_position, base_raw_index) = account.token_position_mut(base_bank.token_index)?; - let base_change = I80F48::from(after_base_vault) - I80F48::from(before_base_vault); - let (base_active, base_loan_origination_fee) = - base_bank.change_with_fee(base_position, base_change)?; + let (position, _) = account.token_position_mut(bank.token_index)?; + let native_before = position.native(bank); + bank.change_without_fee(position, needed_change)?; + let native_after = position.native(bank); + let native_change = cm!(native_after - native_before); + let new_borrows = native_change + .max(native_after) + .min(I80F48::ZERO) + .abs() + .to_num::(); - let (quote_position, quote_raw_index) = account.token_position_mut(quote_bank.token_index)?; - let quote_change = I80F48::from(after_quote_vault) - I80F48::from(before_quote_vault); - let (quote_active, quote_loan_origination_fee) = - quote_bank.change_with_fee(quote_position, quote_change)?; + let market = account.serum3_orders_mut(serum_market_index).unwrap(); + let borrows_without_fee = if bank.token_index == market.base_token_index { + &mut market.base_borrows_without_fee + } else if bank.token_index == market.quote_token_index { + &mut market.quote_borrows_without_fee + } else { + return Err(error_msg!( + "assert failed: apply_vault_difference called with bad token index" + )); + }; - Ok(( - VaultDifferenceResult { - base_raw_index, - base_active, - quote_raw_index, - quote_active, - }, - base_loan_origination_fee, - quote_loan_origination_fee, - )) + // Only for place: Add to potential borrow amount + let old_value = *borrows_without_fee; + *borrows_without_fee = cm!(old_value + new_borrows); + + // Only for settle/liq_force_cancel: Reduce the potential borrow amounts + if needed_change > 0 { + *borrows_without_fee = (*borrows_without_fee).saturating_sub(needed_change.to_num::()); + } + + Ok(VaultDifference { + token_index: bank.token_index, + native_change, + }) } fn cpi_place_order(ctx: &Serum3PlaceOrder, order: NewOrderInstructionV3) -> Result<()> { use crate::serum3_cpi; - let order_payer_token_account = match order.side { - Side::Bid => &ctx.quote_vault, - Side::Ask => &ctx.base_vault, - }; - let group = ctx.group.load()?; serum3_cpi::PlaceOrder { program: ctx.serum_program.to_account_info(), @@ -420,26 +448,8 @@ fn cpi_place_order(ctx: &Serum3PlaceOrder, order: NewOrderInstructionV3) -> Resu token_program: ctx.token_program.to_account_info(), open_orders: ctx.open_orders.to_account_info(), - order_payer_token_account: order_payer_token_account.to_account_info(), + order_payer_token_account: ctx.payer_vault.to_account_info(), user_authority: ctx.group.to_account_info(), } .call(&group, order) } - -fn cpi_settle_funds(ctx: &Serum3PlaceOrder) -> Result<()> { - use crate::serum3_cpi; - let group = ctx.group.load()?; - serum3_cpi::SettleFunds { - program: ctx.serum_program.to_account_info(), - market: ctx.serum_market_external.to_account_info(), - open_orders: ctx.open_orders.to_account_info(), - open_orders_authority: ctx.group.to_account_info(), - base_vault: ctx.market_base_vault.to_account_info(), - quote_vault: ctx.market_quote_vault.to_account_info(), - user_base_wallet: ctx.base_vault.to_account_info(), - user_quote_wallet: ctx.quote_vault.to_account_info(), - vault_signer: ctx.market_vault_signer.to_account_info(), - token_program: ctx.token_program.to_account_info(), - } - .call(&group) -} diff --git a/programs/mango-v4/src/instructions/serum3_register_market.rs b/programs/mango-v4/src/instructions/serum3_register_market.rs index a2d07b5d1..5dc86393f 100644 --- a/programs/mango-v4/src/instructions/serum3_register_market.rs +++ b/programs/mango-v4/src/instructions/serum3_register_market.rs @@ -6,6 +6,7 @@ use crate::state::*; use crate::util::fill_from_str; #[derive(Accounts)] +#[instruction(market_index: Serum3MarketIndex)] pub struct Serum3RegisterMarket<'info> { #[account( mut, @@ -15,7 +16,6 @@ pub struct Serum3RegisterMarket<'info> { pub group: AccountLoader<'info, Group>, pub admin: Signer<'info>, - // TODO: limit? /// CHECK: Can register a market for any serum program pub serum_program: UncheckedAccount<'info>, /// CHECK: Can register any serum market @@ -31,6 +31,17 @@ pub struct Serum3RegisterMarket<'info> { )] pub serum_market: AccountLoader<'info, Serum3Market>, + /// CHECK: Unused account + #[account( + init, + // block using the same market index twice + seeds = [b"Serum3Index".as_ref(), group.key().as_ref(), &market_index.to_le_bytes()], + bump, + payer = payer, + space = 1, + )] + pub index_reservation: UncheckedAccount<'info>, + #[account(has_one = group)] pub quote_bank: AccountLoader<'info, Bank>, #[account(has_one = group)] @@ -42,7 +53,6 @@ pub struct Serum3RegisterMarket<'info> { pub system_program: Program<'info, System>, } -// TODO: should this be "configure_serum_market", which allows reconfiguring? pub fn serum3_register_market( ctx: Context, market_index: Serum3MarketIndex, diff --git a/programs/mango-v4/src/instructions/serum3_settle_funds.rs b/programs/mango-v4/src/instructions/serum3_settle_funds.rs index 8b4d92d7a..6b87ac63b 100644 --- a/programs/mango-v4/src/instructions/serum3_settle_funds.rs +++ b/programs/mango-v4/src/instructions/serum3_settle_funds.rs @@ -1,5 +1,3 @@ -use std::borrow::BorrowMut; - use anchor_lang::prelude::*; use anchor_spl::token::{Token, TokenAccount}; @@ -9,19 +7,23 @@ use crate::error::*; use crate::serum3_cpi::load_open_orders_ref; use crate::state::*; -use super::{apply_vault_difference, OpenOrdersReserved, OpenOrdersSlim}; +use super::{apply_vault_difference, OpenOrdersAmounts, OpenOrdersSlim}; use crate::logs::{LoanOriginationFeeInstruction, WithdrawLoanOriginationFeeLog}; #[derive(Accounts)] pub struct Serum3SettleFunds<'info> { pub group: AccountLoader<'info, Group>, - #[account(mut, has_one = group)] + #[account( + mut, + has_one = group + // owner is checked at #1 + )] pub account: AccountLoaderDynamic<'info, MangoAccount>, pub owner: Signer<'info>, #[account(mut)] - /// CHECK: Validated inline by checking against the pubkey stored in the account + /// CHECK: Validated inline by checking against the pubkey stored in the account at #2 pub open_orders: UncheckedAccount<'info>, #[account( @@ -48,7 +50,7 @@ pub struct Serum3SettleFunds<'info> { /// CHECK: Validated by the serum cpi call pub market_vault_signer: UncheckedAccount<'info>, - // token_index and bank.vault == vault is validated inline + // token_index and bank.vault == vault is validated inline at #3 #[account(mut, has_one = group)] pub quote_bank: AccountLoader<'info, Bank>, #[account(mut)] @@ -74,22 +76,22 @@ pub fn serum3_settle_funds(ctx: Context) -> Result<()> { // { let account = ctx.accounts.account.load()?; + // account constraint #1 require!( account.fixed.is_owner_or_delegate(ctx.accounts.owner.key()), MangoError::SomeError ); - // Validate open_orders + // Validate open_orders #2 require!( account - .serum3_orders(serum_market.market_index) - .ok_or_else(|| error!(MangoError::SomeError))? + .serum3_orders(serum_market.market_index)? .open_orders == ctx.accounts.open_orders.key(), MangoError::SomeError ); - // Validate banks and vaults + // Validate banks and vaults #3 let quote_bank = ctx.accounts.quote_bank.load()?; require!( quote_bank.vault == ctx.accounts.quote_vault.key(), @@ -111,34 +113,35 @@ pub fn serum3_settle_funds(ctx: Context) -> Result<()> { } // - // Before-order tracking - // - - let before_base_vault = ctx.accounts.base_vault.amount; - let before_quote_vault = ctx.accounts.quote_vault.amount; - - // - // Settle + // Charge any open loan origination fees // { let open_orders = load_open_orders_ref(ctx.accounts.open_orders.as_ref())?; - cpi_settle_funds(ctx.accounts)?; - - let after_oo = OpenOrdersSlim::from_oo(&open_orders); + let before_oo = OpenOrdersSlim::from_oo(&open_orders); let mut account = ctx.accounts.account.load_mut()?; let mut base_bank = ctx.accounts.base_bank.load_mut()?; let mut quote_bank = ctx.accounts.quote_bank.load_mut()?; - charge_maybe_fees( + charge_loan_origination_fees( + &ctx.accounts.group.key(), + &ctx.accounts.account.key(), serum_market.market_index, &mut base_bank, &mut quote_bank, &mut account.borrow_mut(), - &after_oo, + &before_oo, )?; } // - // After-order tracking + // Settle + // + let before_base_vault = ctx.accounts.base_vault.amount; + let before_quote_vault = ctx.accounts.quote_vault.amount; + + cpi_settle_funds(ctx.accounts)?; + + // + // After-settle tracking // { ctx.accounts.base_vault.reload()?; @@ -146,102 +149,92 @@ pub fn serum3_settle_funds(ctx: Context) -> Result<()> { let after_base_vault = ctx.accounts.base_vault.amount; let after_quote_vault = ctx.accounts.quote_vault.amount; - // Charge the difference in vault balances to the user's account + // Settle cannot decrease vault balances + require_gte!(after_base_vault, before_base_vault); + require_gte!(after_quote_vault, before_quote_vault); + + // Credit the difference in vault balances to the user's account let mut account = ctx.accounts.account.load_mut()?; let mut base_bank = ctx.accounts.base_bank.load_mut()?; let mut quote_bank = ctx.accounts.quote_bank.load_mut()?; - let (vault_difference_result, base_loan_origination_fee, quote_loan_origination_fee) = - apply_vault_difference( - &mut account.borrow_mut(), - &mut base_bank, - after_base_vault, - before_base_vault, - &mut quote_bank, - after_quote_vault, - before_quote_vault, - )?; - vault_difference_result.deactivate_inactive_token_accounts(&mut account.borrow_mut()); - - if base_loan_origination_fee.is_positive() { - emit!(WithdrawLoanOriginationFeeLog { - mango_group: ctx.accounts.group.key(), - mango_account: ctx.accounts.account.key(), - token_index: serum_market.base_token_index, - loan_origination_fee: base_loan_origination_fee.to_bits(), - instruction: LoanOriginationFeeInstruction::Serum3SettleFunds - }); - } - if quote_loan_origination_fee.is_positive() { - emit!(WithdrawLoanOriginationFeeLog { - mango_group: ctx.accounts.group.key(), - mango_account: ctx.accounts.account.key(), - token_index: serum_market.quote_token_index, - loan_origination_fee: quote_loan_origination_fee.to_bits(), - instruction: LoanOriginationFeeInstruction::Serum3SettleFunds - }); - } + apply_vault_difference( + &mut account.borrow_mut(), + serum_market.market_index, + &mut base_bank, + after_base_vault, + before_base_vault, + )?; + apply_vault_difference( + &mut account.borrow_mut(), + serum_market.market_index, + &mut quote_bank, + after_quote_vault, + before_quote_vault, + )?; } Ok(()) } -// if reserved is less than cached, charge loan fee on the difference -pub fn charge_maybe_fees( +// Charge fees if the potential borrows are bigger than the funds on the open orders account +pub fn charge_loan_origination_fees( + group_pubkey: &Pubkey, + account_pubkey: &Pubkey, market_index: Serum3MarketIndex, - coin_bank: &mut Bank, - pc_bank: &mut Bank, + base_bank: &mut Bank, + quote_bank: &mut Bank, account: &mut MangoAccountRefMut, - after_oo: &OpenOrdersSlim, + before_oo: &OpenOrdersSlim, ) -> Result<()> { let serum3_account = account.serum3_orders_mut(market_index).unwrap(); - let maybe_actualized_coin_loan = I80F48::from_num::( + let oo_base_total = before_oo.native_base_total(); + let actualized_base_loan = I80F48::from_num( serum3_account - .previous_native_coin_reserved - .saturating_sub(after_oo.native_coin_reserved()), + .base_borrows_without_fee + .saturating_sub(oo_base_total), ); + if actualized_base_loan > 0 { + serum3_account.base_borrows_without_fee = oo_base_total; - if maybe_actualized_coin_loan > 0 { - serum3_account.previous_native_coin_reserved = after_oo.native_coin_reserved(); + // now that the loan is actually materialized, charge the loan origination fee + // note: the withdraw has already happened while placing the order + let base_token_account = account.token_position_mut(base_bank.token_index)?.0; + let (_, fee) = + base_bank.withdraw_loan_origination_fee(base_token_account, actualized_base_loan)?; - // loan origination fees - let coin_token_account = account.token_position_mut(coin_bank.token_index)?.0; - let coin_token_native = coin_token_account.native(coin_bank); - - if coin_token_native.is_negative() { - let actualized_loan = coin_token_native.abs().min(maybe_actualized_coin_loan); - // note: the withdraw has already happened while placing the order - // now that the loan is actually materialized (since the fill having taken place) - // charge the loan origination fee - coin_bank - .borrow_mut() - .withdraw_loan_origination_fee(coin_token_account, actualized_loan)?; - } + emit!(WithdrawLoanOriginationFeeLog { + mango_group: *group_pubkey, + mango_account: *account_pubkey, + token_index: base_bank.token_index, + loan_origination_fee: fee.to_bits(), + instruction: LoanOriginationFeeInstruction::Serum3SettleFunds, + }); } let serum3_account = account.serum3_orders_mut(market_index).unwrap(); - let maybe_actualized_pc_loan = I80F48::from_num::( + let oo_quote_total = before_oo.native_quote_total_plus_rebates(); + let actualized_quote_loan = I80F48::from_num::( serum3_account - .previous_native_pc_reserved - .saturating_sub(after_oo.native_pc_reserved()), + .quote_borrows_without_fee + .saturating_sub(oo_quote_total), ); + if actualized_quote_loan > 0 { + serum3_account.quote_borrows_without_fee = oo_quote_total; - if maybe_actualized_pc_loan > 0 { - serum3_account.previous_native_pc_reserved = after_oo.native_pc_reserved(); + // now that the loan is actually materialized, charge the loan origination fee + // note: the withdraw has already happened while placing the order + let quote_token_account = account.token_position_mut(quote_bank.token_index)?.0; + let (_, fee) = + quote_bank.withdraw_loan_origination_fee(quote_token_account, actualized_quote_loan)?; - // loan origination fees - let pc_token_account = account.token_position_mut(pc_bank.token_index)?.0; - let pc_token_native = pc_token_account.native(pc_bank); - - if pc_token_native.is_negative() { - let actualized_loan = pc_token_native.abs().min(maybe_actualized_pc_loan); - // note: the withdraw has already happened while placing the order - // now that the loan is actually materialized (since the fill having taken place) - // charge the loan origination fee - pc_bank - .borrow_mut() - .withdraw_loan_origination_fee(pc_token_account, actualized_loan)?; - } + emit!(WithdrawLoanOriginationFeeLog { + mango_group: *group_pubkey, + mango_account: *account_pubkey, + token_index: quote_bank.token_index, + loan_origination_fee: fee.to_bits(), + instruction: LoanOriginationFeeInstruction::Serum3SettleFunds, + }); } Ok(()) diff --git a/programs/mango-v4/src/instructions/token_deposit.rs b/programs/mango-v4/src/instructions/token_deposit.rs index 1cae507f9..05a2d9e51 100644 --- a/programs/mango-v4/src/instructions/token_deposit.rs +++ b/programs/mango-v4/src/instructions/token_deposit.rs @@ -4,6 +4,7 @@ use anchor_spl::token::Token; use anchor_spl::token::TokenAccount; use fixed::types::I80F48; +use crate::accounts_zerocopy::AccountInfoRef; use crate::error::*; use crate::state::*; use crate::util::checked_math as cm; @@ -21,6 +22,7 @@ pub struct TokenDeposit<'info> { mut, has_one = group, has_one = vault, + has_one = oracle, // the mints of bank/vault/token_account are implicitly the same because // spl::token::transfer succeeds between token_account and vault )] @@ -29,6 +31,9 @@ pub struct TokenDeposit<'info> { #[account(mut)] pub vault: Account<'info, TokenAccount>, + /// CHECK: The oracle can be one of several different account types + pub oracle: UncheckedAccount<'info>, + #[account(mut)] pub token_account: Box>, pub token_authority: Signer<'info>, @@ -56,7 +61,7 @@ pub fn token_deposit(ctx: Context, amount: u64) -> Result<()> { // Get the account's position for that token index let mut account = ctx.accounts.account.load_mut()?; - let (position, raw_token_index, active_token_index) = + let (position, raw_token_index, _active_token_index) = account.ensure_token_position(token_index)?; let amount_i80f48 = I80F48::from(amount); @@ -69,10 +74,8 @@ pub fn token_deposit(ctx: Context, amount: u64) -> Result<()> { token::transfer(ctx.accounts.transfer_ctx(), amount)?; let indexed_position = position.indexed_position; - - let retriever = new_fixed_order_account_retriever(ctx.remaining_accounts, &account.borrow())?; - let (bank, oracle_price) = - retriever.bank_and_oracle(&ctx.accounts.group.key(), active_token_index, token_index)?; + let bank = ctx.accounts.bank.load()?; + let oracle_price = bank.oracle_price(&AccountInfoRef::borrow(ctx.accounts.oracle.as_ref())?)?; // Update the net deposits - adjust by price so different tokens are on the same basis (in USD terms) let amount_usd = cm!(amount_i80f48 * oracle_price).to_num::(); @@ -91,10 +94,17 @@ pub fn token_deposit(ctx: Context, amount: u64) -> Result<()> { // // Health computation // - let health = compute_health(&account.borrow(), HealthType::Init, &retriever) - .context("post-deposit init health")?; - msg!("health: {}", health); - account.fixed.maybe_recover_from_being_liquidated(health); + // Since depositing can only increase health, we can skip the usual pre-health computation. + // Also, TokenDeposit is one of the rare instructions that is allowed even during being_liquidated. + // + if !account.fixed.is_in_health_region() { + let retriever = + new_fixed_order_account_retriever(ctx.remaining_accounts, &account.borrow())?; + let health = compute_health(&account.borrow(), HealthType::Init, &retriever) + .context("post-deposit init health")?; + msg!("health: {}", health); + account.fixed.maybe_recover_from_being_liquidated(health); + } // // Deactivate the position only after the health check because the user passed in diff --git a/programs/mango-v4/src/instructions/token_deregister.rs b/programs/mango-v4/src/instructions/token_deregister.rs index d5df31373..687bc39f6 100644 --- a/programs/mango-v4/src/instructions/token_deregister.rs +++ b/programs/mango-v4/src/instructions/token_deregister.rs @@ -4,8 +4,9 @@ use anchor_spl::token::{self, CloseAccount, Token, TokenAccount}; use crate::{accounts_zerocopy::LoadZeroCopyRef, state::*}; use anchor_lang::AccountsClose; +/// In addition to these accounts, there must be remaining_accounts: +/// all n pairs of bank and its corresponding vault account for a token #[derive(Accounts)] -#[instruction(token_index: TokenIndex)] pub struct TokenDeregister<'info> { #[account( constraint = group.load()?.is_testing(), @@ -18,7 +19,6 @@ pub struct TokenDeregister<'info> { #[account( mut, has_one = group, - constraint = mint_info.load()?.token_index == token_index, close = sol_destination )] pub mint_info: AccountLoader<'info, MintInfo>, @@ -36,7 +36,6 @@ pub struct TokenDeregister<'info> { #[allow(clippy::too_many_arguments)] pub fn token_deregister<'key, 'accounts, 'remaining, 'info>( ctx: Context<'key, 'accounts, 'remaining, 'info, TokenDeregister<'info>>, - token_index: TokenIndex, ) -> Result<()> { let mint_info = ctx.accounts.mint_info.load()?; { @@ -61,7 +60,7 @@ pub fn token_deregister<'key, 'accounts, 'remaining, 'info>( { let bank = bank_ai.load::()?; require_keys_eq!(bank.group, ctx.accounts.group.key()); - require_eq!(bank.token_index, token_index); + require_eq!(bank.token_index, mint_info.token_index); require_keys_eq!(bank.vault, vault_ai.key()); } diff --git a/programs/mango-v4/src/instructions/token_update_index_and_rate.rs b/programs/mango-v4/src/instructions/token_update_index_and_rate.rs index 0a24adb2a..adc274cf5 100644 --- a/programs/mango-v4/src/instructions/token_update_index_and_rate.rs +++ b/programs/mango-v4/src/instructions/token_update_index_and_rate.rs @@ -5,7 +5,7 @@ use crate::logs::{UpdateIndexLog, UpdateRateLog}; use crate::state::HOUR; use crate::{ accounts_zerocopy::{AccountInfoRef, LoadMutZeroCopyRef, LoadZeroCopyRef}, - state::{oracle_price, Bank, Group, MintInfo}, + state::{Bank, Group, MintInfo}, }; use anchor_lang::solana_program::sysvar::instructions as tx_instructions; use anchor_lang::Discriminator; @@ -106,11 +106,8 @@ pub fn token_update_index_and_rate(ctx: Context) -> Res now_ts, ); - let price = oracle_price( - &AccountInfoRef::borrow(ctx.accounts.oracle.as_ref())?, - some_bank.oracle_config.conf_filter, - some_bank.mint_decimals, - )?; + let price = + some_bank.oracle_price(&AccountInfoRef::borrow(ctx.accounts.oracle.as_ref())?)?; emit!(UpdateIndexLog { mango_group: mint_info.group.key(), token_index: mint_info.token_index, diff --git a/programs/mango-v4/src/instructions/token_withdraw.rs b/programs/mango-v4/src/instructions/token_withdraw.rs index 2b0680826..d5e946ddd 100644 --- a/programs/mango-v4/src/instructions/token_withdraw.rs +++ b/programs/mango-v4/src/instructions/token_withdraw.rs @@ -1,3 +1,4 @@ +use crate::accounts_zerocopy::*; use crate::error::*; use crate::state::*; use anchor_lang::prelude::*; @@ -9,7 +10,6 @@ use fixed::types::I80F48; use crate::logs::{ LoanOriginationFeeInstruction, TokenBalanceLog, WithdrawLoanOriginationFeeLog, WithdrawLog, }; -use crate::state::new_fixed_order_account_retriever; use crate::util::checked_math as cm; #[derive(Accounts)] @@ -24,6 +24,7 @@ pub struct TokenWithdraw<'info> { mut, has_one = group, has_one = vault, + has_one = oracle, // the mints of bank/vault/token_account are implicitly the same because // spl::token::transfer succeeds between token_account and vault )] @@ -32,6 +33,9 @@ pub struct TokenWithdraw<'info> { #[account(mut)] pub vault: Account<'info, TokenAccount>, + /// CHECK: The oracle can be one of several different account types + pub oracle: UncheckedAccount<'info>, + #[account(mut)] pub token_account: Box>, @@ -56,90 +60,92 @@ pub fn token_withdraw(ctx: Context, amount: u64, allow_borrow: bo let group = ctx.accounts.group.load()?; let token_index = ctx.accounts.bank.load()?.token_index; - // Get the account's position for that token index + // Create the account's position for that token index let mut account = ctx.accounts.account.load_mut()?; + let (_, raw_token_index, _) = account.ensure_token_position(token_index)?; - let (position, raw_token_index, active_token_index) = - account.ensure_token_position(token_index)?; - - // The bank will also be passed in remainingAccounts. Use an explicit scope - // to drop the &mut before we borrow it immutably again later. - let (position_is_active, amount_i80f48, loan_origination_fee) = { - let mut bank = ctx.accounts.bank.load_mut()?; - let native_position = position.native(&bank); - - // Handle amount special case for withdrawing everything - let amount = if amount == u64::MAX && !allow_borrow { - if native_position.is_positive() { - // TODO: This rounding may mean that if we deposit and immediately withdraw - // we can't withdraw the full amount! - native_position.floor().to_num::() - } else { - return Ok(()); - } - } else { - amount - }; - - require!( - allow_borrow || amount <= native_position, - MangoError::SomeError - ); - - let amount_i80f48 = I80F48::from(amount); - - // Update the bank and position - let (position_is_active, loan_origination_fee) = - bank.withdraw_with_fee(position, amount_i80f48)?; - - // Provide a readable error message in case the vault doesn't have enough tokens - if ctx.accounts.vault.amount < amount { - return err!(MangoError::InsufficentBankVaultFunds).with_context(|| { - format!( - "bank vault does not have enough tokens, need {} but have {}", - amount, ctx.accounts.vault.amount - ) - }); - } - - // Transfer the actual tokens - let group_seeds = group_seeds!(group); - token::transfer( - ctx.accounts.transfer_ctx().with_signer(&[group_seeds]), - amount, - )?; - - (position_is_active, amount_i80f48, loan_origination_fee) + // Health check _after_ the token position is guaranteed to exist + 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_health = account.check_health_pre(&health_cache)?; + Some((health_cache, pre_health)) + } else { + None }; - let indexed_position = position.indexed_position; + let mut bank = ctx.accounts.bank.load_mut()?; + let position = account.token_position_mut_by_raw_index(raw_token_index); + let native_position = position.native(&bank); - let retriever = new_fixed_order_account_retriever(ctx.remaining_accounts, &account.borrow())?; - let (bank, oracle_price) = - retriever.bank_and_oracle(&ctx.accounts.group.key(), active_token_index, token_index)?; + // Handle amount special case for withdrawing everything + let amount = if amount == u64::MAX && !allow_borrow { + if native_position.is_positive() { + // TODO: This rounding may mean that if we deposit and immediately withdraw + // we can't withdraw the full amount! + native_position.floor().to_num::() + } else { + return Ok(()); + } + } else { + amount + }; - // Update the net deposits - adjust by price so different tokens are on the same basis (in USD terms) - let amount_usd = cm!(amount_i80f48 * oracle_price).to_num::(); - account.fixed.net_deposits = cm!(account.fixed.net_deposits - amount_usd); + require!( + allow_borrow || amount <= native_position, + MangoError::SomeError + ); + + let amount_i80f48 = I80F48::from(amount); + + // Update the bank and position + let (position_is_active, loan_origination_fee) = + bank.withdraw_with_fee(position, amount_i80f48)?; + + // Provide a readable error message in case the vault doesn't have enough tokens + if ctx.accounts.vault.amount < amount { + return err!(MangoError::InsufficentBankVaultFunds).with_context(|| { + format!( + "bank vault does not have enough tokens, need {} but have {}", + amount, ctx.accounts.vault.amount + ) + }); + } + + // Transfer the actual tokens + let group_seeds = group_seeds!(group); + token::transfer( + ctx.accounts.transfer_ctx().with_signer(&[group_seeds]), + amount, + )?; + + let native_position_after = position.native(&bank); + let oracle_price = bank.oracle_price(&AccountInfoRef::borrow(ctx.accounts.oracle.as_ref())?)?; emit!(TokenBalanceLog { mango_group: ctx.accounts.group.key(), mango_account: ctx.accounts.account.key(), token_index, - indexed_position: indexed_position.to_bits(), + indexed_position: position.indexed_position.to_bits(), deposit_index: bank.deposit_index.to_bits(), borrow_index: bank.borrow_index.to_bits(), price: oracle_price.to_bits(), }); + // Update the net deposits - adjust by price so different tokens are on the same basis (in USD terms) + let amount_usd = cm!(amount_i80f48 * oracle_price).to_num::(); + account.fixed.net_deposits = cm!(account.fixed.net_deposits - amount_usd); + // // Health check // - let health = compute_health(&account.borrow(), HealthType::Init, &retriever) - .context("post-withdraw init health")?; - msg!("health: {}", health); - require!(health >= 0, MangoError::HealthMustBePositive); - account.fixed.maybe_recover_from_being_liquidated(health); + if let Some((mut health_cache, pre_health)) = pre_health_opt { + health_cache + .adjust_token_balance(token_index, cm!(native_position_after - native_position))?; + account.check_health_post(&health_cache, pre_health)?; + } // // Deactivate the position only after the health check because the user passed in diff --git a/programs/mango-v4/src/lib.rs b/programs/mango-v4/src/lib.rs index 347069be5..f5c95c773 100644 --- a/programs/mango-v4/src/lib.rs +++ b/programs/mango-v4/src/lib.rs @@ -142,9 +142,8 @@ pub mod mango_v4 { pub fn token_deregister<'key, 'accounts, 'remaining, 'info>( ctx: Context<'key, 'accounts, 'remaining, 'info, TokenDeregister<'info>>, - token_index: TokenIndex, ) -> Result<()> { - instructions::token_deregister(ctx, token_index) + instructions::token_deregister(ctx) } pub fn token_update_index_and_rate(ctx: Context) -> Result<()> { @@ -236,6 +235,18 @@ pub mod mango_v4 { instructions::flash_loan_end(ctx, flash_loan_type) } + pub fn health_region_begin<'key, 'accounts, 'remaining, 'info>( + ctx: Context<'key, 'accounts, 'remaining, 'info, HealthRegionBegin<'info>>, + ) -> Result<()> { + instructions::health_region_begin(ctx) + } + + pub fn health_region_end<'key, 'accounts, 'remaining, 'info>( + ctx: Context<'key, 'accounts, 'remaining, 'info, HealthRegionEnd<'info>>, + ) -> Result<()> { + instructions::health_region_end(ctx) + } + /// /// Serum /// diff --git a/programs/mango-v4/src/state/bank.rs b/programs/mango-v4/src/state/bank.rs index 51caf833f..d7fbad109 100644 --- a/programs/mango-v4/src/state/bank.rs +++ b/programs/mango-v4/src/state/bank.rs @@ -1,4 +1,6 @@ use super::{OracleConfig, TokenIndex, TokenPosition}; +use crate::accounts_zerocopy::KeyedAccountReader; +use crate::state::oracle; use crate::util; use crate::util::checked_math as cm; use anchor_lang::prelude::*; @@ -274,7 +276,7 @@ impl Bank { let (position_is_active, _) = self.withdraw_internal(position, native_amount, false, !position.is_in_use())?; - return Ok(position_is_active); + Ok(position_is_active) } /// Like `withdraw_without_fee()` but allows dusting of in-use token accounts. @@ -285,9 +287,8 @@ impl Bank { position: &mut TokenPosition, native_amount: I80F48, ) -> Result { - Ok(self - .withdraw_internal(position, native_amount, false, true) - .map(|(not_dusted, _)| not_dusted || position.is_in_use())?) + self.withdraw_internal(position, native_amount, false, true) + .map(|(not_dusted, _)| not_dusted || position.is_in_use()) } /// Withdraws `native_amount` while applying the loan origination fee if a borrow is created. @@ -545,6 +546,15 @@ impl Bank { (self.rate0, self.rate1, self.max_rate) } } + + pub fn oracle_price(&self, oracle_acc: &impl KeyedAccountReader) -> Result { + require_keys_eq!(self.oracle, *oracle_acc.key()); + oracle::oracle_price( + oracle_acc, + self.oracle_config.conf_filter, + self.mint_decimals, + ) + } } #[macro_export] diff --git a/programs/mango-v4/src/state/group.rs b/programs/mango-v4/src/state/group.rs index b24e92648..8aaabbcf8 100644 --- a/programs/mango-v4/src/state/group.rs +++ b/programs/mango-v4/src/state/group.rs @@ -44,7 +44,7 @@ impl Group { } pub fn multiple_banks_supported(&self) -> bool { - self.is_testing() || self.version > 0 + self.is_testing() || self.version > 1 } pub fn serum3_supported(&self) -> bool { @@ -52,7 +52,7 @@ impl Group { } pub fn perps_supported(&self) -> bool { - self.is_testing() || self.version > 0 + self.is_testing() || self.version > 1 } } diff --git a/programs/mango-v4/src/state/health.rs b/programs/mango-v4/src/state/health.rs index 6dda44174..ef03cdb82 100644 --- a/programs/mango-v4/src/state/health.rs +++ b/programs/mango-v4/src/state/health.rs @@ -11,7 +11,10 @@ use std::collections::HashMap; use crate::accounts_zerocopy::*; use crate::error::*; use crate::serum3_cpi; -use crate::state::{oracle_price, Bank, PerpMarket, PerpMarketIndex, TokenIndex}; +use crate::state::{ + Bank, MangoAccountFixed, PerpMarket, PerpMarketIndex, PerpPosition, Serum3MarketIndex, + TokenIndex, +}; use crate::util::checked_math as cm; use super::MangoAccountRef; @@ -89,8 +92,7 @@ impl FixedOrderAccountRetriever { fn oracle_price(&self, account_index: usize, bank: &Bank) -> Result { let oracle = &self.ais[cm!(self.n_banks + account_index)]; - require_keys_eq!(bank.oracle, *oracle.key()); - oracle_price(oracle, bank.oracle_config.conf_filter, bank.mint_decimals) + bank.oracle_price(oracle) } } @@ -275,8 +277,7 @@ impl<'a, 'info> ScanningAccountRetriever<'a, 'info> { let index = self.bank_index(token_index1)?; let bank = self.banks[index].load_mut_fully_unchecked::()?; let oracle = &self.oracles[index]; - require_keys_eq!(bank.oracle, *oracle.key); - let price = oracle_price(oracle, bank.oracle_config.conf_filter, bank.mint_decimals)?; + let price = bank.oracle_price(oracle)?; return Ok((bank, price, None)); } let index1 = self.bank_index(token_index1)?; @@ -294,12 +295,8 @@ impl<'a, 'info> ScanningAccountRetriever<'a, 'info> { let bank2 = second_bank_part[second - (first + 1)].load_mut_fully_unchecked::()?; let oracle1 = &self.oracles[first]; let oracle2 = &self.oracles[second]; - require_keys_eq!(bank1.oracle, *oracle1.key); - require_keys_eq!(bank2.oracle, *oracle2.key); - let mint_decimals1 = bank1.mint_decimals; - let mint_decimals2 = bank2.mint_decimals; - let price1 = oracle_price(oracle1, bank1.oracle_config.conf_filter, mint_decimals1)?; - let price2 = oracle_price(oracle2, bank2.oracle_config.conf_filter, mint_decimals2)?; + let price1 = bank1.oracle_price(oracle1)?; + let price2 = bank2.oracle_price(oracle2)?; if swap { Ok((bank2, price2, Some((bank1, price1)))) } else { @@ -311,11 +308,7 @@ impl<'a, 'info> ScanningAccountRetriever<'a, 'info> { let index = self.bank_index(token_index)?; let bank = self.banks[index].load_fully_unchecked::()?; let oracle = &self.oracles[index]; - require_keys_eq!(bank.oracle, *oracle.key); - Ok(( - bank, - oracle_price(oracle, bank.oracle_config.conf_filter, bank.mint_decimals)?, - )) + Ok((bank, bank.oracle_price(oracle)?)) } pub fn scanned_perp_market(&self, perp_market_index: PerpMarketIndex) -> Result<&PerpMarket> { @@ -437,6 +430,7 @@ pub struct Serum3Info { reserved: I80F48, base_index: usize, quote_index: usize, + market_index: Serum3MarketIndex, } impl Serum3Info { @@ -480,6 +474,7 @@ impl Serum3Info { #[derive(Clone, AnchorDeserialize, AnchorSerialize)] pub struct PerpInfo { + perp_market_index: PerpMarketIndex, maint_asset_weight: I80F48, init_asset_weight: I80F48, maint_liab_weight: I80F48, @@ -491,6 +486,93 @@ pub struct PerpInfo { } impl PerpInfo { + fn new( + perp_position: &PerpPosition, + perp_market: &PerpMarket, + token_infos: &[TokenInfo], + ) -> Result { + // find the TokenInfos for the market's base and quote tokens + let base_index = find_token_info_index(token_infos, perp_market.base_token_index)?; + // TODO: base_index could be unset + let base_info = &token_infos[base_index]; + + let base_lot_size = I80F48::from(perp_market.base_lot_size); + + let base_lots = cm!(perp_position.base_position_lots + perp_position.taker_base_lots); + let taker_quote = I80F48::from(cm!( + perp_position.taker_quote_lots * perp_market.quote_lot_size + )); + let quote_current = cm!(perp_position.quote_position_native + taker_quote); + + // Two scenarios: + // 1. The price goes low and all bids execute, converting to base. + // That means the perp position is increased by `bids` and the quote position + // is decreased by `bids * base_lot_size * price`. + // The health for this case is: + // (weighted(base_lots + bids) - bids) * base_lot_size * price + quote + // 2. The price goes high and all asks execute, converting to quote. + // The health for this case is: + // (weighted(base_lots - asks) + asks) * base_lot_size * price + quote + // + // Comparing these makes it clear we need to pick the worse subfactor + // weighted(base_lots + bids) - bids =: scenario1 + // or + // weighted(base_lots - asks) + asks =: scenario2 + // + // Additionally, we want this scenario choice to be the same no matter whether we're + // computing init or maint health. This can be guaranteed by requiring the weights + // to satisfy the property (P): + // + // (1 - init_asset_weight) / (init_liab_weight - 1) + // == (1 - maint_asset_weight) / (maint_liab_weight - 1) + // + // Derivation: + // Set asks_net_lots := base_lots - asks, bids_net_lots := base_lots + bids. + // Now + // scenario1 = weighted(bids_net_lots) - bids_net_lots + base_lots and + // scenario2 = weighted(asks_net_lots) - asks_net_lots + base_lots + // So with expanding weigthed(a) = weight_factor_for_a * a, the question + // scenario1 < scenario2 + // becomes: + // (weight_factor_for_bids_net_lots - 1) * bids_net_lots + // < (weight_factor_for_asks_net_lots - 1) * asks_net_lots + // Since asks_net_lots < 0 and bids_net_lots > 0 is the only interesting case, (P) follows. + // + // We satisfy (P) by requiring + // asset_weight = 1 - x and liab_weight = 1 + x + // + // And with that assumption the scenario choice condition further simplifies to: + // scenario1 < scenario2 + // iff abs(bids_net_lots) > abs(asks_net_lots) + let bids_net_lots = cm!(base_lots + perp_position.bids_base_lots); + let asks_net_lots = cm!(base_lots - perp_position.asks_base_lots); + + let lots_to_quote = base_lot_size * base_info.oracle_price; + let base; + let quote; + if cm!(bids_net_lots.abs()) > cm!(asks_net_lots.abs()) { + let bids_net_lots = I80F48::from(bids_net_lots); + let bids_base_lots = I80F48::from(perp_position.bids_base_lots); + base = cm!(bids_net_lots * lots_to_quote); + quote = cm!(quote_current - bids_base_lots * lots_to_quote); + } else { + let asks_net_lots = I80F48::from(asks_net_lots); + let asks_base_lots = I80F48::from(perp_position.asks_base_lots); + base = cm!(asks_net_lots * lots_to_quote); + quote = cm!(quote_current + asks_base_lots * lots_to_quote); + }; + + Ok(Self { + perp_market_index: perp_market.perp_market_index, + init_asset_weight: perp_market.init_asset_weight, + init_liab_weight: perp_market.init_liab_weight, + maint_asset_weight: perp_market.maint_asset_weight, + maint_liab_weight: perp_market.maint_liab_weight, + base, + quote, + }) + } + /// Total health contribution from perp balances /// /// Due to isolation of perp markets, users may never borrow against perp @@ -536,16 +618,107 @@ impl HealthCache { health } + pub fn check_health_pre(&self, account: &mut MangoAccountFixed) -> Result { + let pre_health = self.health(HealthType::Init); + msg!("pre_health: {}", pre_health); + account.maybe_recover_from_being_liquidated(pre_health); + require!(!account.being_liquidated(), MangoError::BeingLiquidated); + Ok(pre_health) + } + + pub fn check_health_post( + &self, + account: &mut MangoAccountFixed, + pre_health: I80F48, + ) -> Result<()> { + let post_health = self.health(HealthType::Init); + msg!("post_health: {}", post_health); + require!( + post_health >= 0 || post_health > pre_health, + MangoError::HealthMustBePositiveOrIncrease + ); + account.maybe_recover_from_being_liquidated(post_health); + Ok(()) + } + + fn token_entry_index(&mut self, token_index: TokenIndex) -> Result { + self.token_infos + .iter() + .position(|t| t.token_index == token_index) + .ok_or_else(|| error_msg!("token index {} not found", token_index)) + } + pub fn adjust_token_balance(&mut self, token_index: TokenIndex, change: I80F48) -> Result<()> { - let mut entry = self - .token_infos - .iter_mut() - .find(|t| t.token_index == token_index) - .ok_or_else(|| error_msg!("token index {} not found", token_index))?; + let entry_index = self.token_entry_index(token_index)?; + let mut entry = &mut self.token_infos[entry_index]; entry.balance = cm!(entry.balance + change * entry.oracle_price); Ok(()) } + pub fn adjust_serum3_reserved( + &mut self, + market_index: Serum3MarketIndex, + base_token_index: TokenIndex, + reserved_base_change: I80F48, + free_base_change: I80F48, + quote_token_index: TokenIndex, + reserved_quote_change: I80F48, + free_quote_change: I80F48, + ) -> Result<()> { + let base_entry_index = self.token_entry_index(base_token_index)?; + let quote_entry_index = self.token_entry_index(quote_token_index)?; + + // Compute the total reserved amount change in health reference units + let mut reserved_amount; + { + let base_entry = &mut self.token_infos[base_entry_index]; + reserved_amount = cm!(reserved_base_change * base_entry.oracle_price); + } + { + let quote_entry = &mut self.token_infos[quote_entry_index]; + reserved_amount = + cm!(reserved_amount + reserved_quote_change * quote_entry.oracle_price); + } + + // Apply it to the tokens + { + let base_entry = &mut self.token_infos[base_entry_index]; + base_entry.serum3_max_reserved = cm!(base_entry.serum3_max_reserved + reserved_amount); + base_entry.balance = + cm!(base_entry.balance + free_base_change * base_entry.oracle_price); + } + { + let quote_entry = &mut self.token_infos[quote_entry_index]; + quote_entry.serum3_max_reserved = + cm!(quote_entry.serum3_max_reserved + reserved_amount); + quote_entry.balance = + cm!(quote_entry.balance + free_quote_change * quote_entry.oracle_price); + } + + // Apply it to the serum3 info + let market_entry = self + .serum3_infos + .iter_mut() + .find(|m| m.market_index == market_index) + .ok_or_else(|| error_msg!("serum3 market {} not found", market_index))?; + market_entry.reserved = cm!(market_entry.reserved + reserved_amount); + Ok(()) + } + + pub fn recompute_perp_info( + &mut self, + perp_position: &PerpPosition, + perp_market: &PerpMarket, + ) -> Result<()> { + let perp_entry = self + .perp_infos + .iter_mut() + .find(|m| m.perp_market_index == perp_market.perp_market_index) + .ok_or_else(|| error_msg!("perp market {} not found", perp_market.perp_market_index))?; + *perp_entry = PerpInfo::new(perp_position, perp_market, &self.token_infos)?; + Ok(()) + } + pub fn has_liquidatable_assets(&self) -> bool { let spot_liquidatable = self .token_infos @@ -842,95 +1015,17 @@ pub fn new_health_cache( reserved: reserved_balance, base_index, quote_index, + market_index: serum_account.market_index, }); } // TODO: also account for perp funding in health // health contribution from perp accounts let mut perp_infos = Vec::with_capacity(account.active_perp_positions().count()); - for (i, perp_account) in account.active_perp_positions().enumerate() { + for (i, perp_position) in account.active_perp_positions().enumerate() { let perp_market = - retriever.perp_market(&account.fixed.group, i, perp_account.market_index)?; - - // find the TokenInfos for the market's base and quote tokens - let base_index = find_token_info_index(&token_infos, perp_market.base_token_index)?; - // TODO: base_index could be unset - let base_info = &token_infos[base_index]; - - let base_lot_size = I80F48::from(perp_market.base_lot_size); - - let base_lots = cm!(perp_account.base_position_lots + perp_account.taker_base_lots); - let taker_quote = I80F48::from(cm!( - perp_account.taker_quote_lots * perp_market.quote_lot_size - )); - let quote_current = cm!(perp_account.quote_position_native + taker_quote); - - // Two scenarios: - // 1. The price goes low and all bids execute, converting to base. - // That means the perp position is increased by `bids` and the quote position - // is decreased by `bids * base_lot_size * price`. - // The health for this case is: - // (weighted(base_lots + bids) - bids) * base_lot_size * price + quote - // 2. The price goes high and all asks execute, converting to quote. - // The health for this case is: - // (weighted(base_lots - asks) + asks) * base_lot_size * price + quote - // - // Comparing these makes it clear we need to pick the worse subfactor - // weighted(base_lots + bids) - bids =: scenario1 - // or - // weighted(base_lots - asks) + asks =: scenario2 - // - // Additionally, we want this scenario choice to be the same no matter whether we're - // computing init or maint health. This can be guaranteed by requiring the weights - // to satisfy the property (P): - // - // (1 - init_asset_weight) / (init_liab_weight - 1) - // == (1 - maint_asset_weight) / (maint_liab_weight - 1) - // - // Derivation: - // Set asks_net_lots := base_lots - asks, bids_net_lots := base_lots + bids. - // Now - // scenario1 = weighted(bids_net_lots) - bids_net_lots + base_lots and - // scenario2 = weighted(asks_net_lots) - asks_net_lots + base_lots - // So with expanding weigthed(a) = weight_factor_for_a * a, the question - // scenario1 < scenario2 - // becomes: - // (weight_factor_for_bids_net_lots - 1) * bids_net_lots - // < (weight_factor_for_asks_net_lots - 1) * asks_net_lots - // Since asks_net_lots < 0 and bids_net_lots > 0 is the only interesting case, (P) follows. - // - // We satisfy (P) by requiring - // asset_weight = 1 - x and liab_weight = 1 + x - // - // And with that assumption the scenario choice condition further simplifies to: - // scenario1 < scenario2 - // iff abs(bids_net_lots) > abs(asks_net_lots) - let bids_net_lots = cm!(base_lots + perp_account.bids_base_lots); - let asks_net_lots = cm!(base_lots - perp_account.asks_base_lots); - - let lots_to_quote = base_lot_size * base_info.oracle_price; - let base; - let quote; - if cm!(bids_net_lots.abs()) > cm!(asks_net_lots.abs()) { - let bids_net_lots = I80F48::from(bids_net_lots); - let bids_base_lots = I80F48::from(perp_account.bids_base_lots); - base = cm!(bids_net_lots * lots_to_quote); - quote = cm!(quote_current - bids_base_lots * lots_to_quote); - } else { - let asks_net_lots = I80F48::from(asks_net_lots); - let asks_base_lots = I80F48::from(perp_account.asks_base_lots); - base = cm!(asks_net_lots * lots_to_quote); - quote = cm!(quote_current + asks_base_lots * lots_to_quote); - }; - - perp_infos.push(PerpInfo { - init_asset_weight: perp_market.init_asset_weight, - init_liab_weight: perp_market.init_liab_weight, - maint_asset_weight: perp_market.maint_asset_weight, - maint_liab_weight: perp_market.maint_liab_weight, - base, - quote, - }); + retriever.perp_market(&account.fixed.group, i, perp_position.market_index)?; + perp_infos.push(PerpInfo::new(perp_position, perp_market, &token_infos)?); } Ok(HealthCache { @@ -1064,7 +1159,7 @@ mod tests { // Run a health test that includes all the side values (like referrer_rebates_accrued) #[test] fn test_health0() { - let buffer = MangoAccount::default().try_to_vec().unwrap(); + let buffer = MangoAccount::default_for_tests().try_to_vec().unwrap(); let mut account = MangoAccountValue::from_bytes(&buffer).unwrap(); let group = Pubkey::new_unique(); @@ -1243,7 +1338,7 @@ mod tests { expected_health: f64, } fn test_health1_runner(testcase: &TestHealth1Case) { - let buffer = MangoAccount::default().try_to_vec().unwrap(); + let buffer = MangoAccount::default_for_tests().try_to_vec().unwrap(); let mut account = MangoAccountValue::from_bytes(&buffer).unwrap(); let group = Pubkey::new_unique(); @@ -1454,7 +1549,7 @@ mod tests { for (i, testcase) in testcases.iter().enumerate() { println!("checking testcase {}", i); - test_health1_runner(&testcase); + test_health1_runner(testcase); } } diff --git a/programs/mango-v4/src/state/mango_account.rs b/programs/mango-v4/src/state/mango_account.rs index df017df14..4fc04a7c5 100644 --- a/programs/mango-v4/src/state/mango_account.rs +++ b/programs/mango-v4/src/state/mango_account.rs @@ -17,11 +17,12 @@ use super::FillEvent; use super::LeafNode; use super::PerpMarket; use super::PerpMarketIndex; -use super::PerpOpenOrders; +use super::PerpOpenOrder; use super::Serum3MarketIndex; use super::Side; use super::TokenIndex; use super::FREE_ORDER_SLOT; +use super::{HealthCache, HealthType}; use super::{PerpPosition, Serum3Orders, TokenPosition}; use checked_math as cm; @@ -56,9 +57,16 @@ pub struct MangoAccount { /// Normally accounts can not be liquidated while maint_health >= 0. But when an account /// reaches maint_health < 0, liquidators will call a liquidation instruction and thereby /// set this flag. Now the account may be liquidated until init_health >= 0. - being_liquidated: u8, + /// + /// Many actions should be disabled while the account is being liquidated, even if + /// its maint health has recovered to positive. Creating new open orders would, for example, + /// confuse liquidators. + pub being_liquidated: u8, - padding2: u8, + /// The account is currently inside a health region marked by HealthRegionBegin...HealthRegionEnd. + /// + /// Must never be set after a transaction ends. + pub in_health_region: u8, pub bump: u8, @@ -72,7 +80,10 @@ pub struct MangoAccount { // TODO: unimplemented pub net_settled: i64, - pub reserved: [u8; 248], + /// Init health as calculated during HealthReginBegin, rounded up. + pub health_region_pre_init_health: i64, + + pub reserved: [u8; 240], // dynamic pub header_version: u8, @@ -89,24 +100,25 @@ pub struct MangoAccount { pub padding6: u32, pub perps: Vec, pub padding7: u32, - pub perp_open_orders: Vec, + pub perp_open_orders: Vec, } -impl Default for MangoAccount { - fn default() -> Self { +impl MangoAccount { + pub fn default_for_tests() -> Self { Self { name: Default::default(), group: Pubkey::default(), owner: Pubkey::default(), delegate: Pubkey::default(), being_liquidated: 0, - padding2: 0, + in_health_region: 0, account_num: 0, bump: 0, padding: Default::default(), net_deposits: 0, net_settled: 0, - reserved: [0; 248], + health_region_pre_init_health: 0, + reserved: [0; 240], header_version: DEFAULT_MANGO_ACCOUNT_VERSION, padding3: Default::default(), padding4: Default::default(), @@ -114,14 +126,13 @@ impl Default for MangoAccount { padding5: Default::default(), serum3: vec![Serum3Orders::default(); 5], padding6: Default::default(), - perps: vec![PerpPosition::default(); 2], + perps: vec![PerpPosition::default(); 4], padding7: Default::default(), - perp_open_orders: vec![PerpOpenOrders::default(); 2], + perp_open_orders: vec![PerpOpenOrder::default(); 6], } } -} -impl MangoAccount { + /// Number of bytes needed for the MangoAccount, including the discriminator pub fn space( token_count: u8, serum3_count: u8, @@ -167,59 +178,10 @@ impl MangoAccount { perp_oo_count: u8, ) -> usize { Self::dynamic_perp_oo_vec_offset(token_count, serum3_count, perp_count) - + (BORSH_VEC_SIZE_BYTES + size_of::() * usize::from(perp_oo_count)) + + (BORSH_VEC_SIZE_BYTES + size_of::() * usize::from(perp_oo_count)) } } -#[test] -fn test_serialization_match() { - let mut account = MangoAccount::default(); - account.group = Pubkey::new_unique(); - account.owner = Pubkey::new_unique(); - account.name = crate::util::fill_from_str("abcdef").unwrap(); - account.delegate = Pubkey::new_unique(); - account.account_num = 1; - account.bump = 2; - account.net_deposits = 3; - account.net_settled = 4; - account.tokens.resize(8, TokenPosition::default()); - account.tokens[0].token_index = 5; - account.serum3.resize(8, Serum3Orders::default()); - account.perps.resize(8, PerpPosition::default()); - account.perps[0].market_index = 6; - account - .perp_open_orders - .resize(8, PerpOpenOrders::default()); - - let account_bytes = AnchorSerialize::try_to_vec(&account).unwrap(); - assert_eq!( - 8 + account_bytes.len(), - MangoAccount::space(8, 8, 8, 8).unwrap() - ); - - let account2 = MangoAccountValue::from_bytes(&account_bytes).unwrap(); - assert_eq!(account.group, account2.fixed.group); - assert_eq!(account.owner, account2.fixed.owner); - assert_eq!(account.name, account2.fixed.name); - assert_eq!(account.delegate, account2.fixed.delegate); - assert_eq!(account.account_num, account2.fixed.account_num); - assert_eq!(account.bump, account2.fixed.bump); - assert_eq!(account.net_deposits, account2.fixed.net_deposits); - assert_eq!(account.net_settled, account2.fixed.net_settled); - assert_eq!( - account.tokens[0].token_index, - account2.token_position_by_raw_index(0).token_index - ); - assert_eq!( - account.serum3[0].open_orders, - account2.serum3_orders_by_raw_index(0).open_orders - ); - assert_eq!( - account.perps[0].market_index, - account2.perp_position_by_raw_index(0).market_index - ); -} - // Mango Account fixed part for easy zero copy deserialization #[derive(Copy, Clone)] #[repr(C)] @@ -230,14 +192,15 @@ pub struct MangoAccountFixed { pub delegate: Pubkey, pub account_num: u32, being_liquidated: u8, - padding2: u8, + in_health_region: u8, pub bump: u8, pub padding: [u8; 1], pub net_deposits: i64, pub net_settled: i64, - pub reserved: [u8; 248], + pub health_region_begin_init_health: i64, + pub reserved: [u8; 240], } -const_assert_eq!(size_of::(), 32 * 4 + 8 + 2 * 8 + 248); +const_assert_eq!(size_of::(), 32 * 4 + 8 + 3 * 8 + 240); const_assert_eq!(size_of::() % 8, 0); unsafe impl bytemuck::Pod for MangoAccountFixed {} @@ -255,18 +218,29 @@ impl MangoAccountFixed { } pub fn being_liquidated(&self) -> bool { - self.being_liquidated != 0 + self.being_liquidated == 1 } pub fn set_being_liquidated(&mut self, b: bool) { self.being_liquidated = if b { 1 } else { 0 }; } - pub fn maybe_recover_from_being_liquidated(&mut self, init_health: I80F48) { + pub fn is_in_health_region(&self) -> bool { + self.in_health_region == 1 + } + + pub fn set_in_health_region(&mut self, b: bool) { + self.in_health_region = if b { 1 } else { 0 }; + } + + pub fn maybe_recover_from_being_liquidated(&mut self, init_health: I80F48) -> bool { // This is used as threshold to flip flag instead of 0 because of dust issues let one_native_usdc = I80F48::ONE; if self.being_liquidated() && init_health > -one_native_usdc { self.set_being_liquidated(false); + true + } else { + false } } } @@ -374,7 +348,7 @@ impl MangoAccountDynamicHeader { self.serum3_count, self.perp_count, ) + BORSH_VEC_SIZE_BYTES - + raw_index * size_of::() + + raw_index * size_of::() } pub fn token_count(&self) -> usize { @@ -474,14 +448,13 @@ impl< // get iter over all active TokenPositions pub fn active_token_positions(&self) -> impl Iterator + '_ { - (0..self.header().token_count()) - .map(|i| self.token_position_by_raw_index(i)) - .filter(|token| token.is_active()) + self.all_token_positions().filter(|token| token.is_active()) } - pub fn serum3_orders(&self, market_index: Serum3MarketIndex) -> Option<&Serum3Orders> { - self.active_serum3_orders() + pub fn serum3_orders(&self, market_index: Serum3MarketIndex) -> Result<&Serum3Orders> { + self.all_serum3_orders() .find(|p| p.is_active_for_market(market_index)) + .ok_or_else(|| error_msg!("serum3 orders for market index {} not found", market_index)) } pub fn serum3_orders_by_raw_index(&self, raw_index: usize) -> &Serum3Orders { @@ -493,14 +466,14 @@ impl< } pub fn active_serum3_orders(&self) -> impl Iterator + '_ { - (0..self.header().serum3_count()) - .map(|i| self.serum3_orders_by_raw_index(i)) + self.all_serum3_orders() .filter(|serum3_order| serum3_order.is_active()) } - pub fn perp_position(&self, market_index: PerpMarketIndex) -> Option<&PerpPosition> { - self.active_perp_positions() + pub fn perp_position(&self, market_index: PerpMarketIndex) -> Result<&PerpPosition> { + self.all_perp_positions() .find(|p| p.is_active_for_market(market_index)) + .ok_or_else(|| error_msg!("perp position for market index {} not found", market_index)) } pub fn perp_position_by_raw_index(&self, raw_index: usize) -> &PerpPosition { @@ -512,22 +485,21 @@ impl< } pub fn active_perp_positions(&self) -> impl Iterator { - (0..self.header().perp_count()) - .map(|i| self.perp_position_by_raw_index(i)) - .filter(|p| p.is_active()) + self.all_perp_positions().filter(|p| p.is_active()) } - pub fn perp_orders_by_raw_index(&self, raw_index: usize) -> &PerpOpenOrders { + pub fn perp_order_by_raw_index(&self, raw_index: usize) -> &PerpOpenOrder { get_helper(self.dynamic(), self.header().perp_oo_offset(raw_index)) } - pub fn all_perp_orders(&self) -> impl Iterator { - (0..self.header().perp_oo_count()).map(|i| self.perp_orders_by_raw_index(i)) + pub fn all_perp_orders(&self) -> impl Iterator { + (0..self.header().perp_oo_count()).map(|i| self.perp_order_by_raw_index(i)) } - pub fn perp_next_order_slot(&self) -> Option { + pub fn perp_next_order_slot(&self) -> Result { self.all_perp_orders() .position(|&oo| oo.order_market == FREE_ORDER_SLOT) + .ok_or_else(|| error_msg!("no free perp order index")) } pub fn perp_find_order_with_client_order_id( @@ -535,8 +507,7 @@ impl< market_index: PerpMarketIndex, client_order_id: u64, ) -> Option<(i128, Side)> { - for i in 0..self.header().perp_oo_count() { - let oo = self.perp_orders_by_raw_index(i); + for oo in self.all_perp_orders() { if oo.order_market == market_index && oo.client_order_id == client_order_id { return Some((oo.order_id, oo.order_side)); } @@ -549,8 +520,7 @@ impl< market_index: PerpMarketIndex, order_id: i128, ) -> Option { - for i in 0..self.header().perp_oo_count() { - let oo = self.perp_orders_by_raw_index(i); + for oo in self.all_perp_orders() { if oo.order_market == market_index && oo.order_id == order_id { return Some(oo.order_side); } @@ -580,6 +550,9 @@ impl< fn header_mut(&mut self) -> &mut MangoAccountDynamicHeader { self.header.deref_or_borrow_mut() } + fn fixed_mut(&mut self) -> &mut MangoAccountFixed { + self.fixed.deref_or_borrow_mut() + } fn dynamic_mut(&mut self) -> &mut [u8] { self.dynamic.deref_or_borrow_mut() } @@ -669,7 +642,7 @@ impl< &mut self, market_index: Serum3MarketIndex, ) -> Result<&mut Serum3Orders> { - if self.serum3_orders(market_index).is_some() { + if self.serum3_orders(market_index).is_ok() { return err!(MangoError::Serum3OpenOrdersExistAlready); } @@ -679,9 +652,9 @@ impl< market_index: market_index as Serum3MarketIndex, ..Serum3Orders::default() }; - return Ok(self.serum3_orders_mut_by_raw_index(raw_index)); + Ok(self.serum3_orders_mut_by_raw_index(raw_index)) } else { - return err!(MangoError::NoFreeSerum3OpenOrdersIndex); + err!(MangoError::NoFreeSerum3OpenOrdersIndex) } } @@ -697,11 +670,13 @@ impl< pub fn serum3_orders_mut( &mut self, market_index: Serum3MarketIndex, - ) -> Option<&mut Serum3Orders> { + ) -> Result<&mut Serum3Orders> { let raw_index_opt = self - .active_serum3_orders() + .all_serum3_orders() .position(|p| p.is_active_for_market(market_index)); - raw_index_opt.map(|raw_index| self.serum3_orders_mut_by_raw_index(raw_index)) + raw_index_opt + .map(|raw_index| self.serum3_orders_mut_by_raw_index(raw_index)) + .ok_or_else(|| error_msg!("serum3 orders for market index {} not found", market_index)) } // get mut PerpPosition at raw_index @@ -710,7 +685,7 @@ impl< get_helper_mut(self.dynamic_mut(), offset) } - pub fn perp_orders_mut_by_raw_index(&mut self, raw_index: usize) -> &mut PerpOpenOrders { + pub fn perp_order_mut_by_raw_index(&mut self, raw_index: usize) -> &mut PerpOpenOrder { let offset = self.header().perp_oo_offset(raw_index); get_helper_mut(self.dynamic_mut(), offset) } @@ -720,7 +695,7 @@ impl< perp_market_index: PerpMarketIndex, ) -> Result<(&mut PerpPosition, usize)> { let mut raw_index_opt = self - .active_perp_positions() + .all_perp_positions() .position(|p| p.is_active_for_market(perp_market_index)); if raw_index_opt.is_none() { raw_index_opt = self.all_perp_positions().position(|p| !p.is_active()); @@ -748,6 +723,7 @@ impl< side: Side, order: &LeafNode, ) -> Result<()> { + // TODO: pass in the PerpPosition, currently has a creation side-effect let mut perp_account = self.ensure_perp_position(perp_market_index).unwrap().0; match side { Side::Bid => { @@ -759,7 +735,7 @@ impl< }; let slot = order.owner_slot as usize; - let mut oo = self.perp_orders_mut_by_raw_index(slot); + let mut oo = self.perp_order_mut_by_raw_index(slot); oo.order_market = perp_market_index; oo.order_side = side; oo.order_id = order.key; @@ -768,8 +744,9 @@ impl< } pub fn remove_perp_order(&mut self, slot: usize, quantity: i64) -> Result<()> { + // TODO: pass in the PerpPosition, currently has a creation side-effect { - let oo = self.perp_orders_mut_by_raw_index(slot); + let oo = self.perp_order_mut_by_raw_index(slot); require_neq!(oo.order_market, FREE_ORDER_SLOT); let order_side = oo.order_side; let perp_market_index = oo.order_market; @@ -787,7 +764,7 @@ impl< } // release space - let oo = self.perp_orders_mut_by_raw_index(slot); + let oo = self.perp_order_mut_by_raw_index(slot); oo.order_market = FREE_ORDER_SLOT; oo.order_side = Side::Bid; oo.order_id = 0i128; @@ -801,6 +778,7 @@ impl< perp_market: &mut PerpMarket, fill: &FillEvent, ) -> Result<()> { + // TODO: pass in the PerpPosition, currently has a creation side-effect let pa = self.ensure_perp_position(perp_market_index).unwrap().0; pa.settle_funding(perp_market); @@ -840,6 +818,7 @@ impl< perp_market: &mut PerpMarket, fill: &FillEvent, ) -> Result<()> { + // TODO: pass in the PerpPosition, currently has a creation side-effect let pa = self.ensure_perp_position(perp_market_index).unwrap().0; pa.settle_funding(perp_market); @@ -854,6 +833,34 @@ impl< Ok(()) } + pub fn check_health_pre(&mut self, health_cache: &HealthCache) -> Result { + let pre_health = health_cache.health(HealthType::Init); + msg!("pre_health: {}", pre_health); + self.fixed_mut() + .maybe_recover_from_being_liquidated(pre_health); + require!( + !self.fixed().being_liquidated(), + MangoError::BeingLiquidated + ); + Ok(pre_health) + } + + pub fn check_health_post( + &mut self, + health_cache: &HealthCache, + pre_health: I80F48, + ) -> Result<()> { + let post_health = health_cache.health(HealthType::Init); + msg!("post_health: {}", post_health); + require!( + post_health >= 0 || post_health > pre_health, + MangoError::HealthMustBePositiveOrIncrease + ); + self.fixed_mut() + .maybe_recover_from_being_liquidated(post_health); + Ok(()) + } + // writes length of tokens vec at appropriate offset so that borsh can infer the vector length // length used is that present in the header fn write_token_length(&mut self) { @@ -934,12 +941,12 @@ impl< sol_memmove( &mut dynamic[new_header.perp_oo_offset(0)], &mut dynamic[old_header.perp_oo_offset(0)], - size_of::() * old_header.perp_oo_count(), + size_of::() * old_header.perp_oo_count(), ); } for i in old_header.perp_oo_count..new_perp_oo_count { *get_helper_mut(dynamic, new_header.perp_oo_offset(i.into())) = - PerpOpenOrders::default(); + PerpOpenOrder::default(); } } @@ -1000,3 +1007,237 @@ impl< Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn make_test_account() -> MangoAccountValue { + let bytes = AnchorSerialize::try_to_vec(&MangoAccount::default_for_tests()).unwrap(); + MangoAccountValue::from_bytes(&bytes).unwrap() + } + + #[test] + fn test_serialization_match() { + let mut account = MangoAccount::default_for_tests(); + account.group = Pubkey::new_unique(); + account.owner = Pubkey::new_unique(); + account.name = crate::util::fill_from_str("abcdef").unwrap(); + account.delegate = Pubkey::new_unique(); + account.account_num = 1; + account.being_liquidated = 2; + account.in_health_region = 3; + account.bump = 4; + account.net_deposits = 5; + account.net_settled = 6; + account.health_region_pre_init_health = 7; + account.tokens.resize(8, TokenPosition::default()); + account.tokens[0].token_index = 8; + account.serum3.resize(8, Serum3Orders::default()); + account.perps.resize(8, PerpPosition::default()); + account.perps[0].market_index = 9; + account.perp_open_orders.resize(8, PerpOpenOrder::default()); + + let account_bytes = AnchorSerialize::try_to_vec(&account).unwrap(); + assert_eq!( + 8 + account_bytes.len(), + MangoAccount::space(8, 8, 8, 8).unwrap() + ); + + let account2 = MangoAccountValue::from_bytes(&account_bytes).unwrap(); + assert_eq!(account.group, account2.fixed.group); + assert_eq!(account.owner, account2.fixed.owner); + assert_eq!(account.name, account2.fixed.name); + assert_eq!(account.delegate, account2.fixed.delegate); + assert_eq!(account.account_num, account2.fixed.account_num); + assert_eq!(account.being_liquidated, account2.fixed.being_liquidated); + assert_eq!(account.in_health_region, account2.fixed.in_health_region); + assert_eq!(account.bump, account2.fixed.bump); + assert_eq!(account.net_deposits, account2.fixed.net_deposits); + assert_eq!(account.net_settled, account2.fixed.net_settled); + assert_eq!( + account.health_region_pre_init_health, + account2.fixed.health_region_begin_init_health + ); + assert_eq!( + account.tokens[0].token_index, + account2.token_position_by_raw_index(0).token_index + ); + assert_eq!( + account.serum3[0].open_orders, + account2.serum3_orders_by_raw_index(0).open_orders + ); + assert_eq!( + account.perps[0].market_index, + account2.perp_position_by_raw_index(0).market_index + ); + } + + #[test] + fn test_token_positions() { + let mut account = make_test_account(); + assert!(account.token_position(1).is_err()); + assert!(account.token_position_and_raw_index(2).is_err()); + assert!(account.token_position_mut(3).is_err()); + assert_eq!( + account.token_position_by_raw_index(0).token_index, + TokenIndex::MAX + ); + + { + let (pos, raw, active) = account.ensure_token_position(1).unwrap(); + assert_eq!(raw, 0); + assert_eq!(active, 0); + assert_eq!(pos.token_index, 1); + } + { + let (pos, raw, active) = account.ensure_token_position(7).unwrap(); + assert_eq!(raw, 1); + assert_eq!(active, 1); + assert_eq!(pos.token_index, 7); + } + { + let (pos, raw, active) = account.ensure_token_position(42).unwrap(); + assert_eq!(raw, 2); + assert_eq!(active, 2); + assert_eq!(pos.token_index, 42); + } + + { + account.deactivate_token_position(1); + + let (pos, raw, active) = account.ensure_token_position(42).unwrap(); + assert_eq!(raw, 2); + assert_eq!(active, 1); + assert_eq!(pos.token_index, 42); + + let (pos, raw, active) = account.ensure_token_position(8).unwrap(); + assert_eq!(raw, 1); + assert_eq!(active, 1); + assert_eq!(pos.token_index, 8); + } + + assert_eq!(account.active_token_positions().count(), 3); + account.deactivate_token_position(0); + assert_eq!( + account.token_position_by_raw_index(0).token_index, + TokenIndex::MAX + ); + assert!(account.token_position(1).is_err()); + assert!(account.token_position_mut(1).is_err()); + assert!(account.token_position(8).is_ok()); + assert!(account.token_position(42).is_ok()); + assert_eq!(account.token_position_and_raw_index(42).unwrap().1, 2); + assert_eq!(account.active_token_positions().count(), 2); + + { + let (pos, raw) = account.token_position_mut(42).unwrap(); + assert_eq!(pos.token_index, 42); + assert_eq!(raw, 2); + } + { + let (pos, raw) = account.token_position_mut(8).unwrap(); + assert_eq!(pos.token_index, 8); + assert_eq!(raw, 1); + } + } + + #[test] + fn test_serum3_orders() { + let mut account = make_test_account(); + assert!(account.serum3_orders(1).is_err()); + assert!(account.serum3_orders_mut(3).is_err()); + assert_eq!( + account.serum3_orders_by_raw_index(0).market_index, + Serum3MarketIndex::MAX + ); + + assert_eq!(account.create_serum3_orders(1).unwrap().market_index, 1); + assert_eq!(account.create_serum3_orders(7).unwrap().market_index, 7); + assert_eq!(account.create_serum3_orders(42).unwrap().market_index, 42); + assert!(account.create_serum3_orders(7).is_err()); + assert_eq!(account.active_serum3_orders().count(), 3); + + assert!(account.deactivate_serum3_orders(7).is_ok()); + assert_eq!( + account.serum3_orders_by_raw_index(1).market_index, + Serum3MarketIndex::MAX + ); + assert!(account.create_serum3_orders(8).is_ok()); + assert_eq!(account.serum3_orders_by_raw_index(1).market_index, 8); + + assert_eq!(account.active_serum3_orders().count(), 3); + assert!(account.deactivate_serum3_orders(1).is_ok()); + assert!(account.serum3_orders(1).is_err()); + assert!(account.serum3_orders_mut(1).is_err()); + assert!(account.serum3_orders(8).is_ok()); + assert!(account.serum3_orders(42).is_ok()); + assert_eq!(account.active_serum3_orders().count(), 2); + + assert_eq!(account.serum3_orders_mut(42).unwrap().market_index, 42); + assert_eq!(account.serum3_orders_mut(8).unwrap().market_index, 8); + assert!(account.serum3_orders_mut(7).is_err()); + } + + #[test] + fn test_perp_positions() { + let mut account = make_test_account(); + assert!(account.perp_position(1).is_err()); + //assert!(account.perp_position_mut(3).is_err()); + assert_eq!( + account.perp_position_by_raw_index(0).market_index, + PerpMarketIndex::MAX + ); + + { + let (pos, raw) = account.ensure_perp_position(1).unwrap(); + assert_eq!(raw, 0); + assert_eq!(pos.market_index, 1); + } + { + let (pos, raw) = account.ensure_perp_position(7).unwrap(); + assert_eq!(raw, 1); + assert_eq!(pos.market_index, 7); + } + { + let (pos, raw) = account.ensure_perp_position(42).unwrap(); + assert_eq!(raw, 2); + assert_eq!(pos.market_index, 42); + } + + { + account.deactivate_perp_position(1); + + let (pos, raw) = account.ensure_perp_position(42).unwrap(); + assert_eq!(raw, 2); + assert_eq!(pos.market_index, 42); + + let (pos, raw) = account.ensure_perp_position(8).unwrap(); + assert_eq!(raw, 1); + assert_eq!(pos.market_index, 8); + } + + assert_eq!(account.active_perp_positions().count(), 3); + account.deactivate_perp_position(0); + assert_eq!( + account.perp_position_by_raw_index(0).market_index, + PerpMarketIndex::MAX + ); + assert!(account.perp_position(1).is_err()); + //assert!(account.perp_position_mut(1).is_err()); + assert!(account.perp_position(8).is_ok()); + assert!(account.perp_position(42).is_ok()); + assert_eq!(account.active_perp_positions().count(), 2); + + /*{ + let (pos, raw) = account.perp_position_mut(42).unwrap(); + assert_eq!(pos.perp_index, 42); + assert_eq!(raw, 2); + } + { + let (pos, raw) = account.perp_position_mut(8).unwrap(); + assert_eq!(pos.perp_index, 8); + assert_eq!(raw, 1); + }*/ + } +} diff --git a/programs/mango-v4/src/state/mango_account_components.rs b/programs/mango-v4/src/state/mango_account_components.rs index 5a5cf1cbe..b13d3b46b 100644 --- a/programs/mango-v4/src/state/mango_account_components.rs +++ b/programs/mango-v4/src/state/mango_account_components.rs @@ -92,12 +92,12 @@ impl TokenPosition { pub struct Serum3Orders { pub open_orders: Pubkey, - // tracks reserved funds in open orders account, - // used for bookkeeping of potentital loans which - // can be charged with loan origination fees - // e.g. serum3 settle funds ix - pub previous_native_coin_reserved: u64, - pub previous_native_pc_reserved: u64, + /// Tracks the amount of borrows that have flowed into the serum open orders account. + /// These borrows did not have the loan origination fee applied, and that may happen + /// later (in serum3_settle_funds) if we can guarantee that the funds were used. + /// In particular a place-on-book, cancel, settle should not cost fees. + pub base_borrows_without_fee: u64, + pub quote_borrows_without_fee: u64, pub market_index: Serum3MarketIndex, @@ -138,8 +138,8 @@ impl Default for Serum3Orders { quote_token_index: TokenIndex::MAX, reserved: [0; 64], padding: Default::default(), - previous_native_coin_reserved: 0, - previous_native_pc_reserved: 0, + base_borrows_without_fee: 0, + quote_borrows_without_fee: 0, } } } @@ -321,7 +321,7 @@ impl PerpPosition { #[zero_copy] #[derive(AnchorSerialize, AnchorDeserialize, Debug)] -pub struct PerpOpenOrders { +pub struct PerpOpenOrder { pub order_side: Side, // TODO: storing enums isn't POD pub padding1: [u8; 1], pub order_market: PerpMarketIndex, @@ -331,7 +331,7 @@ pub struct PerpOpenOrders { pub reserved: [u8; 64], } -impl Default for PerpOpenOrders { +impl Default for PerpOpenOrder { fn default() -> Self { Self { order_side: Side::Bid, @@ -345,11 +345,11 @@ impl Default for PerpOpenOrders { } } -unsafe impl bytemuck::Pod for PerpOpenOrders {} -unsafe impl bytemuck::Zeroable for PerpOpenOrders {} +unsafe impl bytemuck::Pod for PerpOpenOrder {} +unsafe impl bytemuck::Zeroable for PerpOpenOrder {} -const_assert_eq!(size_of::(), 1 + 1 + 2 + 4 + 8 + 16 + 64); -const_assert_eq!(size_of::() % 8, 0); +const_assert_eq!(size_of::(), 1 + 1 + 2 + 4 + 8 + 16 + 64); +const_assert_eq!(size_of::() % 8, 0); #[macro_export] macro_rules! account_seeds { diff --git a/programs/mango-v4/src/state/orderbook/book.rs b/programs/mango-v4/src/state/orderbook/book.rs index da78ec8a0..17fcd821c 100644 --- a/programs/mango-v4/src/state/orderbook/book.rs +++ b/programs/mango-v4/src/state/orderbook/book.rs @@ -346,9 +346,7 @@ impl<'a> Book<'a> { event_queue.push_back(cast(event)).unwrap(); } - let owner_slot = mango_account - .perp_next_order_slot() - .ok_or_else(|| error!(MangoError::SomeError))?; + let owner_slot = mango_account.perp_next_order_slot()?; let new_order = LeafNode::new( owner_slot as u8, order_id, @@ -393,7 +391,7 @@ impl<'a> Book<'a> { side_to_cancel_option: Option, ) -> Result<()> { for i in 0..mango_account.header.perp_oo_count() { - let oo = mango_account.perp_orders_by_raw_index(i); + let oo = mango_account.perp_order_by_raw_index(i); if oo.order_market == FREE_ORDER_SLOT || oo.order_market != perp_market.perp_market_index { diff --git a/programs/mango-v4/src/state/orderbook/mod.rs b/programs/mango-v4/src/state/orderbook/mod.rs index 6d144f863..d24ed489d 100644 --- a/programs/mango-v4/src/state/orderbook/mod.rs +++ b/programs/mango-v4/src/state/orderbook/mod.rs @@ -101,7 +101,7 @@ mod tests { let mut new_order = |book: &mut Book, event_queue: &mut EventQueue, side, price, now_ts| -> i128 { - let buffer = MangoAccount::default().try_to_vec().unwrap(); + let buffer = MangoAccount::default_for_tests().try_to_vec().unwrap(); let mut account = MangoAccountValue::from_bytes(&buffer).unwrap(); let quantity = 1; @@ -124,7 +124,7 @@ mod tests { u8::MAX, ) .unwrap(); - account.perp_orders_by_raw_index(0).order_id + account.perp_order_by_raw_index(0).order_id }; // insert bids until book side is full @@ -196,7 +196,7 @@ mod tests { market.maker_fee = I80F48::from_num(-0.001f64); market.taker_fee = I80F48::from_num(0.01f64); - let buffer = MangoAccount::default().try_to_vec().unwrap(); + let buffer = MangoAccount::default_for_tests().try_to_vec().unwrap(); let mut maker = MangoAccountValue::from_bytes(&buffer).unwrap(); let mut taker = MangoAccountValue::from_bytes(&buffer).unwrap(); @@ -225,19 +225,19 @@ mod tests { ) .unwrap(); assert_eq!( - maker.perp_orders_mut_by_raw_index(0).order_market, + maker.perp_order_mut_by_raw_index(0).order_market, market.perp_market_index ); assert_eq!( - maker.perp_orders_mut_by_raw_index(1).order_market, + maker.perp_order_mut_by_raw_index(1).order_market, FREE_ORDER_SLOT ); - assert_ne!(maker.perp_orders_mut_by_raw_index(0).order_id, 0); - assert_eq!(maker.perp_orders_mut_by_raw_index(0).client_order_id, 42); - assert_eq!(maker.perp_orders_mut_by_raw_index(0).order_side, Side::Bid); + assert_ne!(maker.perp_order_mut_by_raw_index(0).order_id, 0); + assert_eq!(maker.perp_order_mut_by_raw_index(0).client_order_id, 42); + assert_eq!(maker.perp_order_mut_by_raw_index(0).order_side, Side::Bid); assert!(bookside_contains_key( &book.bids, - maker.perp_orders_mut_by_raw_index(0).order_id + maker.perp_order_mut_by_raw_index(0).order_id )); assert!(bookside_contains_price(&book.bids, price)); assert_eq!( @@ -279,7 +279,7 @@ mod tests { // the remainder of the maker order is still on the book // (the maker account is unchanged: it was not even passed in) let order = - bookside_leaf_by_key(&book.bids, maker.perp_orders_by_raw_index(0).order_id).unwrap(); + bookside_leaf_by_key(&book.bids, maker.perp_order_by_raw_index(0).order_id).unwrap(); assert_eq!(order.price(), price); assert_eq!(order.quantity, bid_quantity - match_quantity); @@ -292,7 +292,7 @@ mod tests { // the taker account is updated assert_eq!( - taker.perp_orders_by_raw_index(0).order_market, + taker.perp_order_by_raw_index(0).order_market, FREE_ORDER_SLOT ); assert_eq!(taker.perp_position_by_raw_index(0).bids_base_lots, 0); @@ -327,14 +327,14 @@ mod tests { // simulate event queue processing maker - .execute_perp_maker(market.perp_market_index, &mut market, &fill) + .execute_perp_maker(market.perp_market_index, &mut market, fill) .unwrap(); taker - .execute_perp_taker(market.perp_market_index, &mut market, &fill) + .execute_perp_taker(market.perp_market_index, &mut market, fill) .unwrap(); assert_eq!(market.open_interest, 2 * match_quantity); - assert_eq!(maker.perp_orders_by_raw_index(0).order_market, 0); + assert_eq!(maker.perp_order_by_raw_index(0).order_market, 0); assert_eq!( maker.perp_position_by_raw_index(0).bids_base_lots, bid_quantity - match_quantity diff --git a/programs/mango-v4/src/util.rs b/programs/mango-v4/src/util.rs index 112ec6719..2d0aa07ed 100644 --- a/programs/mango-v4/src/util.rs +++ b/programs/mango-v4/src/util.rs @@ -37,5 +37,4 @@ pub fn format_zero_terminated_utf8_bytes( .unwrap() .trim_matches(char::from(0)), ) - .into() } diff --git a/programs/mango-v4/tests/program_test/mango_client.rs b/programs/mango-v4/tests/program_test/mango_client.rs index 1d12ecc6d..b38a14342 100644 --- a/programs/mango-v4/tests/program_test/mango_client.rs +++ b/programs/mango-v4/tests/program_test/mango_client.rs @@ -319,6 +319,28 @@ pub async fn account_position_f64(solana: &SolanaCookie, account: Pubkey, bank: native.to_num::() } +// Verifies that the "post_health: ..." log emitted by the previous instruction +// matches the init health of the account. +pub async fn check_prev_instruction_post_health(solana: &SolanaCookie, account: Pubkey) { + let logs = solana.program_log(); + let post_health_str = logs + .iter() + .find_map(|line| line.strip_prefix("post_health: ")) + .unwrap(); + let post_health = post_health_str.parse::().unwrap(); + + solana.advance_by_slots(1).await; // ugly, just to avoid sending the same tx next + send_tx(solana, ComputeAccountDataInstruction { account }) + .await + .unwrap(); + + let health_data = solana + .program_log_events::() + .pop() + .unwrap(); + assert_eq!(health_data.init_health.to_num::(), post_health); +} + // // a struct for each instruction along with its // ClientInstruction impl @@ -502,6 +524,7 @@ impl<'keypair> ClientInstruction for TokenWithdrawInstruction<'keypair> { owner: self.owner.pubkey(), bank: mint_info.banks[self.bank_index], vault: mint_info.vaults[self.bank_index], + oracle: mint_info.oracle, token_account: self.token_account, token_program: Token::id(), }; @@ -569,6 +592,7 @@ impl ClientInstruction for TokenDepositInstruction { account: self.account, bank: mint_info.banks[self.bank_index], vault: mint_info.vaults[self.bank_index], + oracle: mint_info.oracle, token_account: self.token_account, token_authority: self.token_authority.pubkey(), token_program: Token::id(), @@ -812,9 +836,7 @@ impl<'keypair> ClientInstruction for TokenDeregisterInstruction<'keypair> { _loader: impl ClientAccountLoader + 'async_trait, ) -> (Self::Accounts, Instruction) { let program_id = mango_v4::id(); - let instruction = Self::Instruction { - token_index: self.token_index, - }; + let instruction = Self::Instruction {}; let accounts = Self::Accounts { admin: self.admin.pubkey(), @@ -1321,12 +1343,23 @@ impl<'keypair> ClientInstruction for Serum3RegisterMarketInstruction<'keypair> { ) .0; + let index_reservation = Pubkey::find_program_address( + &[ + b"Serum3Index".as_ref(), + self.group.as_ref(), + &self.market_index.to_le_bytes(), + ], + &program_id, + ) + .0; + let accounts = Self::Accounts { group: self.group, admin: self.admin.pubkey(), serum_program: self.serum_program, serum_market_external: self.serum_market_external, serum_market, + index_reservation, base_bank: self.base_bank, quote_bank: self.quote_bank, payer: self.payer.pubkey(), @@ -1567,14 +1600,17 @@ impl<'keypair> ClientInstruction for Serum3PlaceOrderInstruction<'keypair> { ) .await; + let (payer_bank, payer_vault) = match self.side { + Serum3Side::Bid => (quote_info.first_bank(), quote_info.first_vault()), + Serum3Side::Ask => (base_info.first_bank(), base_info.first_vault()), + }; + let accounts = Self::Accounts { group: account.fixed.group, account: self.account, open_orders, - quote_bank: quote_info.first_bank(), - quote_vault: quote_info.first_vault(), - base_bank: base_info.first_bank(), - base_vault: base_info.first_vault(), + payer_bank, + payer_vault, serum_market: self.serum_market, serum_program: serum_market.serum_program, serum_market_external: serum_market.serum_market_external, @@ -2499,7 +2535,6 @@ impl ClientInstruction for TokenUpdateIndexAndRateInstruction { pub struct ComputeAccountDataInstruction { pub account: Pubkey, - pub health_type: HealthType, } #[async_trait::async_trait(?Send)] impl ClientInstruction for ComputeAccountDataInstruction { @@ -2541,3 +2576,91 @@ impl ClientInstruction for ComputeAccountDataInstruction { vec![] } } + +pub struct HealthRegionBeginInstruction { + pub account: Pubkey, +} +#[async_trait::async_trait(?Send)] +impl ClientInstruction for HealthRegionBeginInstruction { + type Accounts = mango_v4::accounts::HealthRegionBegin; + type Instruction = mango_v4::instruction::HealthRegionBegin; + async fn to_instruction( + &self, + account_loader: impl ClientAccountLoader + 'async_trait, + ) -> (Self::Accounts, instruction::Instruction) { + let program_id = mango_v4::id(); + let instruction = Self::Instruction {}; + + let account = account_loader + .load_mango_account(&self.account) + .await + .unwrap(); + + let health_check_metas = derive_health_check_remaining_account_metas( + &account_loader, + &account, + None, + false, + None, + ) + .await; + + let accounts = Self::Accounts { + instructions: solana_program::sysvar::instructions::id(), + account: self.account, + }; + + let mut instruction = make_instruction(program_id, &accounts, instruction); + instruction.accounts.extend(health_check_metas.into_iter()); + + (accounts, instruction) + } + + fn signers(&self) -> Vec<&Keypair> { + vec![] + } +} + +pub struct HealthRegionEndInstruction { + pub account: Pubkey, + pub affected_bank: Option, +} +#[async_trait::async_trait(?Send)] +impl ClientInstruction for HealthRegionEndInstruction { + type Accounts = mango_v4::accounts::HealthRegionEnd; + type Instruction = mango_v4::instruction::HealthRegionEnd; + async fn to_instruction( + &self, + account_loader: impl ClientAccountLoader + 'async_trait, + ) -> (Self::Accounts, instruction::Instruction) { + let program_id = mango_v4::id(); + let instruction = Self::Instruction {}; + + let account = account_loader + .load_mango_account(&self.account) + .await + .unwrap(); + + let health_check_metas = derive_health_check_remaining_account_metas( + &account_loader, + &account, + self.affected_bank, + false, + None, + ) + .await; + + let accounts = Self::Accounts { + account: self.account, + }; + + let mut instruction = make_instruction(program_id, &accounts, instruction); + instruction.accounts.extend(health_check_metas.into_iter()); + + (accounts, instruction) + } + + fn signers(&self) -> Vec<&Keypair> { + vec![] + } +} diff --git a/programs/mango-v4/tests/program_test/mango_setup.rs b/programs/mango-v4/tests/program_test/mango_setup.rs index d375ebbaf..f9f8438df 100644 --- a/programs/mango-v4/tests/program_test/mango_setup.rs +++ b/programs/mango-v4/tests/program_test/mango_setup.rs @@ -5,7 +5,7 @@ use solana_sdk::signature::Keypair; use super::mango_client::*; use super::solana::SolanaCookie; -use super::{send_tx, MintCookie}; +use super::{send_tx, ClonableKeypair, MintCookie, UserCookie}; pub struct GroupWithTokensConfig<'a> { pub admin: &'a Keypair, @@ -13,6 +13,7 @@ pub struct GroupWithTokensConfig<'a> { pub mints: &'a [MintCookie], } +#[derive(Clone)] pub struct Token { pub index: u16, pub mint: MintCookie, @@ -136,3 +137,48 @@ impl<'a> GroupWithTokensConfig<'a> { } } } + +pub async fn create_funded_account( + solana: &SolanaCookie, + group: Pubkey, + owner: &Keypair, + account_num: u32, + payer: &UserCookie, + mints: &[MintCookie], + amounts: u64, + bank_index: usize, +) -> Pubkey { + let account = send_tx( + solana, + AccountCreateInstruction { + account_num, + token_count: 16, + serum3_count: 8, + perp_count: 8, + perp_oo_count: 8, + group, + owner, + payer: &payer.key, + }, + ) + .await + .unwrap() + .account; + + for mint in mints { + send_tx( + solana, + TokenDepositInstruction { + amount: amounts, + account, + token_account: payer.token_accounts[mint.index], + token_authority: payer.key.clone(), + bank_index, + }, + ) + .await + .unwrap(); + } + + account +} diff --git a/programs/mango-v4/tests/program_test/mod.rs b/programs/mango-v4/tests/program_test/mod.rs index d6da75484..2ca8772b3 100644 --- a/programs/mango-v4/tests/program_test/mod.rs +++ b/programs/mango-v4/tests/program_test/mod.rs @@ -52,7 +52,7 @@ impl AddPacked for ProgramTest { struct LoggerWrapper { inner: env_logger::Logger, - program_log: Arc>>, + capture: Arc>>, } impl Log for LoggerWrapper { @@ -67,7 +67,9 @@ impl Log for LoggerWrapper { { let msg = record.args().to_string(); if let Some(data) = msg.strip_prefix("Program log: ") { - self.program_log.write().unwrap().push(data.into()); + self.capture.write().unwrap().push(data.into()); + } else if let Some(data) = msg.strip_prefix("Program data: ") { + self.capture.write().unwrap().push(data.into()); } } self.inner.log(record); @@ -85,14 +87,17 @@ pub struct MarginTradeCookie { pub struct TestContextBuilder { test: ProgramTest, - program_log_capture: Arc>>, + logger_capture: Arc>>, mint0: Pubkey, } +lazy_static::lazy_static! { + static ref LOGGER_CAPTURE: Arc>> = Arc::new(RwLock::new(vec![])); + static ref LOGGER_LOCK: Arc> = Arc::new(RwLock::new(())); +} + impl TestContextBuilder { pub fn new() -> Self { - let mut test = ProgramTest::new("mango_v4", mango_v4::id(), processor!(mango_v4::entry)); - // We need to intercept logs to capture program log output let log_filter = "solana_rbpf=trace,\ solana_runtime::message_processor=debug,\ @@ -102,18 +107,19 @@ impl TestContextBuilder { env_logger::Builder::from_env(env_logger::Env::new().default_filter_or(log_filter)) .format_timestamp_nanos() .build(); - let program_log_capture = Arc::new(RwLock::new(vec![])); let _ = log::set_boxed_logger(Box::new(LoggerWrapper { inner: env_logger, - program_log: program_log_capture.clone(), + capture: LOGGER_CAPTURE.clone(), })); + let mut test = ProgramTest::new("mango_v4", mango_v4::id(), processor!(mango_v4::entry)); + // intentionally set to as tight as possible, to catch potential problems early - test.set_compute_max_units(87000); + test.set_compute_max_units(75000); Self { test, - program_log_capture, + logger_capture: LOGGER_CAPTURE.clone(), mint0: Pubkey::new_unique(), } } @@ -277,7 +283,9 @@ impl TestContextBuilder { let solana = Arc::new(SolanaCookie { context: RefCell::new(context), rent, - program_log: self.program_log_capture.clone(), + logger_capture: self.logger_capture.clone(), + logger_lock: LOGGER_LOCK.clone(), + last_transaction_log: RefCell::new(vec![]), }); solana diff --git a/programs/mango-v4/tests/program_test/serum.rs b/programs/mango-v4/tests/program_test/serum.rs index 7d78675dc..a340bbc1f 100644 --- a/programs/mango-v4/tests/program_test/serum.rs +++ b/programs/mango-v4/tests/program_test/serum.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use std::{mem, sync::Arc}; use bytemuck::from_bytes; @@ -41,7 +43,6 @@ pub struct SerumCookie { } impl SerumCookie { - #[allow(dead_code)] pub fn create_dex_account(&self, unpadded_len: usize) -> (Keypair, Instruction) { let serum_program_id = self.program_id; let key = Keypair::new(); @@ -57,7 +58,6 @@ impl SerumCookie { return (key, create_account_instr); } - #[allow(dead_code)] fn gen_listing_params( &self, _coin_mint: &Pubkey, @@ -94,7 +94,6 @@ impl SerumCookie { return (info, instructions); } - #[allow(dead_code)] pub async fn list_spot_market( &self, coin_mint: &MintCookie, @@ -186,20 +185,19 @@ impl SerumCookie { } } - #[allow(dead_code)] pub async fn consume_spot_events( &self, spot_market_cookie: &SpotMarketCookie, - open_orders: Pubkey, + open_orders: &[Pubkey], ) { let instructions = [serum_dex::instruction::consume_events( &self.program_id, - vec![&open_orders], + open_orders.iter().collect(), &spot_market_cookie.market, &spot_market_cookie.event_q, &spot_market_cookie.coin_fee_account, &spot_market_cookie.pc_fee_account, - 5, + 10, ) .unwrap()]; self.solana @@ -208,13 +206,11 @@ impl SerumCookie { .unwrap(); } - #[allow(dead_code)] fn strip_dex_padding(data: &[u8]) -> &[u8] { assert!(data.len() >= 12); &data[5..data.len() - 7] } - #[allow(dead_code)] pub async fn load_open_orders(&self, open_orders: Pubkey) -> serum_dex::state::OpenOrders { let data = self.solana.get_account_data(open_orders).await.unwrap(); let slice = Self::strip_dex_padding(&data); diff --git a/programs/mango-v4/tests/program_test/solana.rs b/programs/mango-v4/tests/program_test/solana.rs index 293909683..f05115d4f 100644 --- a/programs/mango-v4/tests/program_test/solana.rs +++ b/programs/mango-v4/tests/program_test/solana.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use std::cell::RefCell; use std::sync::{Arc, RwLock}; @@ -17,17 +19,26 @@ use spl_token::*; pub struct SolanaCookie { pub context: RefCell, pub rent: Rent, - pub program_log: Arc>>, + pub logger_capture: Arc>>, + pub logger_lock: Arc>, + pub last_transaction_log: RefCell>, } impl SolanaCookie { - #[allow(dead_code)] pub async fn process_transaction( &self, instructions: &[Instruction], signers: Option<&[&Keypair]>, ) -> Result<(), BanksClientError> { - self.program_log.write().unwrap().clear(); + // The locking in this function is convoluted: + // We capture the program log output by overriding the global logger and capturing + // messages there. This logger is potentially shared among multiple tests that run + // concurrently. + // To allow each independent SolanaCookie to capture only the logs from the transaction + // passed to process_transaction, wo globally hold the "program_log_lock" for the + // duration that the tx needs to process. So only a single one can run at a time. + let tx_log_lock = Arc::new(self.logger_lock.write().unwrap()); + self.logger_capture.write().unwrap().clear(); let mut context = self.context.borrow_mut(); @@ -45,13 +56,19 @@ impl SolanaCookie { transaction.sign(&all_signers, context.last_blockhash); - context + let result = context .banks_client .process_transaction_with_commitment( transaction, solana_sdk::commitment_config::CommitmentLevel::Processed, ) - .await + .await; + + *self.last_transaction_log.borrow_mut() = self.logger_capture.read().unwrap().clone(); + + drop(tx_log_lock); + + result } pub async fn get_clock(&self) -> solana_program::clock::Clock { @@ -63,7 +80,6 @@ impl SolanaCookie { .unwrap() } - #[allow(dead_code)] pub async fn advance_by_slots(&self, slots: u64) { let clock = self.get_clock().await; self.context @@ -72,8 +88,6 @@ impl SolanaCookie { .unwrap(); } - #[allow(dead_code)] - pub async fn advance_clock(&self) { let mut clock = self.get_clock().await; let old_ts = clock.unix_timestamp; @@ -133,7 +147,6 @@ impl SolanaCookie { key.pubkey() } - #[allow(dead_code)] pub async fn create_token_account(&self, owner: &Pubkey, mint: Pubkey) -> Pubkey { let keypair = Keypair::new(); let rent = self.rent.minimum_balance(spl_token::state::Account::LEN); @@ -162,7 +175,6 @@ impl SolanaCookie { } // Note: Only one table can be created per authority per slot! - #[allow(dead_code)] pub async fn create_address_lookup_table( &self, authority: &Keypair, @@ -180,7 +192,6 @@ impl SolanaCookie { alt_address } - #[allow(dead_code)] pub async fn get_account_data(&self, address: Pubkey) -> Option> { Some( self.context @@ -194,7 +205,6 @@ impl SolanaCookie { ) } - #[allow(dead_code)] pub async fn get_account_opt(&self, address: Pubkey) -> Option { self.context .borrow_mut() @@ -209,18 +219,30 @@ impl SolanaCookie { AccountDeserialize::try_deserialize(&mut data_slice).ok() } - #[allow(dead_code)] pub async fn get_account(&self, address: Pubkey) -> T { self.get_account_opt(address).await.unwrap() } - #[allow(dead_code)] pub async fn token_account_balance(&self, address: Pubkey) -> u64 { self.get_account::(address).await.amount } - #[allow(dead_code)] pub fn program_log(&self) -> Vec { - self.program_log.read().unwrap().clone() + self.last_transaction_log.borrow().clone() + } + + pub fn program_log_events( + &self, + ) -> Vec { + self.program_log() + .iter() + .filter_map(|data| { + let bytes = base64::decode(data).ok()?; + if bytes[0..8] != T::discriminator() { + return None; + } + T::try_from_slice(&bytes[8..]).ok() + }) + .collect() } } diff --git a/programs/mango-v4/tests/program_test/utils.rs b/programs/mango-v4/tests/program_test/utils.rs index 9a52861fb..c053b6065 100644 --- a/programs/mango-v4/tests/program_test/utils.rs +++ b/programs/mango-v4/tests/program_test/utils.rs @@ -1,15 +1,15 @@ +#![allow(dead_code)] + use bytemuck::{bytes_of, Contiguous}; use solana_program::program_error::ProgramError; use solana_sdk::pubkey::Pubkey; use solana_sdk::signature::Keypair; use std::ops::Deref; -#[allow(dead_code)] pub fn gen_signer_seeds<'a>(nonce: &'a u64, acc_pk: &'a Pubkey) -> [&'a [u8]; 2] { [acc_pk.as_ref(), bytes_of(nonce)] } -#[allow(dead_code)] pub fn gen_signer_key( nonce: u64, acc_pk: &Pubkey, @@ -19,7 +19,6 @@ pub fn gen_signer_key( Ok(Pubkey::create_program_address(&seeds, program_id)?) } -#[allow(dead_code)] pub fn create_signer_key_and_nonce(program_id: &Pubkey, acc_pk: &Pubkey) -> (Pubkey, u64) { for i in 0..=u64::MAX_VALUE { if let Ok(pk) = gen_signer_key(i, acc_pk, program_id) { @@ -29,7 +28,6 @@ pub fn create_signer_key_and_nonce(program_id: &Pubkey, acc_pk: &Pubkey) -> (Pub panic!("Could not generate signer key"); } -#[allow(dead_code)] pub fn clone_keypair(keypair: &Keypair) -> Keypair { Keypair::from_base58_string(&keypair.to_base58_string()) } diff --git a/programs/mango-v4/tests/test_bankrupt_tokens.rs b/programs/mango-v4/tests/test_bankrupt_tokens.rs index 69cff8a1f..2f02de6f3 100644 --- a/programs/mango-v4/tests/test_bankrupt_tokens.rs +++ b/programs/mango-v4/tests/test_bankrupt_tokens.rs @@ -10,6 +10,8 @@ use solana_sdk::{ use mango_v4::state::*; use program_test::*; +use mango_setup::*; + mod program_test; #[tokio::test] @@ -27,7 +29,7 @@ async fn test_bankrupt_tokens_socialize_loss() -> Result<(), TransportError> { // SETUP: Create a group and an account to fill the vaults // - let mango_setup::GroupWithTokens { group, tokens, .. } = mango_setup::GroupWithTokensConfig { + let GroupWithTokens { group, tokens, .. } = GroupWithTokensConfig { admin, payer, mints, @@ -40,37 +42,18 @@ async fn test_bankrupt_tokens_socialize_loss() -> Result<(), TransportError> { let collateral_token2 = &tokens[3]; // deposit some funds, to the vaults aren't empty - let vault_account = send_tx( - solana, - AccountCreateInstruction { - account_num: 2, - token_count: 16, - serum3_count: 8, - perp_count: 8, - perp_oo_count: 8, - group, - owner, - payer, - }, - ) - .await - .unwrap() - .account; let vault_amount = 100000; - for &token_account in payer_mint_accounts { - send_tx( - solana, - TokenDepositInstruction { - amount: vault_amount, - account: vault_account, - token_account, - token_authority: payer.clone(), - bank_index: 1, - }, - ) - .await - .unwrap(); - } + let vault_account = create_funded_account( + &solana, + group, + owner, + 2, + &context.users[1], + mints, + vault_amount, + 1, + ) + .await; // also add a tiny amount to bank0 for borrow_token1, so we can test multi-bank socialized loss send_tx( diff --git a/programs/mango-v4/tests/test_basic.rs b/programs/mango-v4/tests/test_basic.rs index 6a0673d91..7cefa5771 100644 --- a/programs/mango-v4/tests/test_basic.rs +++ b/programs/mango-v4/tests/test_basic.rs @@ -111,15 +111,14 @@ async fn test_basic() -> Result<(), TransportError> { // // TEST: Compute the account health // - send_tx( - solana, - ComputeAccountDataInstruction { - account, - health_type: HealthType::Init, - }, - ) - .await - .unwrap(); + send_tx(solana, ComputeAccountDataInstruction { account }) + .await + .unwrap(); + let health_data = solana + .program_log_events::() + .pop() + .unwrap(); + assert_eq!(health_data.init_health.to_num::(), 60); // // TEST: Withdraw funds @@ -143,6 +142,8 @@ async fn test_basic() -> Result<(), TransportError> { .await .unwrap(); + check_prev_instruction_post_health(&solana, account).await; + assert_eq!(solana.token_account_balance(vault).await, withdraw_amount); assert_eq!( solana.token_account_balance(payer_mint0_account).await, diff --git a/programs/mango-v4/tests/test_delegate.rs b/programs/mango-v4/tests/test_delegate.rs index 18e669b83..500797c05 100644 --- a/programs/mango-v4/tests/test_delegate.rs +++ b/programs/mango-v4/tests/test_delegate.rs @@ -6,6 +6,8 @@ use solana_sdk::{signature::Keypair, signature::Signer, transport::TransportErro use mango_v4::state::*; use program_test::*; +use mango_setup::*; + mod program_test; #[tokio::test] @@ -24,7 +26,7 @@ async fn test_delegate() -> Result<(), TransportError> { // SETUP: Create a group, register a token (mint0), create an account // - let mango_setup::GroupWithTokens { group, tokens, .. } = mango_setup::GroupWithTokensConfig { + let GroupWithTokens { group, tokens, .. } = GroupWithTokensConfig { admin, payer, mints, @@ -33,36 +35,8 @@ async fn test_delegate() -> Result<(), TransportError> { .await; let bank = tokens[0].bank; - let account = send_tx( - solana, - AccountCreateInstruction { - account_num: 0, - token_count: 16, - serum3_count: 8, - perp_count: 8, - perp_oo_count: 8, - group, - owner, - payer, - }, - ) - .await - .unwrap() - .account; - - // deposit - send_tx( - solana, - TokenDepositInstruction { - amount: 100, - account, - token_account: payer_mint0_account, - token_authority: payer.clone(), - bank_index: 0, - }, - ) - .await - .unwrap(); + let account = + create_funded_account(&solana, group, owner, 0, &context.users[1], mints, 100, 0).await; // // TEST: Edit account - Set delegate diff --git a/programs/mango-v4/tests/test_health_compute.rs b/programs/mango-v4/tests/test_health_compute.rs index cf338cabc..da4bb4c42 100644 --- a/programs/mango-v4/tests/test_health_compute.rs +++ b/programs/mango-v4/tests/test_health_compute.rs @@ -7,6 +7,8 @@ use solana_sdk::{signature::Keypair, transport::TransportError}; use program_test::*; +use mango_setup::*; + mod program_test; // Try to reach compute limits in health checks by having many different tokens in an account @@ -19,13 +21,12 @@ async fn test_health_compute_tokens() -> Result<(), TransportError> { let owner = &context.users[0].key; let payer = &context.users[1].key; let mints = &context.mints[0..10]; - let payer_mint_accounts = &context.users[1].token_accounts[0..mints.len()]; // // SETUP: Create a group and an account // - let mango_setup::GroupWithTokens { group, .. } = mango_setup::GroupWithTokensConfig { + let GroupWithTokens { group, .. } = GroupWithTokensConfig { admin, payer, mints, @@ -33,43 +34,8 @@ async fn test_health_compute_tokens() -> Result<(), TransportError> { .create(solana) .await; - let account = send_tx( - solana, - AccountCreateInstruction { - account_num: 0, - token_count: 16, - serum3_count: 8, - perp_count: 8, - perp_oo_count: 8, - group, - owner, - payer, - }, - ) - .await - .unwrap() - .account; - - // - // TEST: Deposit user funds for all the mints - // each deposit will end with a health check - // - for &token_account in payer_mint_accounts { - let deposit_amount = 1000; - - send_tx( - solana, - TokenDepositInstruction { - amount: deposit_amount, - account, - token_account, - token_authority: payer.clone(), - bank_index: 0, - }, - ) - .await - .unwrap(); - } + // each deposit ends with a health check + create_funded_account(&solana, group, owner, 0, &context.users[1], mints, 1000, 0).await; // TODO: actual explicit CU comparisons. // On 2022-5-25 the final deposit costs 36905 CU and each new token increases it by roughly 1600 CU @@ -216,36 +182,17 @@ async fn test_health_compute_perp() -> Result<(), TransportError> { .create(solana) .await; - let account = send_tx( - solana, - AccountCreateInstruction { - account_num: 0, - token_count: 16, - serum3_count: 8, - perp_count: 8, - perp_oo_count: 8, - group, - owner, - payer, - }, + let account = create_funded_account( + &solana, + group, + owner, + 0, + &context.users[1], + &mints[..1], + 1000, + 0, ) - .await - .unwrap() - .account; - - // Give the account some quote currency - send_tx( - solana, - TokenDepositInstruction { - amount: 1000, - account, - token_account: payer_mint_accounts[0], - token_authority: payer.clone(), - bank_index: 0, - }, - ) - .await - .unwrap(); + .await; // // SETUP: Create perp markets diff --git a/programs/mango-v4/tests/test_health_region.rs b/programs/mango-v4/tests/test_health_region.rs new file mode 100644 index 000000000..9fadeae6d --- /dev/null +++ b/programs/mango-v4/tests/test_health_region.rs @@ -0,0 +1,151 @@ +#![cfg(feature = "test-bpf")] + +use solana_program_test::*; +use solana_sdk::{signature::Keypair, transport::TransportError}; + +use mango_v4::state::MangoAccount; + +use program_test::*; + +use mango_setup::*; + +mod program_test; + +#[tokio::test] +async fn test_health_wrap() -> Result<(), TransportError> { + let context = TestContext::new().await; + let solana = &context.solana.clone(); + + let admin = &Keypair::new(); + let owner = &context.users[0].key; + let payer = &context.users[1].key; + let mints = &context.mints[0..2]; + let payer_mint_accounts = &context.users[1].token_accounts; + + // + // SETUP: Create a group, account, register a token (mint0) + // + + let GroupWithTokens { group, tokens, .. } = GroupWithTokensConfig { + admin, + payer, + mints, + } + .create(solana) + .await; + + // SETUP: Create an account with deposits, so the second account can borrow more than it has + create_funded_account(&solana, group, owner, 0, &context.users[1], mints, 1000, 0).await; + + // SETUP: Make a second account + let account = send_tx( + solana, + AccountCreateInstruction { + account_num: 1, + token_count: 8, + serum3_count: 0, + perp_count: 0, + perp_oo_count: 0, + group, + owner, + payer, + }, + ) + .await + .unwrap() + .account; + + // SETUP: deposit something, so only one new token position needs to be created + // simply because the test code can't deal with two affected banks right now + send_tx( + solana, + TokenDepositInstruction { + amount: 1, + account, + token_account: payer_mint_accounts[0], + token_authority: payer.clone(), + bank_index: 0, + }, + ) + .await + .unwrap(); + + let send_test_tx = |repay_amount| { + let tokens = tokens.clone(); + async move { + let mut tx = ClientTransaction::new(solana); + tx.add_instruction(HealthRegionBeginInstruction { account }) + .await; + tx.add_instruction(TokenWithdrawInstruction { + amount: 1000, // more than the 1 token that's on the account + allow_borrow: true, + account, + owner, + token_account: payer_mint_accounts[0], + bank_index: 0, + }) + .await; + tx.add_instruction(TokenDepositInstruction { + amount: repay_amount, + account, + token_account: payer_mint_accounts[1], + token_authority: payer.clone(), + bank_index: 0, + }) + .await; + tx.add_instruction(HealthRegionEndInstruction { + account, + affected_bank: Some(tokens[1].bank), + }) + .await; + tx.send().await + } + }; + + // + // TEST: Borrow a lot of token0 without collateral, but repay too little + // + { + send_test_tx(1000).await.unwrap_err(); + let logs = solana.program_log(); + // reaches the End instruction + assert!(logs + .iter() + .any(|line| line.contains("Instruction: HealthRegionEnd"))); + // errors due to health + assert!(logs + .iter() + .any(|line| line.contains("Error Code: HealthMustBePositiveOrIncrease"))); + } + + // + // TEST: Borrow a lot of token0 without collateral, and repay in token1 in the same tx + // + { + let start_payer_mint0 = solana.token_account_balance(payer_mint_accounts[0]).await; + let start_payer_mint1 = solana.token_account_balance(payer_mint_accounts[1]).await; + + send_test_tx(3000).await.unwrap(); + + assert_eq!( + solana.token_account_balance(payer_mint_accounts[0]).await - start_payer_mint0, + 1000 + ); + assert_eq!( + start_payer_mint1 - solana.token_account_balance(payer_mint_accounts[1]).await, + 3000 + ); + assert_eq!( + account_position(solana, account, tokens[0].bank).await, + -999 + ); + assert_eq!( + account_position(solana, account, tokens[1].bank).await, + 3000 + ); + let account_data: MangoAccount = solana.get_account(account).await; + assert_eq!(account_data.in_health_region, 0); + } + + Ok(()) +} diff --git a/programs/mango-v4/tests/test_liq_tokens.rs b/programs/mango-v4/tests/test_liq_tokens.rs index d89a2b59c..591371682 100644 --- a/programs/mango-v4/tests/test_liq_tokens.rs +++ b/programs/mango-v4/tests/test_liq_tokens.rs @@ -7,11 +7,15 @@ use solana_sdk::{signature::Keypair, transport::TransportError}; use mango_v4::instructions::{Serum3OrderType, Serum3SelfTradeBehavior, Serum3Side}; use program_test::*; +use mango_setup::*; + mod program_test; #[tokio::test] async fn test_liq_tokens_force_cancel() -> Result<(), TransportError> { - let context = TestContext::new().await; + 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(); let admin = &Keypair::new(); @@ -24,7 +28,7 @@ async fn test_liq_tokens_force_cancel() -> Result<(), TransportError> { // SETUP: Create a group and an account to fill the vaults // - let mango_setup::GroupWithTokens { group, tokens, .. } = mango_setup::GroupWithTokensConfig { + let GroupWithTokens { group, tokens, .. } = GroupWithTokensConfig { admin, payer, mints, @@ -35,36 +39,7 @@ async fn test_liq_tokens_force_cancel() -> Result<(), TransportError> { let quote_token = &tokens[1]; // deposit some funds, to the vaults aren't empty - let vault_account = send_tx( - solana, - AccountCreateInstruction { - account_num: 2, - token_count: 16, - serum3_count: 8, - perp_count: 8, - perp_oo_count: 8, - group, - owner, - payer, - }, - ) - .await - .unwrap() - .account; - for &token_account in payer_mint_accounts { - send_tx( - solana, - TokenDepositInstruction { - amount: 10000, - account: vault_account, - token_account, - token_authority: payer.clone(), - bank_index: 0, - }, - ) - .await - .unwrap(); - } + create_funded_account(&solana, group, owner, 0, &context.users[1], mints, 10000, 0).await; // // SETUP: Create serum market @@ -94,36 +69,18 @@ async fn test_liq_tokens_force_cancel() -> Result<(), TransportError> { // // SETUP: Make an account and deposit some quote // - let account = send_tx( - solana, - AccountCreateInstruction { - account_num: 0, - token_count: 16, - serum3_count: 8, - perp_count: 8, - perp_oo_count: 8, - group, - owner, - payer, - }, - ) - .await - .unwrap() - .account; - let deposit_amount = 1000; - send_tx( - solana, - TokenDepositInstruction { - amount: deposit_amount, - account, - token_account: payer_mint_accounts[1], - token_authority: payer.clone(), - bank_index: 0, - }, + let account = create_funded_account( + &solana, + group, + owner, + 1, + &context.users[1], + &mints[1..2], + deposit_amount, + 0, ) - .await - .unwrap(); + .await; // // SETUP: Create an open orders account and an order diff --git a/programs/mango-v4/tests/test_margin_trade.rs b/programs/mango-v4/tests/test_margin_trade.rs index be485ab1a..9d3979cc1 100644 --- a/programs/mango-v4/tests/test_margin_trade.rs +++ b/programs/mango-v4/tests/test_margin_trade.rs @@ -6,6 +6,8 @@ use solana_sdk::signature::Signer; use program_test::*; +use mango_setup::*; + mod program_test; // This is an unspecific happy-case test that just runs a few instructions to check @@ -21,7 +23,6 @@ async fn test_margin_trade() -> Result<(), BanksClientError> { let payer = &context.users[1].key; let mints = &context.mints[0..2]; let payer_mint0_account = context.users[1].token_accounts[0]; - let payer_mint1_account = context.users[1].token_accounts[1]; let loan_origination_fee = 0.0005; // higher resolution that the loan_origination_fee for one token @@ -31,7 +32,7 @@ async fn test_margin_trade() -> Result<(), BanksClientError> { // SETUP: Create a group, account, register a token (mint0) // - let mango_setup::GroupWithTokens { group, tokens, .. } = mango_setup::GroupWithTokensConfig { + let GroupWithTokens { group, tokens, .. } = GroupWithTokensConfig { admin, payer, mints, @@ -45,48 +46,17 @@ async fn test_margin_trade() -> Result<(), BanksClientError> { // provide some funds for tokens, so the test user can borrow // let provided_amount = 1000; - - let provider_account = send_tx( - solana, - AccountCreateInstruction { - account_num: 1, - token_count: 16, - serum3_count: 8, - perp_count: 8, - perp_oo_count: 8, - group, - owner, - payer, - }, + create_funded_account( + &solana, + group, + owner, + 1, + &context.users[1], + mints, + provided_amount, + 0, ) - .await - .unwrap() - .account; - - send_tx( - solana, - TokenDepositInstruction { - amount: provided_amount, - account: provider_account, - token_account: payer_mint0_account, - token_authority: payer.clone(), - bank_index: 0, - }, - ) - .await - .unwrap(); - send_tx( - solana, - TokenDepositInstruction { - amount: provided_amount, - account: provider_account, - token_account: payer_mint1_account, - token_authority: payer.clone(), - bank_index: 0, - }, - ) - .await - .unwrap(); + .await; // // create thes test user account diff --git a/programs/mango-v4/tests/test_perp.rs b/programs/mango-v4/tests/test_perp.rs index 64a8c5aef..c7dec62cd 100644 --- a/programs/mango-v4/tests/test_perp.rs +++ b/programs/mango-v4/tests/test_perp.rs @@ -7,6 +7,8 @@ use program_test::*; use solana_program_test::*; use solana_sdk::{signature::Keypair, signer::Signer, transport::TransportError}; +use mango_setup::*; + mod program_test; #[tokio::test] @@ -18,13 +20,12 @@ async fn test_perp() -> Result<(), TransportError> { let owner = &context.users[0].key; let payer = &context.users[1].key; let mints = &context.mints[0..2]; - let payer_mint_accounts = &context.users[1].token_accounts[0..=2]; // // SETUP: Create a group and an account // - let mango_setup::GroupWithTokens { group, tokens, .. } = mango_setup::GroupWithTokensConfig { + let GroupWithTokens { group, tokens, .. } = GroupWithTokensConfig { admin, payer, mints, @@ -32,102 +33,29 @@ async fn test_perp() -> Result<(), TransportError> { .create(solana) .await; - let account_0 = send_tx( - solana, - AccountCreateInstruction { - account_num: 0, - token_count: 16, - serum3_count: 8, - perp_count: 8, - perp_oo_count: 8, - group, - owner, - payer, - }, + let deposit_amount = 1000; + let account_0 = create_funded_account( + &solana, + group, + owner, + 0, + &context.users[1], + mints, + deposit_amount, + 0, ) - .await - .unwrap() - .account; - - let account_1 = send_tx( - solana, - AccountCreateInstruction { - account_num: 1, - token_count: 16, - serum3_count: 8, - perp_count: 8, - perp_oo_count: 8, - group, - owner, - payer, - }, + .await; + let account_1 = create_funded_account( + &solana, + group, + owner, + 1, + &context.users[1], + mints, + deposit_amount, + 0, ) - .await - .unwrap() - .account; - - // - // SETUP: Deposit user funds - // - { - let deposit_amount = 1000; - - send_tx( - solana, - TokenDepositInstruction { - amount: deposit_amount, - account: account_0, - token_account: payer_mint_accounts[0], - token_authority: payer.clone(), - bank_index: 0, - }, - ) - .await - .unwrap(); - - send_tx( - solana, - TokenDepositInstruction { - amount: deposit_amount, - account: account_0, - token_account: payer_mint_accounts[1], - token_authority: payer.clone(), - bank_index: 0, - }, - ) - .await - .unwrap(); - } - - { - let deposit_amount = 1000; - - send_tx( - solana, - TokenDepositInstruction { - amount: deposit_amount, - account: account_1, - token_account: payer_mint_accounts[0], - token_authority: payer.clone(), - bank_index: 0, - }, - ) - .await - .unwrap(); - - send_tx( - solana, - TokenDepositInstruction { - amount: deposit_amount, - account: account_1, - token_account: payer_mint_accounts[1], - token_authority: payer.clone(), - bank_index: 0, - }, - ) - .await - .unwrap(); - } + .await; // // TEST: Create a perp market @@ -204,6 +132,7 @@ async fn test_perp() -> Result<(), TransportError> { ) .await .unwrap(); + check_prev_instruction_post_health(&solana, account_0).await; let order_id_to_cancel = solana .get_account::(account_0) @@ -250,6 +179,7 @@ async fn test_perp() -> Result<(), TransportError> { ) .await .unwrap(); + check_prev_instruction_post_health(&solana, account_0).await; send_tx( solana, @@ -291,6 +221,7 @@ async fn test_perp() -> Result<(), TransportError> { ) .await .unwrap(); + check_prev_instruction_post_health(&solana, account_0).await; send_tx( solana, PerpPlaceOrderInstruction { @@ -311,6 +242,7 @@ async fn test_perp() -> Result<(), TransportError> { ) .await .unwrap(); + check_prev_instruction_post_health(&solana, account_0).await; send_tx( solana, PerpPlaceOrderInstruction { @@ -331,6 +263,7 @@ async fn test_perp() -> Result<(), TransportError> { ) .await .unwrap(); + check_prev_instruction_post_health(&solana, account_0).await; send_tx( solana, @@ -371,6 +304,7 @@ async fn test_perp() -> Result<(), TransportError> { ) .await .unwrap(); + check_prev_instruction_post_health(&solana, account_0).await; send_tx( solana, @@ -392,6 +326,7 @@ async fn test_perp() -> Result<(), TransportError> { ) .await .unwrap(); + check_prev_instruction_post_health(&solana, account_1).await; send_tx( solana, diff --git a/programs/mango-v4/tests/test_position_lifetime.rs b/programs/mango-v4/tests/test_position_lifetime.rs index ca79d613e..1f3448303 100644 --- a/programs/mango-v4/tests/test_position_lifetime.rs +++ b/programs/mango-v4/tests/test_position_lifetime.rs @@ -6,6 +6,8 @@ use solana_sdk::signature::Keypair; use program_test::*; +use crate::mango_setup::*; + mod program_test; // Check opening and closing positions @@ -50,43 +52,18 @@ async fn test_position_lifetime() -> Result<()> { .unwrap() .account; - let funding_account = send_tx( - solana, - AccountCreateInstruction { - account_num: 1, - token_count: 16, - serum3_count: 8, - perp_count: 8, - perp_oo_count: 8, - group, - owner, - payer, - }, + let funding_amount = 1000000; + create_funded_account( + &solana, + group, + owner, + 1, + &context.users[1], + mints, + funding_amount, + 0, ) - .await - .unwrap() - .account; - - // - // SETUP: Put some tokens into the funding account to allow borrowing - // - { - let funding_amount = 1000000; - for &payer_token in payer_mint_accounts { - send_tx( - solana, - TokenDepositInstruction { - amount: funding_amount, - account: funding_account, - token_account: payer_token, - token_authority: payer.clone(), - bank_index: 0, - }, - ) - .await - .unwrap(); - } - } + .await; // // TEST: Deposit and withdraw tokens for all mints diff --git a/programs/mango-v4/tests/test_serum.rs b/programs/mango-v4/tests/test_serum.rs index b7d9b63f9..95d56e1f1 100644 --- a/programs/mango-v4/tests/test_serum.rs +++ b/programs/mango-v4/tests/test_serum.rs @@ -1,29 +1,169 @@ #![cfg(feature = "test-bpf")] +#![allow(dead_code)] use solana_program_test::*; -use solana_sdk::{signature::Keypair, signer::Signer, transport::TransportError}; +use solana_sdk::{pubkey::Pubkey, signature::Keypair, signer::Signer, transport::TransportError}; -use mango_v4::instructions::{Serum3OrderType, Serum3SelfTradeBehavior, Serum3Side}; +use mango_v4::instructions::{ + OpenOrdersSlim, Serum3OrderType, Serum3SelfTradeBehavior, Serum3Side, +}; +use mango_v4::state::Serum3Orders; use program_test::*; mod program_test; +use mango_setup::*; +use std::sync::Arc; + +struct SerumOrderPlacer { + solana: Arc, + serum: Arc, + account: Pubkey, + owner: Keypair, + serum_market: Pubkey, + open_orders: Pubkey, + next_client_order_id: u64, +} + +impl SerumOrderPlacer { + fn inc_client_order_id(&mut self) -> u64 { + let id = self.next_client_order_id; + self.next_client_order_id += 1; + id + } + + async fn find_order_id_for_client_order_id(&self, client_order_id: u64) -> Option<(u128, u64)> { + let open_orders = self.serum.load_open_orders(self.open_orders).await; + for i in 0..128 { + if open_orders.free_slot_bits & (1u128 << i) != 0 { + continue; + } + if open_orders.client_order_ids[i] == client_order_id { + return Some((open_orders.orders[i], client_order_id)); + } + } + None + } + + async fn bid(&mut self, limit_price: f64, max_base: u64) -> Option<(u128, u64)> { + let client_order_id = self.inc_client_order_id(); + 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, + self_trade_behavior: Serum3SelfTradeBehavior::AbortTransaction, + order_type: Serum3OrderType::Limit, + client_order_id, + limit: 10, + account: self.account, + owner: &self.owner, + serum_market: self.serum_market, + }, + ) + .await + .unwrap(); + self.find_order_id_for_client_order_id(client_order_id) + .await + } + + async fn ask(&mut self, limit_price: f64, max_base: u64) -> Option<(u128, u64)> { + let client_order_id = self.inc_client_order_id(); + send_tx( + &self.solana, + Serum3PlaceOrderInstruction { + side: Serum3Side::Ask, + 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, + self_trade_behavior: Serum3SelfTradeBehavior::AbortTransaction, + order_type: Serum3OrderType::Limit, + client_order_id, + limit: 10, + account: self.account, + owner: &self.owner, + serum_market: self.serum_market, + }, + ) + .await + .unwrap(); + self.find_order_id_for_client_order_id(client_order_id) + .await + } + + async fn cancel(&self, order_id: u128) { + let side = { + let open_orders = self.serum.load_open_orders(self.open_orders).await; + let orders = open_orders.orders; + let idx = orders.iter().position(|&v| v == order_id).unwrap(); + if open_orders.is_bid_bits & (1u128 << idx) == 0 { + Serum3Side::Ask + } else { + Serum3Side::Bid + } + }; + send_tx( + &self.solana, + Serum3CancelOrderInstruction { + side, + order_id, + account: self.account, + owner: &self.owner, + serum_market: self.serum_market, + }, + ) + .await + .unwrap(); + } + + async fn settle(&self) { + // to avoid multiple settles looking like the same tx + self.solana.advance_by_slots(1).await; + send_tx( + &self.solana, + Serum3SettleFundsInstruction { + account: self.account, + owner: &self.owner, + serum_market: self.serum_market, + }, + ) + .await + .unwrap(); + } + + async fn mango_serum_orders(&self) -> Serum3Orders { + let account_data = get_mango_account(&self.solana, self.account).await; + let orders = account_data + .all_serum3_orders() + .find(|s| s.open_orders == self.open_orders) + .unwrap(); + orders.clone() + } + + async fn open_orders(&self) -> OpenOrdersSlim { + OpenOrdersSlim::from_oo(&self.serum.load_open_orders(self.open_orders).await) + } +} + #[tokio::test] -async fn test_serum() -> Result<(), TransportError> { - let context = TestContext::new().await; +async fn test_serum_basics() -> 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(); let admin = &Keypair::new(); let owner = &context.users[0].key; let payer = &context.users[1].key; let mints = &context.mints[0..2]; - let payer_mint_accounts = &context.users[1].token_accounts[0..=2]; // // SETUP: Create a group and an account // - let mango_setup::GroupWithTokens { group, tokens, .. } = mango_setup::GroupWithTokensConfig { + let GroupWithTokens { group, tokens, .. } = GroupWithTokensConfig { admin, payer, mints, @@ -33,23 +173,6 @@ async fn test_serum() -> Result<(), TransportError> { let base_token = &tokens[0]; let quote_token = &tokens[1]; - let account = send_tx( - solana, - AccountCreateInstruction { - account_num: 0, - token_count: 16, - serum3_count: 8, - perp_count: 8, - perp_oo_count: 8, - group, - owner, - payer, - }, - ) - .await - .unwrap() - .account; - // // SETUP: Create serum market // @@ -58,39 +181,6 @@ async fn test_serum() -> Result<(), TransportError> { .list_spot_market(&base_token.mint, "e_token.mint) .await; - // - // SETUP: Deposit user funds - // - { - let deposit_amount = 1000; - - send_tx( - solana, - TokenDepositInstruction { - amount: deposit_amount, - account, - token_account: payer_mint_accounts[0], - token_authority: payer.clone(), - bank_index: 0, - }, - ) - .await - .unwrap(); - - send_tx( - solana, - TokenDepositInstruction { - amount: deposit_amount, - account, - token_account: payer_mint_accounts[1], - token_authority: payer.clone(), - bank_index: 0, - }, - ) - .await - .unwrap(); - } - // // TEST: Register a serum market // @@ -111,6 +201,22 @@ async fn test_serum() -> Result<(), TransportError> { .unwrap() .serum_market; + // + // SETUP: Create account + // + let deposit_amount = 1000; + let account = create_funded_account( + &solana, + group, + owner, + 0, + &context.users[1], + mints, + deposit_amount, + 0, + ) + .await; + // // TEST: Create an open orders account // @@ -136,89 +242,74 @@ async fn test_serum() -> Result<(), TransportError> { [(open_orders, 0)] ); + let mut order_placer = SerumOrderPlacer { + solana: solana.clone(), + serum: context.serum.clone(), + account, + owner: owner.clone(), + serum_market, + open_orders, + next_client_order_id: 0, + }; + // // TEST: Place an order // - send_tx( - solana, - Serum3PlaceOrderInstruction { - side: Serum3Side::Bid, - limit_price: 10, // in quote_lot (10) per base lot (100) - max_base_qty: 1, // in base lot (100) - max_native_quote_qty_including_fees: 100, - self_trade_behavior: Serum3SelfTradeBehavior::DecrementTake, - order_type: Serum3OrderType::Limit, - client_order_id: 0, - limit: 10, - account, - owner, - serum_market, - }, - ) - .await - .unwrap(); + let (order_id, _) = order_placer.bid(1.0, 100).await.unwrap(); + check_prev_instruction_post_health(&solana, account).await; let native0 = account_position(solana, account, base_token.bank).await; let native1 = account_position(solana, account, quote_token.bank).await; assert_eq!(native0, 1000); assert_eq!(native1, 900); - // get the order id - let open_orders_bytes = solana.get_account_data(open_orders).await.unwrap(); - let open_orders_data: &serum_dex::state::OpenOrders = bytemuck::from_bytes( - &open_orders_bytes[5..5 + std::mem::size_of::()], - ); - let order_id = open_orders_data.orders[0]; + let account_data = get_mango_account(solana, account).await; + assert_eq!(account_data.token_position_by_raw_index(0).in_use_count, 1); + assert_eq!(account_data.token_position_by_raw_index(1).in_use_count, 1); + assert_eq!(account_data.token_position_by_raw_index(2).in_use_count, 0); + let serum_orders = account_data.serum3_orders_by_raw_index(0); + assert_eq!(serum_orders.base_borrows_without_fee, 0); + assert_eq!(serum_orders.quote_borrows_without_fee, 0); + assert!(order_id != 0); // // TEST: Cancel the order // - send_tx( - solana, - Serum3CancelOrderInstruction { - side: Serum3Side::Bid, - order_id, - account, - owner, - serum_market, - }, - ) - .await - .unwrap(); + order_placer.cancel(order_id).await; // // TEST: Settle, moving the freed up funds back // - send_tx( - solana, - Serum3SettleFundsInstruction { - account, - owner, - serum_market, - }, - ) - .await - .unwrap(); + order_placer.settle().await; let native0 = account_position(solana, account, base_token.bank).await; let native1 = account_position(solana, account, quote_token.bank).await; assert_eq!(native0, 1000); assert_eq!(native1, 1000); + // Process events such that the OutEvent deactivates the closed order on open_orders + context + .serum + .consume_spot_events(&serum_market_cookie, &[open_orders]) + .await; + // close oo account - // TODO: custom program error: 0x2a TooManyOpenOrders https://github.com/project-serum/serum-dex/blob/master/dex/src/error.rs#L88 - // send_tx( - // solana, - // Serum3CloseOpenOrdersInstruction { - // account, - // serum_market, - // owner, - // sol_destination: payer.pubkey(), - // }, - // ) - // .await - // .unwrap(); + send_tx( + solana, + Serum3CloseOpenOrdersInstruction { + account, + serum_market, + owner, + sol_destination: payer.pubkey(), + }, + ) + .await + .unwrap(); + + let account_data = get_mango_account(solana, account).await; + assert_eq!(account_data.token_position_by_raw_index(0).in_use_count, 0); + assert_eq!(account_data.token_position_by_raw_index(1).in_use_count, 0); // deregister serum3 market send_tx( @@ -235,3 +326,257 @@ async fn test_serum() -> Result<(), TransportError> { Ok(()) } + +#[tokio::test] +async fn test_serum_loan_origination_fees() -> 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(); + + let admin = &Keypair::new(); + let owner = &context.users[0].key; + let payer = &context.users[1].key; + let mints = &context.mints[0..3]; + + // + // SETUP: Create a group and an account + // + + let GroupWithTokens { group, tokens, .. } = GroupWithTokensConfig { + admin, + payer, + mints, + } + .create(solana) + .await; + let base_token = &tokens[0]; + let base_bank = base_token.bank; + let quote_token = &tokens[1]; + let quote_bank = quote_token.bank; + + // + // SETUP: Create serum market + // + let serum_market_cookie = context + .serum + .list_spot_market(&base_token.mint, "e_token.mint) + .await; + + // + // SETUP: Register a serum market + // + let serum_market = send_tx( + solana, + Serum3RegisterMarketInstruction { + group, + admin, + serum_program: context.serum.program_id, + serum_market_external: serum_market_cookie.market, + market_index: 0, + base_bank: base_token.bank, + quote_bank: quote_token.bank, + payer, + }, + ) + .await + .unwrap() + .serum_market; + + // + // SETUP: Create accounts + // + let deposit_amount = 180000; + let account = create_funded_account( + &solana, + group, + owner, + 0, + &context.users[1], + mints, + deposit_amount, + 0, + ) + .await; + let account2 = create_funded_account( + &solana, + group, + owner, + 2, + &context.users[1], + mints, + deposit_amount, + 0, + ) + .await; + // to have enough funds in the vaults + create_funded_account( + &solana, + group, + owner, + 3, + &context.users[1], + mints, + 10000000, + 0, + ) + .await; + + let open_orders = send_tx( + solana, + Serum3CreateOpenOrdersInstruction { + account, + serum_market, + owner, + payer, + }, + ) + .await + .unwrap() + .open_orders; + + let open_orders2 = send_tx( + solana, + Serum3CreateOpenOrdersInstruction { + account: account2, + serum_market, + owner, + payer, + }, + ) + .await + .unwrap() + .open_orders; + + let mut order_placer = SerumOrderPlacer { + solana: solana.clone(), + serum: context.serum.clone(), + account, + owner: owner.clone(), + serum_market, + open_orders, + next_client_order_id: 0, + }; + let mut order_placer2 = SerumOrderPlacer { + solana: solana.clone(), + serum: context.serum.clone(), + account: account2, + owner: owner.clone(), + serum_market, + open_orders: open_orders2, + next_client_order_id: 100000, + }; + + // + // 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 (ask_order_id, _) = order_placer.ask(2.0, 200000).await.unwrap(); + + let o = order_placer.mango_serum_orders().await; + assert_eq!(o.base_borrows_without_fee, 19999); // rounded + assert_eq!(o.quote_borrows_without_fee, 19999); + + order_placer.cancel(bid_order_id).await; + order_placer.cancel(ask_order_id).await; + + let o = order_placer.mango_serum_orders().await; + assert_eq!(o.base_borrows_without_fee, 19999); // unchanged + 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 (ask_order_id, _) = order_placer.ask(2.0, 300000).await.unwrap(); + + let o = order_placer.mango_serum_orders().await; + assert_eq!(o.base_borrows_without_fee, 119998); // rounded + assert_eq!(o.quote_borrows_without_fee, 29998); + + order_placer.cancel(bid_order_id).await; + order_placer.cancel(ask_order_id).await; + + // returns all the funds + order_placer.settle().await; + + let o = order_placer.mango_serum_orders().await; + assert_eq!(o.base_borrows_without_fee, 0); + assert_eq!(o.quote_borrows_without_fee, 0); + + assert_eq!( + account_position(solana, account, quote_bank).await, + deposit_amount as i64 + ); + assert_eq!( + account_position(solana, account, base_bank).await, + deposit_amount as i64 + ); + + // consume all the out events from the cancels + context + .serum + .consume_spot_events(&serum_market_cookie, &[open_orders]) + .await; + } + + let without_serum_taker_fee = |amount: i64| (amount as f64 * (1.0 - 0.0022)).trunc() as i64; + let serum_maker_rebate = |amount: i64| (amount as f64 * 0.0003).round() as i64; + let loan_origination_fee = |amount: i64| (amount as f64 * 0.0005).trunc() as i64; + + // + // TEST: Order execution and settling charges borrow fee + // + { + let deposit_amount = deposit_amount as i64; + let bid_amount = 200000; + let ask_amount = 210000; + let fill_amount = 200000; + + // account2 has an order on the book + order_placer2.bid(1.0, bid_amount as u64).await.unwrap(); + + // account takes + order_placer.ask(1.0, ask_amount as u64).await.unwrap(); + order_placer.settle().await; + + let o = order_placer.mango_serum_orders().await; + // parts of the order ended up on the book an may cause loan origination fees later + assert_eq!( + o.base_borrows_without_fee, + (ask_amount - fill_amount) as u64 + ); + assert_eq!(o.quote_borrows_without_fee, 0); + + assert_eq!( + account_position(solana, account, quote_bank).await, + deposit_amount + without_serum_taker_fee(fill_amount) + ); + assert_eq!( + account_position(solana, account, base_bank).await, + deposit_amount - ask_amount - loan_origination_fee(fill_amount - deposit_amount) + ); + + // check account2 balances too + context + .serum + .consume_spot_events(&serum_market_cookie, &[open_orders, open_orders2]) + .await; + order_placer2.settle().await; + + let o = order_placer2.mango_serum_orders().await; + assert_eq!(o.base_borrows_without_fee, 0); + assert_eq!(o.quote_borrows_without_fee, 0); + + assert_eq!( + account_position(solana, account2, base_bank).await, + deposit_amount + fill_amount + ); + assert_eq!( + account_position(solana, account2, quote_bank).await, + deposit_amount - fill_amount - loan_origination_fee(fill_amount - deposit_amount) + + serum_maker_rebate(fill_amount) + ); + } + + Ok(()) +} diff --git a/programs/mango-v4/tests/test_token_update_index_and_rate.rs b/programs/mango-v4/tests/test_token_update_index_and_rate.rs index 635e1ae16..4164ac5f6 100644 --- a/programs/mango-v4/tests/test_token_update_index_and_rate.rs +++ b/programs/mango-v4/tests/test_token_update_index_and_rate.rs @@ -4,6 +4,7 @@ use mango_v4::state::*; use solana_program_test::*; use solana_sdk::{signature::Keypair, transport::TransportError}; +use mango_setup::*; use program_test::*; mod program_test; @@ -17,13 +18,12 @@ async fn test_token_update_index_and_rate() -> Result<(), TransportError> { let owner = &context.users[0].key; let payer = &context.users[1].key; let mints = &context.mints[0..2]; - let payer_mint_accounts = &context.users[1].token_accounts[0..2]; // // SETUP: Create a group and an account to fill the vaults // - let mango_setup::GroupWithTokens { group, tokens, .. } = mango_setup::GroupWithTokensConfig { + let GroupWithTokens { group, tokens, .. } = GroupWithTokensConfig { admin, payer, mints, @@ -32,66 +32,18 @@ async fn test_token_update_index_and_rate() -> Result<(), TransportError> { .await; // deposit some funds, to the vaults aren't empty - let deposit_account = send_tx( - solana, - AccountCreateInstruction { - account_num: 0, - token_count: 16, - serum3_count: 8, - perp_count: 8, - perp_oo_count: 8, - group, - owner, - payer, - }, + create_funded_account(&solana, group, owner, 0, &context.users[1], mints, 10000, 0).await; + let withdraw_account = create_funded_account( + &solana, + group, + owner, + 1, + &context.users[1], + &mints[1..2], + 100000, + 0, ) - .await - .unwrap() - .account; - for &token_account in payer_mint_accounts { - send_tx( - solana, - TokenDepositInstruction { - amount: 10000, - account: deposit_account, - token_account, - token_authority: payer.clone(), - bank_index: 0, - }, - ) - .await - .unwrap(); - } - - let withdraw_account = send_tx( - solana, - AccountCreateInstruction { - account_num: 1, - token_count: 16, - serum3_count: 8, - perp_count: 8, - perp_oo_count: 8, - group, - owner, - payer, - }, - ) - .await - .unwrap() - .account; - - send_tx( - solana, - TokenDepositInstruction { - amount: 100000, - account: withdraw_account, - token_account: payer_mint_accounts[1], - token_authority: payer.clone(), - bank_index: 0, - }, - ) - .await - .unwrap(); + .await; send_tx( solana, diff --git a/ts/client/src/accounts/I80F48.ts b/ts/client/src/accounts/I80F48.ts index ec920cccc..978a98848 100644 --- a/ts/client/src/accounts/I80F48.ts +++ b/ts/client/src/accounts/I80F48.ts @@ -197,16 +197,16 @@ export class I80F48 { return this.data.cmp(x.getData()); } neg(): I80F48 { - return this.mul(NEG_ONE_I80F48); + return this.mul(_NEG_ONE_I80F48); } isPos(): boolean { - return this.gt(ZERO_I80F48); + return this.gt(_ZERO_I80F48); } isNeg(): boolean { return this.data.isNeg(); } isZero(): boolean { - return this.eq(ZERO_I80F48); + return this.eq(_ZERO_I80F48); } min(x: I80F48): I80F48 { return this.lte(x) ? this : x; @@ -224,10 +224,22 @@ export class I80F48 { } /** @internal */ -export const ONE_I80F48 = I80F48.fromString('1'); +const _ZERO_I80F48 = I80F48.fromNumber(0); /** @internal */ -export const ZERO_I80F48 = I80F48.fromString('0'); -/** @internal */ -export const NEG_ONE_I80F48 = I80F48.fromString('-1'); -export const HUNDRED_I80F48 = I80F48.fromString('100'); -export const MAX_I80F48 = new I80F48(I80F48.MAX_BN); +const _NEG_ONE_I80F48 = I80F48.fromNumber(-1); + +export function ONE_I80F48(): I80F48 { + return I80F48.fromNumber(1); +} + +export function ZERO_I80F48(): I80F48 { + return I80F48.fromNumber(0); +} + +export function HUNDRED_I80F48(): I80F48 { + return I80F48.fromNumber(100); +} + +export function MAX_I80F48(): I80F48 { + return new I80F48(I80F48.MAX_BN); +} diff --git a/ts/client/src/accounts/bank.ts b/ts/client/src/accounts/bank.ts index 8a081c2ed..ef1c4078b 100644 --- a/ts/client/src/accounts/bank.ts +++ b/ts/client/src/accounts/bank.ts @@ -286,8 +286,8 @@ export class Bank { const totalBorrows = this.nativeBorrows(); const totalDeposits = this.nativeDeposits(); - if (totalDeposits.eq(ZERO_I80F48) && totalBorrows.eq(ZERO_I80F48)) { - return ZERO_I80F48; + if (totalDeposits.isZero() && totalBorrows.isZero()) { + return ZERO_I80F48(); } if (totalDeposits.lte(totalBorrows)) { return this.maxRate; @@ -329,9 +329,9 @@ export class Bank { const totalBorrows = this.nativeBorrows(); const totalDeposits = this.nativeDeposits(); - if (totalDeposits.eq(ZERO_I80F48) && totalBorrows.eq(ZERO_I80F48)) { - return ZERO_I80F48; - } else if (totalDeposits.eq(ZERO_I80F48)) { + if (totalDeposits.isZero() && totalBorrows.isZero()) { + return ZERO_I80F48(); + } else if (totalDeposits.isZero()) { return this.maxRate; } diff --git a/ts/client/src/accounts/group.ts b/ts/client/src/accounts/group.ts index d4b83c445..a04910b96 100644 --- a/ts/client/src/accounts/group.ts +++ b/ts/client/src/accounts/group.ts @@ -1,6 +1,11 @@ import { BorshAccountsCoder } from '@project-serum/anchor'; import { coder } from '@project-serum/anchor/dist/cjs/spl/token'; -import { Market } from '@project-serum/serum'; +import { + getFeeRates, + getFeeTier, + Market, + Orderbook, +} from '@project-serum/serum'; import { parsePriceData, PriceData } from '@pythnetwork/client'; import { PublicKey } from '@solana/web3.js'; import BN from 'bn.js'; @@ -68,8 +73,9 @@ export class Group { public banksMapByName: Map, public banksMapByMint: Map, public banksMapByTokenIndex: Map, - public serum3MarketsMap: Map, + public serum3MarketsMapByExternal: Map, public serum3MarketExternalsMap: Map, + // TODO rethink key public perpMarketsMap: Map, public mintInfosMapByTokenIndex: Map, public mintInfosMapByMint: Map, @@ -77,12 +83,6 @@ export class Group { public vaultAmountsMap: Map, ) {} - public findSerum3Market(marketIndex: number): Serum3Market | undefined { - return Array.from(this.serum3MarketsMap.values()).find( - (serum3Market) => serum3Market.marketIndex === marketIndex, - ); - } - public async reloadAll(client: MangoClient) { let ids: Id | undefined = undefined; @@ -180,14 +180,17 @@ export class Group { serum3Markets = await client.serum3GetMarkets(this); } - this.serum3MarketsMap = new Map( - serum3Markets.map((serum3Market) => [serum3Market.name, serum3Market]), + this.serum3MarketsMapByExternal = new Map( + serum3Markets.map((serum3Market) => [ + serum3Market.serumMarketExternal.toBase58(), + serum3Market, + ]), ); } public async reloadSerum3ExternalMarkets(client: MangoClient, ids?: Id) { const externalMarkets = await Promise.all( - Array.from(this.serum3MarketsMap.values()).map((serum3Market) => + Array.from(this.serum3MarketsMapByExternal.values()).map((serum3Market) => Market.load( client.program.provider.connection, serum3Market.serumMarketExternal, @@ -198,10 +201,12 @@ export class Group { ); this.serum3MarketExternalsMap = new Map( - Array.from(this.serum3MarketsMap.values()).map((serum3Market, index) => [ - serum3Market.name, - externalMarkets[index], - ]), + Array.from(this.serum3MarketsMapByExternal.values()).map( + (serum3Market, index) => [ + serum3Market.serumMarketExternal.toBase58(), + externalMarkets[index], + ], + ), ); } @@ -237,7 +242,7 @@ export class Group { for (const [index, price] of prices.entries()) { for (const bank of banks[index]) { if (bank.name === 'USDC') { - bank.price = ONE_I80F48; + bank.price = ONE_I80F48(); bank.uiPrice = 1; } else { // TODO: Implement switchboard oracle type @@ -299,14 +304,14 @@ export class Group { ); } - public getMintDecimals(mintPk: PublicKey) { + public getMintDecimals(mintPk: PublicKey): number { const banks = this.banksMapByMint.get(mintPk.toString()); if (!banks) throw new Error(`Unable to find mint decimals for ${mintPk.toString()}`); return banks[0].mintDecimals; } - public getFirstBankByMint(mintPk: PublicKey) { + public getFirstBankByMint(mintPk: PublicKey): Bank { const banks = this.banksMapByMint.get(mintPk.toString()); if (!banks) throw new Error(`Unable to find bank for ${mintPk.toString()}`); return banks[0]; @@ -340,6 +345,49 @@ export class Group { return I80F48.fromNumber(totalAmount); } + public findSerum3Market(marketIndex: number): Serum3Market | undefined { + return Array.from(this.serum3MarketsMapByExternal.values()).find( + (serum3Market) => serum3Market.marketIndex === marketIndex, + ); + } + + public async loadSerum3BidsForMarket( + client: MangoClient, + externalMarketPk: PublicKey, + ): Promise { + const serum3Market = this.serum3MarketsMapByExternal.get( + externalMarketPk.toBase58(), + ); + if (!serum3Market) { + throw new Error( + `Unable to find mint serum3Market for ${externalMarketPk.toString()}`, + ); + } + return await serum3Market.loadBids(client, this); + } + + public async loadSerum3AsksForMarket( + client: MangoClient, + externalMarketPk: PublicKey, + ): Promise { + const serum3Market = this.serum3MarketsMapByExternal.get( + externalMarketPk.toBase58(), + ); + if (!serum3Market) { + throw new Error( + `Unable to find mint serum3Market for ${externalMarketPk.toString()}`, + ); + } + return await serum3Market.loadAsks(client, this); + } + + public getFeeRate(maker = true) { + // TODO: fetch msrm/srm vault balance + const feeTier = getFeeTier(0, 0); + const rates = getFeeRates(feeTier); + return maker ? rates.maker : rates.taker; + } + /** * * @param mintPk diff --git a/ts/client/src/accounts/healthCache.ts b/ts/client/src/accounts/healthCache.ts index bc6202293..8139d9b65 100644 --- a/ts/client/src/accounts/healthCache.ts +++ b/ts/client/src/accounts/healthCache.ts @@ -11,6 +11,7 @@ import { ZERO_I80F48, } from './I80F48'; import { HealthType } from './mangoAccount'; +import { Serum3Market, Serum3Side } from './serum3'; // â–‘â–‘â–‘â–‘ // @@ -37,42 +38,46 @@ import { HealthType } from './mangoAccount'; // warning: this code is copy pasta from rust, keep in sync with health.rs export class HealthCache { - tokenInfos: TokenInfo[]; - serum3Infos: Serum3Info[]; - perpInfos: PerpInfo[]; + constructor( + public tokenInfos: TokenInfo[], + public serum3Infos: Serum3Info[], + public perpInfos: PerpInfo[], + ) {} - constructor(dto: HealthCacheDto) { - this.tokenInfos = dto.tokenInfos.map((dto) => TokenInfo.fromDto(dto)); - this.serum3Infos = dto.serum3Infos.map((dto) => new Serum3Info(dto)); - this.perpInfos = dto.perpInfos.map((dto) => new PerpInfo(dto)); + static fromDto(dto) { + return new HealthCache( + dto.tokenInfos.map((dto) => TokenInfo.fromDto(dto)), + dto.serum3Infos.map((dto) => Serum3Info.fromDto(dto)), + dto.perpInfos.map((dto) => new PerpInfo(dto)), + ); } public health(healthType: HealthType): I80F48 { - let health = ZERO_I80F48; + const health = ZERO_I80F48(); for (const tokenInfo of this.tokenInfos) { const contrib = tokenInfo.healthContribution(healthType); - health = health.add(contrib); + health.iadd(contrib); } for (const serum3Info of this.serum3Infos) { const contrib = serum3Info.healthContribution( healthType, this.tokenInfos, ); - health = health.add(contrib); + health.iadd(contrib); } for (const perpInfo of this.perpInfos) { const contrib = perpInfo.healthContribution(healthType); - health = health.add(contrib); + health.iadd(contrib); } return health; } public assets(healthType: HealthType): I80F48 { - let assets = ZERO_I80F48; + const assets = ZERO_I80F48(); for (const tokenInfo of this.tokenInfos) { const contrib = tokenInfo.healthContribution(healthType); if (contrib.isPos()) { - assets = assets.add(contrib); + assets.iadd(contrib); } } for (const serum3Info of this.serum3Infos) { @@ -81,24 +86,24 @@ export class HealthCache { this.tokenInfos, ); if (contrib.isPos()) { - assets = assets.add(contrib); + assets.iadd(contrib); } } for (const perpInfo of this.perpInfos) { const contrib = perpInfo.healthContribution(healthType); if (contrib.isPos()) { - assets = assets.add(contrib); + assets.iadd(contrib); } } return assets; } public liabs(healthType: HealthType): I80F48 { - let liabs = ZERO_I80F48; + const liabs = ZERO_I80F48(); for (const tokenInfo of this.tokenInfos) { const contrib = tokenInfo.healthContribution(healthType); if (contrib.isNeg()) { - liabs = liabs.sub(contrib); + liabs.isub(contrib); } } for (const serum3Info of this.serum3Infos) { @@ -107,28 +112,28 @@ export class HealthCache { this.tokenInfos, ); if (contrib.isNeg()) { - liabs = liabs.sub(contrib); + liabs.isub(contrib); } } for (const perpInfo of this.perpInfos) { const contrib = perpInfo.healthContribution(healthType); if (contrib.isNeg()) { - liabs = liabs.sub(contrib); + liabs.isub(contrib); } } return liabs; } public healthRatio(healthType: HealthType): I80F48 { - let assets = ZERO_I80F48; - let liabs = ZERO_I80F48; + const assets = ZERO_I80F48(); + const liabs = ZERO_I80F48(); for (const tokenInfo of this.tokenInfos) { const contrib = tokenInfo.healthContribution(healthType); if (contrib.isPos()) { - assets = assets.add(contrib); + assets.iadd(contrib); } else { - liabs = liabs.sub(contrib); + liabs.isub(contrib); } } for (const serum3Info of this.serum3Infos) { @@ -137,24 +142,24 @@ export class HealthCache { this.tokenInfos, ); if (contrib.isPos()) { - assets = assets.add(contrib); + assets.iadd(contrib); } else { - liabs = liabs.sub(contrib); + liabs.isub(contrib); } } for (const perpInfo of this.perpInfos) { const contrib = perpInfo.healthContribution(healthType); if (contrib.isPos()) { - assets = assets.add(contrib); + assets.iadd(contrib); } else { - liabs = liabs.sub(contrib); + liabs.isub(contrib); } } if (liabs.isPos()) { - return HUNDRED_I80F48.mul(assets.sub(liabs).div(liabs)); + return HUNDRED_I80F48().mul(assets.sub(liabs).div(liabs)); } else { - return MAX_I80F48; + return MAX_I80F48(); } } @@ -172,10 +177,50 @@ export class HealthCache { return this.findTokenInfoIndex(bank.tokenIndex); } - private static logHealthCache(debug: string, healthCache: HealthCache) { - console.log(debug); + adjustSerum3Reserved( + // todo change indices to types from numbers + marketIndex: number, + baseTokenIndex: number, + reservedBaseChange: I80F48, + freeBaseChange: I80F48, + quoteTokenIndex: number, + reservedQuoteChange: I80F48, + freeQuoteChange: I80F48, + ) { + const baseEntryIndex = this.findTokenInfoIndex(baseTokenIndex); + const quoteEntryIndex = this.findTokenInfoIndex(quoteTokenIndex); + + const baseEntry = this.tokenInfos[baseEntryIndex]; + const reservedAmount = reservedBaseChange.mul(baseEntry.oraclePrice); + + const quoteEntry = this.tokenInfos[quoteEntryIndex]; + reservedAmount.iadd(reservedQuoteChange.mul(quoteEntry.oraclePrice)); + + // Apply it to the tokens + baseEntry.serum3MaxReserved.iadd(reservedAmount); + baseEntry.balance.iadd(freeBaseChange.mul(baseEntry.oraclePrice)); + quoteEntry.serum3MaxReserved.iadd(reservedAmount); + quoteEntry.balance.iadd(freeQuoteChange.mul(quoteEntry.oraclePrice)); + + // Apply it to the serum3 info + const serum3Info = this.serum3Infos.find( + (serum3Info) => serum3Info.marketIndex === marketIndex, + ); + if (!serum3Info) { + throw new Error( + `Serum3Info not found for market with index ${marketIndex}`, + ); + } + serum3Info.reserved = serum3Info.reserved.add(reservedAmount); + } + + public static logHealthCache(debug: string, healthCache: HealthCache) { + if (debug) console.log(debug); for (const token of healthCache.tokenInfos) { - console.log(`${token.toString()}`); + console.log(` ${token.toString()}`); + } + for (const serum3Info of healthCache.serum3Infos) { + console.log(` ${serum3Info.toString(healthCache.tokenInfos)}`); } console.log( ` assets ${healthCache.assets( @@ -209,14 +254,132 @@ export class HealthCache { throw new Error( `Oracle price not loaded for ${change.mintPk.toString()}`, ); - adjustedCache.tokenInfos[changeIndex].balance = adjustedCache.tokenInfos[ - changeIndex - ].balance.add(change.nativeTokenAmount.mul(bank.price)); + adjustedCache.tokenInfos[changeIndex].balance.iadd( + change.nativeTokenAmount.mul(bank.price), + ); } // HealthCache.logHealthCache('afterChange', adjustedCache); return adjustedCache.healthRatio(healthType); } + simHealthRatioWithSerum3BidChanges( + group: Group, + bidNativeQuoteAmount: I80F48, + serum3Market: Serum3Market, + healthType: HealthType = HealthType.init, + ): I80F48 { + const adjustedCache: HealthCache = _.cloneDeep(this); + const quoteBank = group.getFirstBankByTokenIndex( + serum3Market.quoteTokenIndex, + ); + if (!quoteBank) { + throw new Error(`No bank for index ${serum3Market.quoteTokenIndex}`); + } + const quoteIndex = adjustedCache.getOrCreateTokenInfoIndex(quoteBank); + const quote = adjustedCache.tokenInfos[quoteIndex]; + + // Move token balance to reserved funds in open orders, + // essentially simulating a place order + + // Reduce token balance for quote + adjustedCache.tokenInfos[quoteIndex].balance.isub( + bidNativeQuoteAmount.mul(quote.oraclePrice), + ); + + // Increase reserved in Serum3Info for quote + adjustedCache.adjustSerum3Reserved( + serum3Market.marketIndex, + serum3Market.baseTokenIndex, + ZERO_I80F48(), + ZERO_I80F48(), + serum3Market.quoteTokenIndex, + bidNativeQuoteAmount, + ZERO_I80F48(), + ); + return adjustedCache.healthRatio(healthType); + } + + simHealthRatioWithSerum3AskChanges( + group: Group, + askNativeBaseAmount: I80F48, + serum3Market: Serum3Market, + healthType: HealthType = HealthType.init, + ): I80F48 { + const adjustedCache: HealthCache = _.cloneDeep(this); + const baseBank = group.getFirstBankByTokenIndex( + serum3Market.baseTokenIndex, + ); + if (!baseBank) { + throw new Error(`No bank for index ${serum3Market.quoteTokenIndex}`); + } + const baseIndex = adjustedCache.getOrCreateTokenInfoIndex(baseBank); + const base = adjustedCache.tokenInfos[baseIndex]; + + // Move token balance to reserved funds in open orders, + // essentially simulating a place order + + // Reduce token balance for base + adjustedCache.tokenInfos[baseIndex].balance.isub( + askNativeBaseAmount.mul(base.oraclePrice), + ); + + // Increase reserved in Serum3Info for base + adjustedCache.adjustSerum3Reserved( + serum3Market.marketIndex, + serum3Market.baseTokenIndex, + askNativeBaseAmount, + ZERO_I80F48(), + serum3Market.quoteTokenIndex, + ZERO_I80F48(), + ZERO_I80F48(), + ); + return adjustedCache.healthRatio(healthType); + } + + private static binaryApproximationSearch( + left: I80F48, + leftRatio: I80F48, + right: I80F48, + rightRatio: I80F48, + targetRatio: I80F48, + healthRatioAfterActionFn: (I80F48) => I80F48, + ) { + const maxIterations = 40; + // TODO: make relative to health ratio decimals? Might be over engineering + const targetError = I80F48.fromNumber(0.001); + + if ( + (leftRatio.sub(targetRatio).isPos() && + rightRatio.sub(targetRatio).isPos()) || + (leftRatio.sub(targetRatio).isNeg() && + rightRatio.sub(targetRatio).isNeg()) + ) { + throw new Error( + `internal error: left ${leftRatio.toNumber()} and right ${rightRatio.toNumber()} don't contain the target value ${targetRatio.toNumber()}`, + ); + } + + let newAmount; + for (const key of Array(maxIterations).fill(0).keys()) { + newAmount = left.add(right).mul(I80F48.fromNumber(0.5)); + const newAmountRatio = healthRatioAfterActionFn(newAmount); + const error = newAmountRatio.sub(targetRatio); + if (error.isPos() && error.lt(targetError)) { + return newAmount; + } + if (newAmountRatio.gt(targetRatio) != rightRatio.gt(targetRatio)) { + left = newAmount; + } else { + right = newAmount; + rightRatio = newAmountRatio; + } + } + console.error( + `Unable to get targetRatio within ${maxIterations} iterations`, + ); + return newAmount; + } + getMaxSourceForTokenSwap( group: Group, sourceMintPk: PublicKey, @@ -227,20 +390,20 @@ export class HealthCache { const targetBank: Bank = group.getFirstBankByMint(targetMintPk); if (sourceMintPk.equals(targetMintPk)) { - return ZERO_I80F48; + return ZERO_I80F48(); } - if (!sourceBank.price || sourceBank.price.lte(ZERO_I80F48)) { - return ZERO_I80F48; + if (!sourceBank.price || sourceBank.price.lte(ZERO_I80F48())) { + return ZERO_I80F48(); } if ( sourceBank.initLiabWeight .sub(targetBank.initAssetWeight) .abs() - .lte(ZERO_I80F48) + .lte(ZERO_I80F48()) ) { - return ZERO_I80F48; + return ZERO_I80F48(); } // The health_ratio is a nonlinear based on swap amount. @@ -252,8 +415,8 @@ export class HealthCache { // - be careful about finding the minRatio point: the function isn't convex const initialRatio = this.healthRatio(HealthType.init); - if (initialRatio.lte(ZERO_I80F48)) { - return ZERO_I80F48; + if (initialRatio.lte(ZERO_I80F48())) { + return ZERO_I80F48(); } const healthCacheClone: HealthCache = _.cloneDeep(this); @@ -271,10 +434,8 @@ export class HealthCache { function cacheAfterSwap(amount: I80F48) { const adjustedCache: HealthCache = _.cloneDeep(healthCacheClone); // HealthCache.logHealthCache('beforeSwap', adjustedCache); - adjustedCache.tokenInfos[sourceIndex].balance = - adjustedCache.tokenInfos[sourceIndex].balance.sub(amount); - adjustedCache.tokenInfos[targetIndex].balance = - adjustedCache.tokenInfos[targetIndex].balance.add(amount); + adjustedCache.tokenInfos[sourceIndex].balance.isub(amount); + adjustedCache.tokenInfos[targetIndex].balance.iadd(amount); // HealthCache.logHealthCache('afterSwap', adjustedCache); return adjustedCache; } @@ -285,60 +446,16 @@ export class HealthCache { const point0Amount = source.balance .min(target.balance.neg()) - .max(ZERO_I80F48); + .max(ZERO_I80F48()); const point1Amount = source.balance .max(target.balance.neg()) - .max(ZERO_I80F48); + .max(ZERO_I80F48()); const cache0 = cacheAfterSwap(point0Amount); const point0Ratio = cache0.healthRatio(HealthType.init); - const point0Health = cache0.health(HealthType.init); const cache1 = cacheAfterSwap(point1Amount); const point1Ratio = cache1.healthRatio(HealthType.init); const point1Health = cache1.health(HealthType.init); - function binaryApproximationSearch( - left: I80F48, - leftRatio: I80F48, - right: I80F48, - rightRatio: I80F48, - targetRatio: I80F48, - ) { - const maxIterations = 20; - // TODO: make relative to health ratio decimals? Might be over engineering - const targetError = I80F48.fromString('0.001'); - - if ( - (leftRatio.sub(targetRatio).isPos() && - rightRatio.sub(targetRatio).isPos()) || - (leftRatio.sub(targetRatio).isNeg() && - rightRatio.sub(targetRatio).isNeg()) - ) { - throw new Error( - `internal error: left ${leftRatio.toNumber()} and right ${rightRatio.toNumber()} don't contain the target value ${targetRatio.toNumber()}`, - ); - } - - let newAmount; - for (const key of Array(maxIterations).fill(0).keys()) { - newAmount = left.add(right).mul(I80F48.fromString('0.5')); - const newAmountRatio = healthRatioAfterSwap(newAmount); - const error = newAmountRatio.sub(targetRatio); - if (error.isPos() && error.lt(targetError)) { - return newAmount; - } - if (newAmountRatio.gt(targetRatio) != rightRatio.gt(targetRatio)) { - left = newAmount; - } else { - right = newAmount; - rightRatio = newAmountRatio; - } - } - console.error( - `Unable to get targetRatio within ${maxIterations} iterations`, - ); - return newAmount; - } - let amount: I80F48; if ( @@ -356,43 +473,37 @@ export class HealthCache { } else if (point1Ratio.gt(initialRatio)) { amount = point1Amount; } else { - amount = ZERO_I80F48; + amount = ZERO_I80F48(); } } else if (point1Ratio.gte(minRatio)) { // If point1Ratio is still bigger than minRatio, the target amount must be >point1Amount // search to the right of point1Amount: but how far? // At point1, source.balance < 0 and target.balance > 0, so use a simple estimation for // zero health: health - source_liab_weight * a + target_asset_weight * a = 0. - if (point1Health.lte(ZERO_I80F48)) { - return ZERO_I80F48; + if (point1Health.lte(ZERO_I80F48())) { + return ZERO_I80F48(); } const zeroHealthAmount = point1Amount.add( point1Health.div(source.initLiabWeight.sub(target.initAssetWeight)), ); - // console.log(`point1Amount ${point1Amount}`); - // console.log(`point1Health ${point1Health}`); - // console.log(`point1Ratio ${point1Ratio}`); - // console.log(`point0Amount ${point0Amount}`); - // console.log(`point0Health ${point0Health}`); - // console.log(`point0Ratio ${point0Ratio}`); - // console.log(`zeroHealthAmount ${zeroHealthAmount}`); const zeroHealthRatio = healthRatioAfterSwap(zeroHealthAmount); - // console.log(`zeroHealthRatio ${zeroHealthRatio}`); - amount = binaryApproximationSearch( + amount = HealthCache.binaryApproximationSearch( point1Amount, point1Ratio, zeroHealthAmount, zeroHealthRatio, minRatio, + healthRatioAfterSwap, ); } else if (point0Ratio.gte(minRatio)) { // Must be between point0Amount and point1Amount. - amount = binaryApproximationSearch( + amount = HealthCache.binaryApproximationSearch( point0Amount, point0Ratio, point1Amount, point1Ratio, minRatio, + healthRatioAfterSwap, ); } else { throw new Error( @@ -403,11 +514,131 @@ export class HealthCache { return amount .div(source.oraclePrice) .div( - ONE_I80F48.add( + ONE_I80F48().add( group.getFirstBankByMint(sourceMintPk).loanOriginationFeeRate, ), ); } + + getMaxForSerum3Order( + group: Group, + serum3Market: Serum3Market, + side: Serum3Side, + minRatio: I80F48, + ) { + const baseBank = group.getFirstBankByTokenIndex( + serum3Market.baseTokenIndex, + ); + if (!baseBank) { + throw new Error(`No bank for index ${serum3Market.baseTokenIndex}`); + } + const quoteBank = group.getFirstBankByTokenIndex( + serum3Market.quoteTokenIndex, + ); + if (!quoteBank) { + throw new Error(`No bank for index ${serum3Market.quoteTokenIndex}`); + } + + const healthCacheClone: HealthCache = _.cloneDeep(this); + + const baseIndex = healthCacheClone.getOrCreateTokenInfoIndex(baseBank); + const quoteIndex = healthCacheClone.getOrCreateTokenInfoIndex(quoteBank); + const base = healthCacheClone.tokenInfos[baseIndex]; + const quote = healthCacheClone.tokenInfos[quoteIndex]; + + // Binary search between current health (0 sized new order) and + // an amount to trade which will bring health to 0. + + // Current health and amount i.e. 0 + const initialAmount = ZERO_I80F48(); + const initialHealth = this.health(HealthType.init); + const initialRatio = this.healthRatio(HealthType.init); + if (initialRatio.lte(ZERO_I80F48())) { + return ZERO_I80F48(); + } + + // Amount which would bring health to 0 + // amount = max(A_deposits, B_borrows) + init_health / (A_liab_weight - B_asset_weight) + // A is what we would be essentially swapping for B + // So when its an ask, then base->quote, + // and when its a bid, then quote->bid + let zeroAmount; + if (side == Serum3Side.ask) { + const quoteBorrows = quote.balance.lt(ZERO_I80F48()) + ? quote.balance.abs() + : ZERO_I80F48(); + zeroAmount = base.balance + .max(quoteBorrows) + .add( + initialHealth.div( + base + .liabWeight(HealthType.init) + .sub(quote.assetWeight(HealthType.init)), + ), + ); + } else { + const baseBorrows = base.balance.lt(ZERO_I80F48()) + ? base.balance.abs() + : ZERO_I80F48(); + zeroAmount = quote.balance + .max(baseBorrows) + .add( + initialHealth.div( + quote + .liabWeight(HealthType.init) + .sub(base.assetWeight(HealthType.init)), + ), + ); + } + const cache = cacheAfterPlacingOrder(zeroAmount); + const zeroAmountRatio = cache.healthRatio(HealthType.init); + + function cacheAfterPlacingOrder(amount: I80F48) { + const adjustedCache: HealthCache = _.cloneDeep(healthCacheClone); + + side === Serum3Side.ask + ? adjustedCache.tokenInfos[baseIndex].balance.isub(amount) + : adjustedCache.tokenInfos[quoteIndex].balance.isub(amount); + + adjustedCache.adjustSerum3Reserved( + serum3Market.marketIndex, + serum3Market.baseTokenIndex, + side === Serum3Side.ask ? amount.div(base.oraclePrice) : ZERO_I80F48(), + ZERO_I80F48(), + serum3Market.quoteTokenIndex, + side === Serum3Side.bid ? amount.div(quote.oraclePrice) : ZERO_I80F48(), + ZERO_I80F48(), + ); + + return adjustedCache; + } + + function healthRatioAfterPlacingOrder(amount: I80F48): I80F48 { + return cacheAfterPlacingOrder(amount).healthRatio(HealthType.init); + } + + const amount = HealthCache.binaryApproximationSearch( + initialAmount, + initialRatio, + zeroAmount, + zeroAmountRatio, + minRatio, + healthRatioAfterPlacingOrder, + ); + + // If its a bid then the reserved fund and potential loan is in quote, + // If its a ask then the reserved fund and potential loan is in base, + // also keep some buffer for fees, use taker fees for worst case simulation. + return side === Serum3Side.bid + ? amount + .div(quote.oraclePrice) + .div(ONE_I80F48().add(baseBank.loanOriginationFeeRate)) + .div(ONE_I80F48().add(I80F48.fromNumber(group.getFeeRate(false)))) + : amount + .div(base.oraclePrice) + .div(ONE_I80F48().add(quoteBank.loanOriginationFeeRate)) + .div(ONE_I80F48().add(I80F48.fromNumber(group.getFeeRate(false)))); + } } export class TokenInfo { @@ -450,8 +681,8 @@ export class TokenInfo { bank.maintLiabWeight, bank.initLiabWeight, bank.price, - ZERO_I80F48, - ZERO_I80F48, + ZERO_I80F48(), + ZERO_I80F48(), ); } @@ -476,20 +707,30 @@ export class TokenInfo { } toString() { - return ` tokenIndex: ${this.tokenIndex}, balance: ${this.balance}`; + return ` tokenIndex: ${this.tokenIndex}, balance: ${ + this.balance + }, serum3MaxReserved: ${ + this.serum3MaxReserved + }, initHealth ${this.healthContribution(HealthType.init)}`; } } export class Serum3Info { - constructor(dto: Serum3InfoDto) { - this.reserved = I80F48.from(dto.reserved); - this.baseIndex = dto.baseIndex; - this.quoteIndex = dto.quoteIndex; - } + constructor( + public reserved: I80F48, + public baseIndex: number, + public quoteIndex: number, + public marketIndex: number, + ) {} - reserved: I80F48; - baseIndex: number; - quoteIndex: number; + static fromDto(dto: Serum3InfoDto) { + return new Serum3Info( + I80F48.from(dto.reserved), + dto.baseIndex, + dto.quoteIndex, + dto.marketIndex, + ); + } healthContribution(healthType: HealthType, tokenInfos: TokenInfo[]): I80F48 { const baseInfo = tokenInfos[this.baseIndex]; @@ -497,7 +738,7 @@ export class Serum3Info { const reserved = this.reserved; if (reserved.isZero()) { - return ZERO_I80F48; + return ZERO_I80F48(); } // How much the health would increase if the reserved balance were applied to the passed @@ -512,15 +753,14 @@ export class Serum3Info { let assetPart, liabPart; if (maxBalance.gte(reserved)) { assetPart = reserved; - liabPart = ZERO_I80F48; + liabPart = ZERO_I80F48(); } else if (maxBalance.isNeg()) { - assetPart = ZERO_I80F48; + assetPart = ZERO_I80F48(); liabPart = reserved; } else { assetPart = maxBalance; liabPart = reserved.sub(maxBalance); } - const assetWeight = tokenInfo.assetWeight(healthType); const liabWeight = tokenInfo.liabWeight(healthType); return assetWeight.mul(assetPart).add(liabWeight.mul(liabPart)); @@ -530,6 +770,14 @@ export class Serum3Info { const reservedAsQuote = computeHealthEffect(quoteInfo); return reservedAsBase.min(reservedAsQuote); } + + toString(tokenInfos: TokenInfo[]) { + return ` marketIndex: ${this.marketIndex}, baseIndex: ${ + this.baseIndex + }, quoteIndex: ${this.quoteIndex}, reserved: ${ + this.reserved + }, initHealth ${this.healthContribution(HealthType.init, tokenInfos)}`; + } } export class PerpInfo { @@ -566,7 +814,7 @@ export class PerpInfo { // FUTURE: Allow v3-style "reliable" markets where we can return // `self.quote + weight * self.base` here - return this.quote.add(weight.mul(this.base)).min(ZERO_I80F48); + return this.quote.add(weight.mul(this.base)).min(ZERO_I80F48()); } } @@ -586,12 +834,39 @@ export class TokenInfoDto { balance: I80F48Dto; // in health-reference-token native units serum3MaxReserved: I80F48Dto; + + constructor( + tokenIndex: number, + maintAssetWeight: I80F48Dto, + initAssetWeight: I80F48Dto, + maintLiabWeight: I80F48Dto, + initLiabWeight: I80F48Dto, + oraclePrice: I80F48Dto, + balance: I80F48Dto, + serum3MaxReserved: I80F48Dto, + ) { + this.tokenIndex = tokenIndex; + this.maintAssetWeight = maintAssetWeight; + this.initAssetWeight = initAssetWeight; + this.maintLiabWeight = maintLiabWeight; + this.initLiabWeight = initLiabWeight; + this.oraclePrice = oraclePrice; + this.balance = balance; + this.serum3MaxReserved = serum3MaxReserved; + } } export class Serum3InfoDto { reserved: I80F48Dto; baseIndex: number; quoteIndex: number; + marketIndex: number; + + constructor(reserved: I80F48Dto, baseIndex: number, quoteIndex: number) { + this.reserved = reserved; + this.baseIndex = baseIndex; + this.quoteIndex = quoteIndex; + } } export class PerpInfoDto { diff --git a/ts/client/src/accounts/mangoAccount.ts b/ts/client/src/accounts/mangoAccount.ts index ca8c8388b..4ab422bcb 100644 --- a/ts/client/src/accounts/mangoAccount.ts +++ b/ts/client/src/accounts/mangoAccount.ts @@ -1,5 +1,6 @@ import { BN } from '@project-serum/anchor'; import { utf8 } from '@project-serum/anchor/dist/cjs/utils/bytes'; +import { Order, Orderbook } from '@project-serum/serum/lib/market'; import { PublicKey } from '@solana/web3.js'; import { MangoClient } from '../client'; import { nativeI80F48ToUi, toNative, toUiDecimals } from '../utils'; @@ -7,6 +8,7 @@ import { Bank } from './bank'; import { Group } from './group'; import { HealthCache, HealthCacheDto } from './healthCache'; import { I80F48, I80F48Dto, ONE_I80F48, ZERO_I80F48 } from './I80F48'; +import { Serum3Market, Serum3Side } from './serum3'; export class MangoAccount { public tokens: TokenPosition[]; public serum3: Serum3Orders[]; @@ -91,6 +93,18 @@ export class MangoAccount { return this; } + tokensActive(): TokenPosition[] { + return this.tokens.filter((token) => token.isActive()); + } + + serum3Active(): Serum3Orders[] { + return this.serum3.filter((serum3) => serum3.isActive()); + } + + perpActive(): PerpPosition[] { + return this.perps.filter((perp) => perp.isActive()); + } + findToken(tokenIndex: number): TokenPosition | undefined { return this.tokens.find((ta) => ta.tokenIndex == tokenIndex); } @@ -112,7 +126,7 @@ export class MangoAccount { */ getTokenBalance(bank: Bank): I80F48 { const tp = this.findToken(bank.tokenIndex); - return tp ? tp.balance(bank) : ZERO_I80F48; + return tp ? tp.balance(bank) : ZERO_I80F48(); } /** @@ -122,7 +136,7 @@ export class MangoAccount { */ getTokenDeposits(bank: Bank): I80F48 { const tp = this.findToken(bank.tokenIndex); - return tp ? tp.deposits(bank) : ZERO_I80F48; + return tp ? tp.deposits(bank) : ZERO_I80F48(); } /** @@ -132,7 +146,7 @@ export class MangoAccount { */ getTokenBorrows(bank: Bank): I80F48 { const tp = this.findToken(bank.tokenIndex); - return tp ? tp.borrows(bank) : ZERO_I80F48; + return tp ? tp.borrows(bank) : ZERO_I80F48(); } /** @@ -209,7 +223,7 @@ export class MangoAccount { const equity = this.accountData.equity; const total_equity = equity.tokens.reduce( (a, b) => a.add(b.value), - ZERO_I80F48, + ZERO_I80F48(), ); return total_equity; } @@ -255,8 +269,8 @@ export class MangoAccount { // Case 1: // Cannot withdraw if init health is below 0 - if (initHealth.lte(ZERO_I80F48)) { - return ZERO_I80F48; + if (initHealth.lte(ZERO_I80F48())) { + return ZERO_I80F48(); } // Deposits need special treatment since they would neither count towards liabilities @@ -264,12 +278,12 @@ export class MangoAccount { const tp = this.findToken(tokenBank.tokenIndex); if (!tokenBank.price) return undefined; - const existingTokenDeposits = tp ? tp.deposits(tokenBank) : ZERO_I80F48; - let existingPositionHealthContrib = ZERO_I80F48; - if (existingTokenDeposits.gt(ZERO_I80F48)) { - existingPositionHealthContrib = existingTokenDeposits - .mul(tokenBank.price) - .mul(tokenBank.initAssetWeight); + const existingTokenDeposits = tp ? tp.deposits(tokenBank) : ZERO_I80F48(); + const existingPositionHealthContrib = ZERO_I80F48(); + if (existingTokenDeposits.gt(ZERO_I80F48())) { + existingTokenDeposits + .imul(tokenBank.price) + .imul(tokenBank.initAssetWeight); } // Case 2: token deposits have higher contribution than initHealth, @@ -296,7 +310,7 @@ export class MangoAccount { .div(tokenBank.initLiabWeight) .div(tokenBank.price); const maxBorrowNativeWithoutFees = maxBorrowNative.div( - ONE_I80F48.add(tokenBank.loanOriginationFeeRate), + ONE_I80F48().add(tokenBank.loanOriginationFeeRate), ); // console.log(`initHealth ${initHealth}`); // console.log( @@ -344,7 +358,7 @@ export class MangoAccount { group, sourceMintPk, targetMintPk, - ONE_I80F48, // target 1% health + ONE_I80F48(), // target 1% health ) .mul(I80F48.fromNumber(slippageAndFeesFactor)); } @@ -431,25 +445,234 @@ export class MangoAccount { .toNumber(); } - /** - * The remaining native quote margin available for given market. - * - * TODO: this is a very bad estimation atm. - * It assumes quote asset is always quote, - * it assumes that there are no interaction effects, - * it assumes that there are no existing borrows for either of the tokens in the market. - */ - getSerum3MarketMarginAvailable( + public async loadSerum3OpenOrdersForMarket( + client: MangoClient, group: Group, - marketName: string, - ): I80F48 | undefined { - if (!this.accountData) return undefined; - const initHealth = this.accountData.initHealth; - const serum3Market = group.serum3MarketsMap.get(marketName)!; - const marketAssetWeight = group.getFirstBankByTokenIndex( - serum3Market.baseTokenIndex, - ).initAssetWeight; - return initHealth.div(ONE_I80F48.sub(marketAssetWeight)); + externalMarketPk: PublicKey, + ): Promise { + const serum3Market = group.serum3MarketsMapByExternal.get( + externalMarketPk.toBase58(), + ); + if (!serum3Market) { + throw new Error( + `Unable to find mint serum3Market for ${externalMarketPk.toString()}`, + ); + } + const serum3OO = this.serum3Active().find( + (s) => s.marketIndex === serum3Market.marketIndex, + ); + if (!serum3OO) { + throw new Error(`No open orders account found for ${externalMarketPk}`); + } + + const serum3MarketExternal = group.serum3MarketExternalsMap.get( + externalMarketPk.toBase58(), + )!; + const [bidsInfo, asksInfo] = + await client.program.provider.connection.getMultipleAccountsInfo([ + serum3MarketExternal.bidsAddress, + serum3MarketExternal.asksAddress, + ]); + if (!bidsInfo || !asksInfo) { + throw new Error( + `bids and asks ai were not fetched for ${externalMarketPk.toString()}`, + ); + } + const bids = Orderbook.decode(serum3MarketExternal, bidsInfo.data); + const asks = Orderbook.decode(serum3MarketExternal, asksInfo.data); + return [...bids, ...asks].filter((o) => + o.openOrdersAddress.equals(serum3OO.openOrders), + ); + } + + /** + * + * @param group + * @param serum3Market + * @returns maximum native quote which can be traded for base token given current health + */ + public getMaxQuoteForSerum3Bid( + group: Group, + serum3Market: Serum3Market, + ): I80F48 { + if (!this.accountData) { + throw new Error( + `accountData not loaded on MangoAccount, try reloading MangoAccount`, + ); + } + return this.accountData.healthCache.getMaxForSerum3Order( + group, + serum3Market, + Serum3Side.bid, + I80F48.fromNumber(3), + ); + } + + public getMaxQuoteForSerum3BidUi( + group: Group, + externalMarketPk: PublicKey, + ): number { + const serum3Market = group.serum3MarketsMapByExternal.get( + externalMarketPk.toBase58(), + ); + if (!serum3Market) { + throw new Error( + `Unable to find mint serum3Market for ${externalMarketPk.toString()}`, + ); + } + const nativeAmount = this.getMaxQuoteForSerum3Bid(group, serum3Market); + return toUiDecimals( + nativeAmount, + group.getFirstBankByTokenIndex(serum3Market.quoteTokenIndex).mintDecimals, + ); + } + + /** + * + * @param group + * @param serum3Market + * @returns maximum native base which can be traded for quote token given current health + */ + public getMaxBaseForSerum3Ask( + group: Group, + serum3Market: Serum3Market, + ): I80F48 { + if (!this.accountData) { + throw new Error( + `accountData not loaded on MangoAccount, try reloading MangoAccount`, + ); + } + return this.accountData.healthCache.getMaxForSerum3Order( + group, + serum3Market, + Serum3Side.ask, + I80F48.fromNumber(3), + ); + } + + public getMaxBaseForSerum3AskUi( + group: Group, + externalMarketPk: PublicKey, + ): number { + const serum3Market = group.serum3MarketsMapByExternal.get( + externalMarketPk.toBase58(), + ); + if (!serum3Market) { + throw new Error( + `Unable to find mint serum3Market for ${externalMarketPk.toString()}`, + ); + } + const nativeAmount = this.getMaxBaseForSerum3Ask(group, serum3Market); + return toUiDecimals( + nativeAmount, + group.getFirstBankByTokenIndex(serum3Market.baseTokenIndex).mintDecimals, + ); + } + + /** + * + * @param group + * @param nativeQuoteAmount + * @param serum3Market + * @param healthType + * @returns health ratio after a bid with nativeQuoteAmount is placed + */ + simHealthRatioWithSerum3BidChanges( + group: Group, + nativeQuoteAmount: I80F48, + serum3Market: Serum3Market, + healthType: HealthType = HealthType.init, + ): I80F48 { + if (!this.accountData) { + throw new Error( + `accountData not loaded on MangoAccount, try reloading MangoAccount`, + ); + } + return this.accountData.healthCache.simHealthRatioWithSerum3BidChanges( + group, + nativeQuoteAmount, + serum3Market, + healthType, + ); + } + + simHealthRatioWithSerum3BidUiChanges( + group: Group, + uiQuoteAmount: number, + externalMarketPk: PublicKey, + healthType: HealthType = HealthType.init, + ): number { + const serum3Market = group.serum3MarketsMapByExternal.get( + externalMarketPk.toBase58(), + ); + if (!serum3Market) { + throw new Error( + `Unable to find mint serum3Market for ${externalMarketPk.toString()}`, + ); + } + return this.simHealthRatioWithSerum3BidChanges( + group, + toNative( + uiQuoteAmount, + group.getFirstBankByTokenIndex(serum3Market.quoteTokenIndex) + .mintDecimals, + ), + serum3Market, + healthType, + ).toNumber(); + } + + /** + * + * @param group + * @param nativeBaseAmount + * @param serum3Market + * @param healthType + * @returns health ratio after an ask with nativeBaseAmount is placed + */ + simHealthRatioWithSerum3AskChanges( + group: Group, + nativeBaseAmount: I80F48, + serum3Market: Serum3Market, + healthType: HealthType = HealthType.init, + ): I80F48 { + if (!this.accountData) { + throw new Error( + `accountData not loaded on MangoAccount, try reloading MangoAccount`, + ); + } + return this.accountData.healthCache.simHealthRatioWithSerum3AskChanges( + group, + nativeBaseAmount, + serum3Market, + healthType, + ); + } + + simHealthRatioWithSerum3AskUiChanges( + group: Group, + uiBaseAmount: number, + externalMarketPk: PublicKey, + healthType: HealthType = HealthType.init, + ): number { + const serum3Market = group.serum3MarketsMapByExternal.get( + externalMarketPk.toBase58(), + ); + if (!serum3Market) { + throw new Error( + `Unable to find mint serum3Market for ${externalMarketPk.toString()}`, + ); + } + return this.simHealthRatioWithSerum3AskChanges( + group, + toNative( + uiBaseAmount, + group.getFirstBankByTokenIndex(serum3Market.baseTokenIndex) + .mintDecimals, + ), + serum3Market, + healthType, + ).toNumber(); } /** @@ -468,19 +691,7 @@ export class MangoAccount { const initHealth = this.accountData.initHealth; const perpMarket = group.perpMarketsMap.get(marketName)!; const marketAssetWeight = perpMarket.initAssetWeight; - return initHealth.div(ONE_I80F48.sub(marketAssetWeight)); - } - - tokensActive(): TokenPosition[] { - return this.tokens.filter((token) => token.isActive()); - } - - serum3Active(): Serum3Orders[] { - return this.serum3.filter((serum3) => serum3.isActive()); - } - - perpActive(): PerpPosition[] { - return this.perps.filter((perp) => perp.isActive()); + return initHealth.div(ONE_I80F48().sub(marketAssetWeight)); } toString(group?: Group): string { @@ -558,8 +769,8 @@ export class TokenPosition { * @returns native deposits, 0 if position has borrows */ public deposits(bank: Bank): I80F48 { - if (this.indexedPosition && this.indexedPosition.lt(ZERO_I80F48)) { - return ZERO_I80F48; + if (this.indexedPosition && this.indexedPosition.lt(ZERO_I80F48())) { + return ZERO_I80F48(); } return this.balance(bank); } @@ -570,8 +781,8 @@ export class TokenPosition { * @returns native borrows, 0 if position has deposits */ public borrows(bank: Bank): I80F48 { - if (this.indexedPosition && this.indexedPosition.gt(ZERO_I80F48)) { - return ZERO_I80F48; + if (this.indexedPosition && this.indexedPosition.gt(ZERO_I80F48())) { + return ZERO_I80F48(); } return this.balance(bank).abs(); } @@ -734,7 +945,7 @@ export class MangoAccountData { tokenAssets: any; }) { return new MangoAccountData( - new HealthCache(event.healthCache), + HealthCache.fromDto(event.healthCache), I80F48.from(event.initHealth), I80F48.from(event.maintHealth), Equity.from(event.equity), diff --git a/ts/client/src/accounts/serum3.ts b/ts/client/src/accounts/serum3.ts index cd656d107..bb07fd09d 100644 --- a/ts/client/src/accounts/serum3.ts +++ b/ts/client/src/accounts/serum3.ts @@ -1,6 +1,11 @@ import { utf8 } from '@project-serum/anchor/dist/cjs/utils/bytes'; -import { PublicKey } from '@solana/web3.js'; +import { Market, Orderbook } from '@project-serum/serum/lib/market'; +import { Cluster, PublicKey } from '@solana/web3.js'; import BN from 'bn.js'; +import { MangoClient } from '../client'; +import { SERUM3_PROGRAM_ID } from '../constants'; +import { Group } from './group'; +import { MAX_I80F48, ONE_I80F48, ZERO_I80F48 } from './I80F48'; export class Serum3Market { public name: string; @@ -44,6 +49,117 @@ export class Serum3Market { ) { this.name = utf8.decode(new Uint8Array(name)).split('\x00')[0]; } + + /** + * + * @param group + * @returns maximum leverage one can bid on this market, this is only for display purposes, + * also see getMaxQuoteForSerum3BidUi and getMaxBaseForSerum3AskUi + */ + maxBidLeverage(group: Group): number { + const baseBank = group.getFirstBankByTokenIndex(this.baseTokenIndex); + if (!baseBank) { + throw new Error( + `bank for base token with index ${this.baseTokenIndex} not found`, + ); + } + + const quoteBank = group.getFirstBankByTokenIndex(this.quoteTokenIndex); + if (!quoteBank) { + throw new Error( + `bank for quote token with index ${this.quoteTokenIndex} not found`, + ); + } + + if ( + quoteBank.initLiabWeight.sub(baseBank.initAssetWeight).lte(ZERO_I80F48()) + ) { + return MAX_I80F48().toNumber(); + } + + return ONE_I80F48() + .div(quoteBank.initLiabWeight.sub(baseBank.initAssetWeight)) + .toNumber(); + } + + /** + * + * @param group + * @returns maximum leverage one can ask on this market, this is only for display purposes, + * also see getMaxQuoteForSerum3BidUi and getMaxBaseForSerum3AskUi + */ + maxAskLeverage(group: Group): number { + const baseBank = group.getFirstBankByTokenIndex(this.baseTokenIndex); + if (!baseBank) { + throw new Error( + `bank for base token with index ${this.baseTokenIndex} not found`, + ); + } + + const quoteBank = group.getFirstBankByTokenIndex(this.quoteTokenIndex); + if (!quoteBank) { + throw new Error( + `bank for quote token with index ${this.quoteTokenIndex} not found`, + ); + } + + if ( + baseBank.initLiabWeight.sub(quoteBank.initAssetWeight).lte(ZERO_I80F48()) + ) { + return MAX_I80F48().toNumber(); + } + + return ONE_I80F48() + .div(baseBank.initLiabWeight.sub(quoteBank.initAssetWeight)) + .toNumber(); + } + + public async loadBids(client: MangoClient, group: Group): Promise { + const serum3MarketExternal = group.serum3MarketExternalsMap.get( + this.serumMarketExternal.toBase58(), + ); + if (!serum3MarketExternal) { + throw new Error( + `Unable to find serum3MarketExternal for ${this.serumMarketExternal.toBase58()}`, + ); + } + return await serum3MarketExternal.loadBids( + client.program.provider.connection, + ); + } + + public async loadAsks(client: MangoClient, group: Group): Promise { + const serum3MarketExternal = group.serum3MarketExternalsMap.get( + this.serumMarketExternal.toBase58(), + ); + if (!serum3MarketExternal) { + throw new Error( + `Unable to find serum3MarketExternal for ${this.serumMarketExternal.toBase58()}`, + ); + } + return await serum3MarketExternal.loadAsks( + client.program.provider.connection, + ); + } + + public async logOb(client: MangoClient, group: Group): Promise { + let res = ``; + res += ` ${this.name} OrderBook`; + let orders = await this?.loadAsks(client, group); + for (const order of orders!.items(true)) { + res += `\n ${order.price.toString().padStart(10)}, ${order.size + .toString() + .padStart(10)}`; + } + res += `\n --------------------------`; + orders = await this?.loadBids(client, group); + for (const order of orders!.items(true)) { + res += `\n ${order.price.toString().padStart(10)}, ${order.size + .toString() + .padStart(10)}`; + } + return res; + } } export class Serum3SelfTradeBehavior { @@ -62,3 +178,21 @@ export class Serum3Side { static bid = { bid: {} }; static ask = { ask: {} }; } + +export async function generateSerum3MarketExternalVaultSignerAddress( + cluster: Cluster, + serum3Market: Serum3Market, + serum3MarketExternal: Market, +): Promise { + return await PublicKey.createProgramAddress( + [ + serum3Market.serumMarketExternal.toBuffer(), + serum3MarketExternal.decoded.vaultSignerNonce.toArrayLike( + Buffer, + 'le', + 8, + ), + ], + SERUM3_PROGRAM_ID[cluster], + ); +} diff --git a/ts/client/src/client.ts b/ts/client/src/client.ts index 4a1047cd8..633b36398 100644 --- a/ts/client/src/client.ts +++ b/ts/client/src/client.ts @@ -1,6 +1,4 @@ import { AnchorProvider, BN, Program, Provider } from '@project-serum/anchor'; -import { getFeeRates, getFeeTier } from '@project-serum/serum'; -import { Order } from '@project-serum/serum/lib/market'; import { closeAccount, initializeAccount, @@ -37,6 +35,7 @@ import { import { StubOracle } from './accounts/oracle'; import { OrderType, PerpMarket, Side } from './accounts/perp'; import { + generateSerum3MarketExternalVaultSignerAddress, Serum3Market, Serum3OrderType, Serum3SelfTradeBehavior, @@ -348,7 +347,7 @@ export class MangoClient { } return await this.program.methods - .tokenDeregister(bank.tokenIndex) + .tokenDeregister() .accounts({ group: group.publicKey, admin: adminPk, @@ -681,9 +680,13 @@ export class MangoClient { mangoAccount: MangoAccount, ): Promise { const healthRemainingAccounts: PublicKey[] = - this.buildHealthRemainingAccounts(AccountRetriever.Fixed, group, [ - mangoAccount, - ]); + this.buildHealthRemainingAccounts( + AccountRetriever.Fixed, + group, + [mangoAccount], + [], + [], + ); // Use our custom simulate fn in utils/anchor.ts so signing the tx is not required this.program.provider.simulate = simulate; @@ -785,6 +788,7 @@ export class MangoClient { group, [mangoAccount], [bank], + [], ); const transaction = await this.program.methods @@ -794,6 +798,7 @@ export class MangoClient { account: mangoAccount.publicKey, bank: bank.publicKey, vault: bank.vault, + oracle: bank.oracle, tokenAccount: wrappedSolAccount?.publicKey ?? tokenAccountPk, tokenAuthority: mangoAccount.owner, }) @@ -858,6 +863,7 @@ export class MangoClient { group, [mangoAccount], [bank], + [], ); const transaction = await this.program.methods @@ -867,6 +873,7 @@ export class MangoClient { account: mangoAccount.publicKey, bank: bank.publicKey, vault: bank.vault, + oracle: bank.oracle, tokenAccount: tokenAccountPk, owner: mangoAccount.owner, }) @@ -922,9 +929,11 @@ export class MangoClient { public async serum3deregisterMarket( group: Group, - serum3MarketName: string, + externalMarketPk: PublicKey, ): Promise { - const serum3Market = group.serum3MarketsMap.get(serum3MarketName)!; + const serum3Market = group.serum3MarketsMapByExternal.get( + externalMarketPk.toBase58(), + )!; return await this.program.methods .serum3DeregisterMarket() @@ -986,7 +995,8 @@ export class MangoClient { mangoAccount: MangoAccount, marketName: string, ): Promise { - const serum3Market: Serum3Market = group.serum3MarketsMap.get(marketName)!; + const serum3Market: Serum3Market = + group.serum3MarketsMapByExternal.get(marketName)!; return await this.program.methods .serum3CreateOpenOrders() @@ -1005,9 +1015,11 @@ export class MangoClient { public async serum3CloseOpenOrders( group: Group, mangoAccount: MangoAccount, - serum3MarketName: string, + externalMarketPk: PublicKey, ): Promise { - const serum3Market = group.serum3MarketsMap.get(serum3MarketName)!; + const serum3Market = group.serum3MarketsMapByExternal.get( + externalMarketPk.toBase58(), + )!; const openOrders = mangoAccount.serum3.find( (account) => account.marketIndex === serum3Market.marketIndex, @@ -1031,7 +1043,7 @@ export class MangoClient { public async serum3PlaceOrder( group: Group, mangoAccount: MangoAccount, - serum3MarketName: string, + externalMarketPk: PublicKey, side: Serum3Side, price: number, size: number, @@ -1040,46 +1052,48 @@ export class MangoClient { clientOrderId: number, limit: number, ) { - const serum3Market = group.serum3MarketsMap.get(serum3MarketName)!; - + const serum3Market = group.serum3MarketsMapByExternal.get( + externalMarketPk.toBase58(), + )!; if (!mangoAccount.findSerum3Account(serum3Market.marketIndex)) { await this.serum3CreateOpenOrders(group, mangoAccount, 'BTC/USDC'); mangoAccount = await this.getMangoAccount(mangoAccount); } - - const serum3MarketExternal = - group.serum3MarketExternalsMap.get(serum3MarketName)!; - + const serum3MarketExternal = group.serum3MarketExternalsMap.get( + externalMarketPk.toBase58(), + )!; const serum3MarketExternalVaultSigner = - await PublicKey.createProgramAddress( - [ - serum3Market.serumMarketExternal.toBuffer(), - serum3MarketExternal.decoded.vaultSignerNonce.toArrayLike( - Buffer, - 'le', - 8, - ), - ], - SERUM3_PROGRAM_ID[this.cluster], + await generateSerum3MarketExternalVaultSignerAddress( + this.cluster, + serum3Market, + serum3MarketExternal, ); const healthRemainingAccounts: PublicKey[] = - this.buildHealthRemainingAccounts(AccountRetriever.Fixed, group, [ - mangoAccount, - ]); + this.buildHealthRemainingAccounts( + AccountRetriever.Fixed, + group, + [mangoAccount], + [], + [], + ); const limitPrice = serum3MarketExternal.priceNumberToLots(price); const maxBaseQuantity = serum3MarketExternal.baseSizeNumberToLots(size); - const feeTier = getFeeTier(0, 0 /** TODO: fix msrm/srm balance */); - const rates = getFeeRates(feeTier); - const maxQuoteQuantity = new BN( - serum3MarketExternal.decoded.quoteLotSize.toNumber() * - (1 + rates.taker) /** TODO: fix taker/maker */, - ).mul( - serum3MarketExternal - .baseSizeNumberToLots(size) - .mul(serum3MarketExternal.priceNumberToLots(price)), - ); + const maxQuoteQuantity = serum3MarketExternal.decoded.quoteLotSize + .mul(new BN(1 + group.getFeeRate(orderType === Serum3OrderType.postOnly))) + .mul( + serum3MarketExternal + .baseSizeNumberToLots(size) + .mul(serum3MarketExternal.priceNumberToLots(price)), + ); + const payerTokenIndex = (() => { + if (side == Serum3Side.bid) { + return serum3Market.quoteTokenIndex; + } else { + return serum3Market.baseTokenIndex; + } + })(); return await this.program.methods .serum3PlaceOrder( @@ -1108,14 +1122,8 @@ export class MangoClient { marketBaseVault: serum3MarketExternal.decoded.baseVault, marketQuoteVault: serum3MarketExternal.decoded.quoteVault, marketVaultSigner: serum3MarketExternalVaultSigner, - quoteBank: group.getFirstBankByTokenIndex(serum3Market.quoteTokenIndex) - .publicKey, - quoteVault: group.getFirstBankByTokenIndex(serum3Market.quoteTokenIndex) - .vault, - baseBank: group.getFirstBankByTokenIndex(serum3Market.baseTokenIndex) - .publicKey, - baseVault: group.getFirstBankByTokenIndex(serum3Market.baseTokenIndex) - .vault, + payerBank: group.getFirstBankByTokenIndex(payerTokenIndex).publicKey, + payerVault: group.getFirstBankByTokenIndex(payerTokenIndex).vault, }) .remainingAccounts( healthRemainingAccounts.map( @@ -1129,13 +1137,16 @@ export class MangoClient { async serum3CancelAllorders( group: Group, mangoAccount: MangoAccount, - serum3MarketName: string, + externalMarketPk: PublicKey, limit: number, ) { - const serum3Market = group.serum3MarketsMap.get(serum3MarketName)!; + const serum3Market = group.serum3MarketsMapByExternal.get( + externalMarketPk.toBase58(), + )!; - const serum3MarketExternal = - group.serum3MarketExternalsMap.get(serum3MarketName)!; + const serum3MarketExternal = group.serum3MarketExternalsMap.get( + externalMarketPk.toBase58(), + )!; return await this.program.methods .serum3CancelAllOrders(limit) @@ -1158,25 +1169,19 @@ export class MangoClient { async serum3SettleFunds( group: Group, mangoAccount: MangoAccount, - serum3MarketName: string, + externalMarketPk: PublicKey, ): Promise { - const serum3Market = group.serum3MarketsMap.get(serum3MarketName)!; - - const serum3MarketExternal = - group.serum3MarketExternalsMap.get(serum3MarketName)!; - + const serum3Market = group.serum3MarketsMapByExternal.get( + externalMarketPk.toBase58(), + )!; + const serum3MarketExternal = group.serum3MarketExternalsMap.get( + externalMarketPk.toBase58(), + )!; const serum3MarketExternalVaultSigner = - // TODO: put into a helper method, and remove copy pasta - await PublicKey.createProgramAddress( - [ - serum3Market.serumMarketExternal.toBuffer(), - serum3MarketExternal.decoded.vaultSignerNonce.toArrayLike( - Buffer, - 'le', - 8, - ), - ], - SERUM3_PROGRAM_ID[this.cluster], + await generateSerum3MarketExternalVaultSignerAddress( + this.cluster, + serum3Market, + serum3MarketExternal, ); return await this.program.methods @@ -1208,14 +1213,17 @@ export class MangoClient { async serum3CancelOrder( group: Group, mangoAccount: MangoAccount, - serum3MarketName: string, + externalMarketPk: PublicKey, side: Serum3Side, orderId: BN, ): Promise { - const serum3Market = group.serum3MarketsMap.get(serum3MarketName)!; + const serum3Market = group.serum3MarketsMapByExternal.get( + externalMarketPk.toBase58(), + )!; - const serum3MarketExternal = - group.serum3MarketExternalsMap.get(serum3MarketName)!; + const serum3MarketExternal = group.serum3MarketExternalsMap.get( + externalMarketPk.toBase58(), + )!; return await this.program.methods .serum3CancelOrder(side, orderId) @@ -1234,20 +1242,6 @@ export class MangoClient { .rpc(); } - async getSerum3Orders( - group: Group, - serum3MarketName: string, - ): Promise { - const serum3MarketExternal = - group.serum3MarketExternalsMap.get(serum3MarketName)!; - - // TODO: filter for mango account - return await serum3MarketExternal.loadOrdersForOwner( - this.program.provider.connection, - group.publicKey, - ); - } - /// perps async perpCreateMarket( @@ -1470,9 +1464,13 @@ export class MangoClient { const perpMarket = group.perpMarketsMap.get(perpMarketName)!; const healthRemainingAccounts: PublicKey[] = - this.buildHealthRemainingAccounts(AccountRetriever.Fixed, group, [ - mangoAccount, - ]); + this.buildHealthRemainingAccounts( + AccountRetriever.Fixed, + group, + [mangoAccount], + [], + [perpMarket], + ); const [nativePrice, nativeQuantity] = perpMarket.uiToNativePriceQuantity( price, @@ -1543,6 +1541,7 @@ export class MangoClient { group, [mangoAccount], [inputBank, outputBank], + [], ); const parsedHealthAccounts = healthRemainingAccounts.map( (pk) => @@ -1727,6 +1726,7 @@ export class MangoClient { group, [liqor, liqee], [assetBank, liabBank], + [], ); const parsedHealthAccounts = healthRemainingAccounts.map( @@ -1802,31 +1802,39 @@ export class MangoClient { /// private + // todo make private public buildHealthRemainingAccounts( retriever: AccountRetriever, group: Group, mangoAccounts: MangoAccount[], - banks?: Bank[] /** TODO for serum3PlaceOrder we are just ingoring this atm */, + banks: Bank[], + perpMarkets: PerpMarket[], ): PublicKey[] { if (retriever === AccountRetriever.Fixed) { return this.buildFixedAccountRetrieverHealthAccounts( group, mangoAccounts[0], banks, + perpMarkets, ); } else { return this.buildScanningAccountRetrieverHealthAccounts( group, mangoAccounts, banks, + perpMarkets, ); } } + // todo make private public buildFixedAccountRetrieverHealthAccounts( group: Group, mangoAccount: MangoAccount, - banks?: Bank[] /** TODO for serum3PlaceOrder we are just ingoring this atm */, + // Banks and perpMarkets for whom positions don't exist on mango account, + // but user would potentially open new positions. + banks: Bank[], + perpMarkets: PerpMarket[], ): PublicKey[] { const healthRemainingAccounts: PublicKey[] = []; @@ -1857,11 +1865,7 @@ export class MangoClient { healthRemainingAccounts.push( ...mintInfos.map((mintInfo) => mintInfo.oracle), ); - healthRemainingAccounts.push( - ...mangoAccount.serum3 - .filter((serum3Account) => serum3Account.marketIndex !== 65535) - .map((serum3Account) => serum3Account.openOrders), - ); + healthRemainingAccounts.push( ...mangoAccount.perps .filter((perp) => perp.marketIndex !== 65535) @@ -1872,14 +1876,36 @@ export class MangoClient { )[0].publicKey, ), ); + for (const perpMarket of perpMarkets) { + const alreadyAdded = mangoAccount.perps.find( + (p) => p.marketIndex === perpMarket.perpMarketIndex, + ); + if (!alreadyAdded) { + healthRemainingAccounts.push( + Array.from(group.perpMarketsMap.values()).filter( + (p) => p.perpMarketIndex === perpMarket.perpMarketIndex, + )[0].publicKey, + ); + } + } + + healthRemainingAccounts.push( + ...mangoAccount.serum3 + .filter((serum3Account) => serum3Account.marketIndex !== 65535) + .map((serum3Account) => serum3Account.openOrders), + ); + + // debugHealthAccounts(group, mangoAccount, healthRemainingAccounts); return healthRemainingAccounts; } + // todo make private public buildScanningAccountRetrieverHealthAccounts( group: Group, mangoAccounts: MangoAccount[], - banks?: Bank[] /** TODO for serum3PlaceOrder we are just ingoring this atm */, + banks: Bank[], + perpMarkets: PerpMarket[], ): PublicKey[] { const healthRemainingAccounts: PublicKey[] = []; @@ -1907,6 +1933,7 @@ export class MangoClient { healthRemainingAccounts.push( ...mintInfos.map((mintInfo) => mintInfo.oracle), ); + for (const mangoAccount of mangoAccounts) { healthRemainingAccounts.push( ...mangoAccount.serum3 @@ -1914,6 +1941,7 @@ export class MangoClient { .map((serum3Account) => serum3Account.openOrders), ); } + for (const mangoAccount of mangoAccounts) { healthRemainingAccounts.push( ...mangoAccount.perps @@ -1926,6 +1954,20 @@ export class MangoClient { ), ); } + for (const mangoAccount of mangoAccounts) { + for (const perpMarket of perpMarkets) { + const alreadyAdded = mangoAccount.perps.find( + (p) => p.marketIndex === perpMarket.perpMarketIndex, + ); + if (!alreadyAdded) { + healthRemainingAccounts.push( + Array.from(group.perpMarketsMap.values()).filter( + (p) => p.perpMarketIndex === perpMarket.perpMarketIndex, + )[0].publicKey, + ); + } + } + } return healthRemainingAccounts; } diff --git a/ts/client/src/debug-scripts/mb-debug-banks.ts b/ts/client/src/debug-scripts/debug-banks.ts similarity index 83% rename from ts/client/src/debug-scripts/mb-debug-banks.ts rename to ts/client/src/debug-scripts/debug-banks.ts index 8aa09e7d2..9c46369f1 100644 --- a/ts/client/src/debug-scripts/mb-debug-banks.ts +++ b/ts/client/src/debug-scripts/debug-banks.ts @@ -1,38 +1,47 @@ import { AnchorProvider, Wallet } from '@project-serum/anchor'; import { coder } from '@project-serum/anchor/dist/cjs/spl/token'; -import { Connection, Keypair } from '@solana/web3.js'; +import { Cluster, Connection, Keypair } from '@solana/web3.js'; import fs from 'fs'; import { I80F48, ZERO_I80F48 } from '../accounts/I80F48'; import { MangoClient } from '../client'; import { MANGO_V4_ID } from '../constants'; import { toUiDecimals } from '../utils'; +const CLUSTER_URL = + process.env.CLUSTER_URL_OVERRIDE || process.env.MB_CLUSTER_URL; +const PAYER_KEYPAIR = + process.env.PAYER_KEYPAIR_OVERRIDE || process.env.MB_PAYER_KEYPAIR; +const GROUP_NUM = Number(process.env.GROUP_NUM || 2); +const CLUSTER: Cluster = + (process.env.CLUSTER_OVERRIDE as Cluster) || 'mainnet-beta'; + async function main() { const options = AnchorProvider.defaultOptions(); - const connection = new Connection(process.env.MB_CLUSTER_URL!, options); + const connection = new Connection(CLUSTER_URL!, options); const admin = Keypair.fromSecretKey( - Buffer.from( - JSON.parse(fs.readFileSync(process.env.MB_PAYER_KEYPAIR!, 'utf-8')), - ), + Buffer.from(JSON.parse(fs.readFileSync(PAYER_KEYPAIR!, 'utf-8'))), ); const adminWallet = new Wallet(admin); const adminProvider = new AnchorProvider(connection, adminWallet, options); const client = MangoClient.connect( adminProvider, - 'mainnet-beta', - MANGO_V4_ID['mainnet-beta'], + CLUSTER, + MANGO_V4_ID[CLUSTER], + {}, + 'get-program-accounts', ); - const group = await client.getGroupForCreator(admin.publicKey, 2); + const group = await client.getGroupForCreator(admin.publicKey, GROUP_NUM); console.log(`Group ${group.publicKey.toBase58()}`); + console.log(`${group.toString()}`); const banks = Array.from(group.banksMapByMint.values()).flat(); const banksMapUsingTokenIndex = new Map( banks.map((bank) => { - (bank as any).indexedDepositsByMangoAccounts = ZERO_I80F48; - (bank as any).indexedBorrowsByMangoAccounts = ZERO_I80F48; + (bank as any).indexedDepositsByMangoAccounts = ZERO_I80F48(); + (bank as any).indexedBorrowsByMangoAccounts = ZERO_I80F48(); return [bank.tokenIndex, bank]; }), ); @@ -51,7 +60,7 @@ async function main() { bank as any ).indexedDepositsByMangoAccounts.add( token.indexedPosition.mul( - banksMapUsingTokenIndex.get(token.tokenIndex).depositIndex, + banksMapUsingTokenIndex.get(token.tokenIndex)!.depositIndex, ), ); } @@ -61,7 +70,7 @@ async function main() { ).indexedBorrowsByMangoAccounts.add( token.indexedPosition .abs() - .mul(banksMapUsingTokenIndex.get(token.tokenIndex).borrowIndex), + .mul(banksMapUsingTokenIndex.get(token.tokenIndex)!.borrowIndex), ); } }), @@ -74,7 +83,7 @@ async function main() { coder() .accounts.decode( 'token', - (await client.program.provider.connection.getAccountInfo(bank.vault)) + (await client.program.provider.connection.getAccountInfo(bank.vault))! .data, ) .amount.toNumber(), @@ -94,7 +103,7 @@ async function main() { `\n ${'bank'.padEnd(40)} ${bank.publicKey}` + `\n ${'vault'.padEnd(40)} ${bank.vault}` + `\n ${'mint'.padEnd(40)} ${bank.mint}` + - `\n ${'price'.padEnd(40)} ${bank.price.toNumber()}` + + `\n ${'price'.padEnd(40)} ${bank.price?.toNumber()}` + `\n ${'uiPrice'.padEnd(40)} ${bank.uiPrice}` + `\n ${'error'.padEnd(40)} ${error}` + `\n ${'collectedFeesNative'.padEnd(40)} ${bank.collectedFeesNative}` + diff --git a/ts/client/src/debug-scripts/mb-debug-user.ts b/ts/client/src/debug-scripts/mb-debug-user.ts index b5e70883d..50c2da8cf 100644 --- a/ts/client/src/debug-scripts/mb-debug-user.ts +++ b/ts/client/src/debug-scripts/mb-debug-user.ts @@ -20,24 +20,29 @@ async function debugUser( console.log( 'buildFixedAccountRetrieverHealthAccounts ' + client - .buildFixedAccountRetrieverHealthAccounts(group, mangoAccount, [ - group.banksMapByName.get('BTC')[0], - group.banksMapByName.get('USDC')[0], - ]) + .buildFixedAccountRetrieverHealthAccounts( + group, + mangoAccount, + [ + group.banksMapByName.get('BTC')![0], + group.banksMapByName.get('USDC')![0], + ], + [], + ) .map((pk) => pk.toBase58()) .join(', '), ); console.log( 'mangoAccount.getEquity() ' + - toUiDecimalsForQuote(mangoAccount.getEquity().toNumber()), + toUiDecimalsForQuote(mangoAccount.getEquity()!.toNumber()), ); console.log( 'mangoAccount.getHealth(HealthType.init) ' + - toUiDecimalsForQuote(mangoAccount.getHealth(HealthType.init).toNumber()), + toUiDecimalsForQuote(mangoAccount.getHealth(HealthType.init)!.toNumber()), ); console.log( 'mangoAccount.getHealthRatio(HealthType.init) ' + - mangoAccount.getHealthRatio(HealthType.init).toNumber(), + mangoAccount.getHealthRatio(HealthType.init)!.toNumber(), ); console.log( 'mangoAccount.getHealthRatioUi(HealthType.init) ' + @@ -45,7 +50,7 @@ async function debugUser( ); console.log( 'mangoAccount.getHealthRatio(HealthType.maint) ' + - mangoAccount.getHealthRatio(HealthType.maint).toNumber(), + mangoAccount.getHealthRatio(HealthType.maint)!.toNumber(), ); console.log( 'mangoAccount.getHealthRatioUi(HealthType.maint) ' + @@ -53,18 +58,18 @@ async function debugUser( ); console.log( 'mangoAccount.getCollateralValue() ' + - toUiDecimalsForQuote(mangoAccount.getCollateralValue().toNumber()), + toUiDecimalsForQuote(mangoAccount.getCollateralValue()!.toNumber()), ); console.log( 'mangoAccount.getAssetsValue() ' + toUiDecimalsForQuote( - mangoAccount.getAssetsValue(HealthType.init).toNumber(), + mangoAccount.getAssetsValue(HealthType.init)!.toNumber(), ), ); console.log( 'mangoAccount.getLiabsValue() ' + toUiDecimalsForQuote( - mangoAccount.getLiabsValue(HealthType.init).toNumber(), + mangoAccount.getLiabsValue(HealthType.init)!.toNumber(), ), ); @@ -73,7 +78,7 @@ async function debugUser( `mangoAccount.getMaxWithdrawWithBorrowForTokenUi(group, ${token}) ` + mangoAccount.getMaxWithdrawWithBorrowForTokenUi( group, - group.banksMapByName.get(token)[0].mint, + group.banksMapByName.get(token)![0].mint, ), ); } @@ -89,29 +94,28 @@ async function debugUser( } for (const srcToken of Array.from(group.banksMapByName.keys())) { simHealthRatioWithTokenPositionChangesWrapper(`${srcToken} 1 `, { - mintPk: group.banksMapByName.get(srcToken)[0].mint, + mintPk: group.banksMapByName.get(srcToken)![0].mint, uiTokenAmount: 1, }); simHealthRatioWithTokenPositionChangesWrapper(`${srcToken} -1 `, { - mintPk: group.banksMapByName.get(srcToken)[0].mint, + mintPk: group.banksMapByName.get(srcToken)![0].mint, uiTokenAmount: -1, }); } function getMaxSourceForTokenSwapWrapper(src, tgt) { - // console.log(); console.log( `getMaxSourceForTokenSwap ${src.padEnd(4)} ${tgt.padEnd(4)} ` + mangoAccount .getMaxSourceForTokenSwap( group, - group.banksMapByName.get(src)[0].mint, - group.banksMapByName.get(tgt)[0].mint, + group.banksMapByName.get(src)![0].mint, + group.banksMapByName.get(tgt)![0].mint, 1, - ) + )! .div( I80F48.fromNumber( - Math.pow(10, group.banksMapByName.get(src)[0].mintDecimals), + Math.pow(10, group.banksMapByName.get(src)![0].mintDecimals), ), ) .toNumber(), @@ -148,8 +152,8 @@ async function main() { const group = await client.getGroupForCreator(admin.publicKey, 2); for (const keypair of [ - process.env.MB_PAYER_KEYPAIR, - process.env.MB_USER2_KEYPAIR, + process.env.MB_PAYER_KEYPAIR!, + process.env.MB_USER2_KEYPAIR!, ]) { console.log(); const user = Keypair.fromSecretKey( diff --git a/ts/client/src/deployment-scripts/mainnet.ts b/ts/client/src/deployment-scripts/mainnet.ts new file mode 100644 index 000000000..6eec62026 --- /dev/null +++ b/ts/client/src/deployment-scripts/mainnet.ts @@ -0,0 +1,290 @@ +import { AnchorProvider, Wallet } from '@project-serum/anchor'; +import { Connection, Keypair, PublicKey } from '@solana/web3.js'; +import fs from 'fs'; +import { MangoClient } from '../client'; +import { MANGO_V4_ID } from '../constants'; + +const GROUP_NUM = Number(process.env.GROUP_NUM || 0); + +// Reference +// https://explorer.solana.com/ +// https://github.com/blockworks-foundation/mango-client-v3/blob/main/src/ids.json +const MAINNET_MINTS = new Map([ + ['USDC', 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'], + ['USDT', 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'], + ['BTC', '9n4nbM75f5Ui33ZbPYXn59EwSgE8CGsHtAeTH5YFeJ9E'], // Wrapped Bitcoin (Sollet) + ['ETH', '7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs'], // Ether (Portal), will be treat as ETH due to higher liquidity + ['soETH', '2FPyTwcZLUg1MDrwsyoP4D6s1tM7hAkHYRjkNb5w6Pxk'], // Wrapped Ethereum (Sollet), will be treated as soETH + ['SOL', 'So11111111111111111111111111111111111111112'], + ['mSOL', 'mSoLzYCxHdYgdzU16g5QSh3i5K3z3KZK7ytfqcJm7So'], + ['MNGO', 'MangoCzJ36AjZyKwVj3VnYU4GTonjfVEnJmvvWaxLac'], +]); + +// Reference +// https://pyth.network/price-feeds/ +// https://switchboard.xyz/explorer +const MAINNET_ORACLES = new Map([ + ['USDT', '3vxLXJqLqF3JG5TCbYycbKWRBbCJQLxQmBGCkyqEEefL'], + ['BTC', 'GVXRSBjFk6e6J3NbVPXohDJetcTjaeeuykUpbQF8UoMU'], + ['ETH', 'JBu1AL4obBcCMqKBBxhpWCNUt136ijcuMZLFvTP7iWdB'], + ['soETH', 'JBu1AL4obBcCMqKBBxhpWCNUt136ijcuMZLFvTP7iWdB'], + ['SOL', 'H6ARHf6YXhGYeQfUzQNGk6rDNnLBQKrenN712K4AQJEG'], + ['mSOL', 'E4v1BBgoso9s64TQvmyownAVJbhbEPGyzA3qn4n46qj9'], + ['MNGO', '79wm3jjcPr6RaNQ4DGvP5KxG1mNd3gEBsg6FsNVFezK4'], +]); + +async function createGroup() { + const admin = Keypair.fromSecretKey( + Buffer.from( + JSON.parse(fs.readFileSync(process.env.MB_PAYER_KEYPAIR!, 'utf-8')), + ), + ); + + const options = AnchorProvider.defaultOptions(); + const connection = new Connection(process.env.MB_CLUSTER_URL!, options); + + const adminWallet = new Wallet(admin); + console.log(`Admin ${adminWallet.publicKey.toBase58()}`); + const adminProvider = new AnchorProvider(connection, adminWallet, options); + const client = await MangoClient.connect( + adminProvider, + 'mainnet-beta', + MANGO_V4_ID['mainnet-beta'], + {}, + 'get-program-accounts', + ); + + console.log(`Creating Group...`); + const insuranceMint = new PublicKey(MAINNET_MINTS.get('USDC')!); + await client.groupCreate( + GROUP_NUM, + true /* with intention */, + 0 /* since spot and perp features are not finished */, + insuranceMint, + ); + const group = await client.getGroupForCreator(admin.publicKey, GROUP_NUM); + console.log(`...registered group ${group.publicKey}`); +} + +async function registerTokens() { + const admin = Keypair.fromSecretKey( + Buffer.from( + JSON.parse(fs.readFileSync(process.env.MB_PAYER_KEYPAIR!, 'utf-8')), + ), + ); + + const options = AnchorProvider.defaultOptions(); + const connection = new Connection(process.env.MB_CLUSTER_URL!, options); + + const adminWallet = new Wallet(admin); + console.log(`Admin ${adminWallet.publicKey.toBase58()}`); + const adminProvider = new AnchorProvider(connection, adminWallet, options); + const client = await MangoClient.connect( + adminProvider, + 'mainnet-beta', + MANGO_V4_ID['mainnet-beta'], + {}, + 'get-program-accounts' /* idsjson service doesn't know about this group yet */, + ); + + const group = await client.getGroupForCreator(admin.publicKey, GROUP_NUM); + + console.log(`Creating USDC stub oracle...`); + const usdcMainnetMint = new PublicKey(MAINNET_MINTS.get('USDC')!); + await client.stubOracleCreate(group, usdcMainnetMint, 1.0); + const usdcMainnetOracle = ( + await client.getStubOracle(group, usdcMainnetMint) + )[0]; + console.log(`...created stub oracle ${usdcMainnetOracle.publicKey}`); + + console.log(`Registering USDC...`); + await client.tokenRegister( + group, + usdcMainnetMint, + usdcMainnetOracle.publicKey, + 0.1, + 0, // insurance vault token should be the first to be registered + 'USDC', + 0.004, // rate parameters are chosen to be the same for all high asset weight tokens, + // hoping that dynamic rate parameter adjustment would be enough to tune their rates to the markets needs + 0.7, + 0.1, + 0.85, + 0.2, + 2.0, + 0.005, // 50 bps + 0.0005, // 5 bps + 1, + 1, + 1, + 1, + 0, + ); + + console.log(`Registering USDT...`); + const usdtMainnetMint = new PublicKey(MAINNET_MINTS.get('USDT')!); + const usdtMainnetOracle = new PublicKey(MAINNET_ORACLES.get('USDT')!); + await client.tokenRegister( + group, + usdtMainnetMint, + usdtMainnetOracle, + 0.1, + 1, + 'USDT', + 0.004, + 0.7, + 0.1, + 0.85, + 0.2, + 2.0, + 0.005, + 0.0005, + 0.95, + 0.9, + 1.05, + 1.1, + 0.025, // rule of thumb used - half of maintLiabWeight + ); + + console.log(`Registering BTC...`); + const btcMainnetMint = new PublicKey(MAINNET_MINTS.get('BTC')!); + const btcMainnetOracle = new PublicKey(MAINNET_ORACLES.get('BTC')!); + await client.tokenRegister( + group, + btcMainnetMint, + btcMainnetOracle, + 0.1, + 2, + 'BTC', + 0.004, + 0.7, + 0.1, + 0.85, + 0.2, + 2.0, + 0.005, + 0.0005, + 0.9, + 0.8, + 1.1, + 1.2, + 0.05, + ); + + console.log(`Registering ETH...`); + const ethMainnetMint = new PublicKey(MAINNET_MINTS.get('ETH')!); + const ethMainnetOracle = new PublicKey(MAINNET_ORACLES.get('ETH')!); + await client.tokenRegister( + group, + ethMainnetMint, + ethMainnetOracle, + 0.1, + 3, + 'ETH', + 0.004, + 0.7, + 0.1, + 0.85, + 0.2, + 2.0, + 0.005, + 0.0005, + 0.9, + 0.8, + 1.1, + 1.2, + 0.05, + ); + + console.log(`Registering soETH...`); + const soEthMainnetMint = new PublicKey(MAINNET_MINTS.get('soETH')!); + const soEthMainnetOracle = new PublicKey(MAINNET_ORACLES.get('soETH')!); + await client.tokenRegister( + group, + soEthMainnetMint, + soEthMainnetOracle, + 0.1, + 4, + 'soETH', + 0.004, + 0.7, + 0.1, + 0.85, + 0.2, + 2.0, + 0.005, + 0.0005, + 0.9, + 0.8, + 1.1, + 1.2, + 0.05, + ); + + console.log(`Registering SOL...`); + const solMainnetMint = new PublicKey(MAINNET_MINTS.get('SOL')!); + const solMainnetOracle = new PublicKey(MAINNET_ORACLES.get('SOL')!); + await client.tokenRegister( + group, + solMainnetMint, + solMainnetOracle, + 0.1, + 5, + 'SOL', + 0.004, + 0.7, + 0.1, + 0.85, + 0.2, + 2.0, + 0.005, + 0.0005, + 0.9, + 0.8, + 1.1, + 1.2, + 0.05, + ); + + console.log(`Registering mSOL...`); + const msolMainnetMint = new PublicKey(MAINNET_MINTS.get('mSOL')!); + const msolMainnetOracle = new PublicKey(MAINNET_ORACLES.get('mSOL')!); + await client.tokenRegister( + group, + msolMainnetMint, + msolMainnetOracle, + 0.1, + 6, + 'mSOL', + 0.004, + 0.7, + 0.1, + 0.85, + 0.2, + 2.0, + 0.005, + 0.0005, + 0.9, + 0.8, + 1.1, + 1.2, + 0.05, + ); + + // log tokens/banks + await group.reloadAll(client); + for (const [bank] of await group.banksMapByMint.values()) { + console.log(`${bank.toString()}`); + } +} + +async function main() { + createGroup(); + registerTokens(); +} + +try { + main(); +} catch (error) { + console.log(error); +} diff --git a/ts/client/src/mango_v4.ts b/ts/client/src/mango_v4.ts index 695f61f99..4b6a7c6c4 100644 --- a/ts/client/src/mango_v4.ts +++ b/ts/client/src/mango_v4.ts @@ -730,12 +730,7 @@ export type MangoV4 = { "isSigner": false } ], - "args": [ - { - "name": "tokenIndex", - "type": "u16" - } - ] + "args": [] }, { "name": "tokenUpdateIndexAndRate", @@ -1106,6 +1101,11 @@ export type MangoV4 = { "isMut": true, "isSigner": false }, + { + "name": "oracle", + "isMut": false, + "isSigner": false + }, { "name": "tokenAccount", "isMut": true, @@ -1157,6 +1157,11 @@ export type MangoV4 = { "isMut": true, "isSigner": false }, + { + "name": "oracle", + "isMut": false, + "isSigner": false + }, { "name": "tokenAccount", "isMut": true, @@ -1233,6 +1238,36 @@ export type MangoV4 = { } ] }, + { + "name": "healthRegionBegin", + "accounts": [ + { + "name": "instructions", + "isMut": false, + "isSigner": false, + "docs": [ + "Instructions Sysvar for instruction introspection" + ] + }, + { + "name": "account", + "isMut": true, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "healthRegionEnd", + "accounts": [ + { + "name": "account", + "isMut": true, + "isSigner": false + } + ], + "args": [] + }, { "name": "serum3RegisterMarket", "docs": [ @@ -1285,6 +1320,30 @@ export type MangoV4 = { ] } }, + { + "name": "indexReservation", + "isMut": true, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "Serum3Index" + }, + { + "kind": "account", + "type": "publicKey", + "path": "group" + }, + { + "kind": "arg", + "type": "u16", + "path": "market_index" + } + ] + } + }, { "name": "quoteBank", "isMut": false, @@ -1546,24 +1605,20 @@ export type MangoV4 = { ] }, { - "name": "quoteBank", + "name": "payerBank", "isMut": true, - "isSigner": false + "isSigner": false, + "docs": [ + "The bank that pays for the order, if necessary" + ] }, { - "name": "quoteVault", + "name": "payerVault", "isMut": true, - "isSigner": false - }, - { - "name": "baseBank", - "isMut": true, - "isSigner": false - }, - { - "name": "baseVault", - "isMut": true, - "isSigner": false + "isSigner": false, + "docs": [ + "The bank vault that pays for the order, if necessary" + ] }, { "name": "tokenProgram", @@ -3000,12 +3055,21 @@ export type MangoV4 = { "", "Normally accounts can not be liquidated while maint_health >= 0. But when an account", "reaches maint_health < 0, liquidators will call a liquidation instruction and thereby", - "set this flag. Now the account may be liquidated until init_health >= 0." + "set this flag. Now the account may be liquidated until init_health >= 0.", + "", + "Many actions should be disabled while the account is being liquidated, even if", + "its maint health has recovered to positive. Creating new open orders would, for example,", + "confuse liquidators." ], "type": "u8" }, { - "name": "padding2", + "name": "inHealthRegion", + "docs": [ + "The account is currently inside a health region marked by HealthRegionBegin...HealthRegionEnd.", + "", + "Must never be set after a transaction ends." + ], "type": "u8" }, { @@ -3029,12 +3093,19 @@ export type MangoV4 = { "name": "netSettled", "type": "i64" }, + { + "name": "healthRegionPreInitHealth", + "docs": [ + "Init health as calculated during HealthReginBegin, rounded up." + ], + "type": "i64" + }, { "name": "reserved", "type": { "array": [ "u8", - 248 + 240 ] } }, @@ -3095,7 +3166,7 @@ export type MangoV4 = { "name": "perpOpenOrders", "type": { "vec": { - "defined": "PerpOpenOrders" + "defined": "PerpOpenOrder" } } } @@ -3789,6 +3860,10 @@ export type MangoV4 = { { "name": "quoteIndex", "type": "u64" + }, + { + "name": "marketIndex", + "type": "u16" } ] } @@ -3798,6 +3873,10 @@ export type MangoV4 = { "type": { "kind": "struct", "fields": [ + { + "name": "perpMarketIndex", + "type": "u16" + }, { "name": "maintAssetWeight", "type": { @@ -3932,11 +4011,17 @@ export type MangoV4 = { "type": "publicKey" }, { - "name": "previousNativeCoinReserved", + "name": "baseBorrowsWithoutFee", + "docs": [ + "Tracks the amount of borrows that have flowed into the serum open orders account.", + "These borrows did not have the loan origination fee applied, and that may happen", + "later (in serum3_settle_funds) if we can guarantee that the funds were used.", + "In particular a place-on-book, cancel, settle should not cost fees." + ], "type": "u64" }, { - "name": "previousNativePcReserved", + "name": "quoteBorrowsWithoutFee", "type": "u64" }, { @@ -4077,7 +4162,7 @@ export type MangoV4 = { } }, { - "name": "PerpOpenOrders", + "name": "PerpOpenOrder", "type": { "kind": "struct", "fields": [ @@ -5146,43 +5231,53 @@ export type MangoV4 = { }, { "code": 6007, + "name": "HealthMustBePositiveOrIncrease", + "msg": "health must be positive or increase" + }, + { + "code": 6008, "name": "HealthMustBeNegative", "msg": "health must be negative" }, { - "code": 6008, + "code": 6009, "name": "IsBankrupt", "msg": "the account is bankrupt" }, { - "code": 6009, + "code": 6010, "name": "IsNotBankrupt", "msg": "the account is not bankrupt" }, { - "code": 6010, + "code": 6011, "name": "NoFreeTokenPositionIndex", "msg": "no free token position index" }, { - "code": 6011, + "code": 6012, "name": "NoFreeSerum3OpenOrdersIndex", "msg": "no free serum3 open orders index" }, { - "code": 6012, + "code": 6013, "name": "NoFreePerpPositionIndex", "msg": "no free perp position index" }, { - "code": 6013, + "code": 6014, "name": "Serum3OpenOrdersExistAlready", "msg": "serum3 open orders exist already" }, { - "code": 6014, + "code": 6015, "name": "InsufficentBankVaultFunds", "msg": "bank vault has insufficent funds" + }, + { + "code": 6016, + "name": "BeingLiquidated", + "msg": "account is currently being liquidated" } ] }; @@ -5919,12 +6014,7 @@ export const IDL: MangoV4 = { "isSigner": false } ], - "args": [ - { - "name": "tokenIndex", - "type": "u16" - } - ] + "args": [] }, { "name": "tokenUpdateIndexAndRate", @@ -6295,6 +6385,11 @@ export const IDL: MangoV4 = { "isMut": true, "isSigner": false }, + { + "name": "oracle", + "isMut": false, + "isSigner": false + }, { "name": "tokenAccount", "isMut": true, @@ -6346,6 +6441,11 @@ export const IDL: MangoV4 = { "isMut": true, "isSigner": false }, + { + "name": "oracle", + "isMut": false, + "isSigner": false + }, { "name": "tokenAccount", "isMut": true, @@ -6422,6 +6522,36 @@ export const IDL: MangoV4 = { } ] }, + { + "name": "healthRegionBegin", + "accounts": [ + { + "name": "instructions", + "isMut": false, + "isSigner": false, + "docs": [ + "Instructions Sysvar for instruction introspection" + ] + }, + { + "name": "account", + "isMut": true, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "healthRegionEnd", + "accounts": [ + { + "name": "account", + "isMut": true, + "isSigner": false + } + ], + "args": [] + }, { "name": "serum3RegisterMarket", "docs": [ @@ -6474,6 +6604,30 @@ export const IDL: MangoV4 = { ] } }, + { + "name": "indexReservation", + "isMut": true, + "isSigner": false, + "pda": { + "seeds": [ + { + "kind": "const", + "type": "string", + "value": "Serum3Index" + }, + { + "kind": "account", + "type": "publicKey", + "path": "group" + }, + { + "kind": "arg", + "type": "u16", + "path": "market_index" + } + ] + } + }, { "name": "quoteBank", "isMut": false, @@ -6735,24 +6889,20 @@ export const IDL: MangoV4 = { ] }, { - "name": "quoteBank", + "name": "payerBank", "isMut": true, - "isSigner": false + "isSigner": false, + "docs": [ + "The bank that pays for the order, if necessary" + ] }, { - "name": "quoteVault", + "name": "payerVault", "isMut": true, - "isSigner": false - }, - { - "name": "baseBank", - "isMut": true, - "isSigner": false - }, - { - "name": "baseVault", - "isMut": true, - "isSigner": false + "isSigner": false, + "docs": [ + "The bank vault that pays for the order, if necessary" + ] }, { "name": "tokenProgram", @@ -8189,12 +8339,21 @@ export const IDL: MangoV4 = { "", "Normally accounts can not be liquidated while maint_health >= 0. But when an account", "reaches maint_health < 0, liquidators will call a liquidation instruction and thereby", - "set this flag. Now the account may be liquidated until init_health >= 0." + "set this flag. Now the account may be liquidated until init_health >= 0.", + "", + "Many actions should be disabled while the account is being liquidated, even if", + "its maint health has recovered to positive. Creating new open orders would, for example,", + "confuse liquidators." ], "type": "u8" }, { - "name": "padding2", + "name": "inHealthRegion", + "docs": [ + "The account is currently inside a health region marked by HealthRegionBegin...HealthRegionEnd.", + "", + "Must never be set after a transaction ends." + ], "type": "u8" }, { @@ -8218,12 +8377,19 @@ export const IDL: MangoV4 = { "name": "netSettled", "type": "i64" }, + { + "name": "healthRegionPreInitHealth", + "docs": [ + "Init health as calculated during HealthReginBegin, rounded up." + ], + "type": "i64" + }, { "name": "reserved", "type": { "array": [ "u8", - 248 + 240 ] } }, @@ -8284,7 +8450,7 @@ export const IDL: MangoV4 = { "name": "perpOpenOrders", "type": { "vec": { - "defined": "PerpOpenOrders" + "defined": "PerpOpenOrder" } } } @@ -8978,6 +9144,10 @@ export const IDL: MangoV4 = { { "name": "quoteIndex", "type": "u64" + }, + { + "name": "marketIndex", + "type": "u16" } ] } @@ -8987,6 +9157,10 @@ export const IDL: MangoV4 = { "type": { "kind": "struct", "fields": [ + { + "name": "perpMarketIndex", + "type": "u16" + }, { "name": "maintAssetWeight", "type": { @@ -9121,11 +9295,17 @@ export const IDL: MangoV4 = { "type": "publicKey" }, { - "name": "previousNativeCoinReserved", + "name": "baseBorrowsWithoutFee", + "docs": [ + "Tracks the amount of borrows that have flowed into the serum open orders account.", + "These borrows did not have the loan origination fee applied, and that may happen", + "later (in serum3_settle_funds) if we can guarantee that the funds were used.", + "In particular a place-on-book, cancel, settle should not cost fees." + ], "type": "u64" }, { - "name": "previousNativePcReserved", + "name": "quoteBorrowsWithoutFee", "type": "u64" }, { @@ -9266,7 +9446,7 @@ export const IDL: MangoV4 = { } }, { - "name": "PerpOpenOrders", + "name": "PerpOpenOrder", "type": { "kind": "struct", "fields": [ @@ -10335,43 +10515,53 @@ export const IDL: MangoV4 = { }, { "code": 6007, + "name": "HealthMustBePositiveOrIncrease", + "msg": "health must be positive or increase" + }, + { + "code": 6008, "name": "HealthMustBeNegative", "msg": "health must be negative" }, { - "code": 6008, + "code": 6009, "name": "IsBankrupt", "msg": "the account is bankrupt" }, { - "code": 6009, + "code": 6010, "name": "IsNotBankrupt", "msg": "the account is not bankrupt" }, { - "code": 6010, + "code": 6011, "name": "NoFreeTokenPositionIndex", "msg": "no free token position index" }, { - "code": 6011, + "code": 6012, "name": "NoFreeSerum3OpenOrdersIndex", "msg": "no free serum3 open orders index" }, { - "code": 6012, + "code": 6013, "name": "NoFreePerpPositionIndex", "msg": "no free perp position index" }, { - "code": 6013, + "code": 6014, "name": "Serum3OpenOrdersExistAlready", "msg": "serum3 open orders exist already" }, { - "code": 6014, + "code": 6015, "name": "InsufficentBankVaultFunds", "msg": "bank vault has insufficent funds" + }, + { + "code": 6016, + "name": "BeingLiquidated", + "msg": "account is currently being liquidated" } ] }; diff --git a/ts/client/src/scripts/devnet-admin.ts b/ts/client/src/scripts/devnet-admin.ts index 81f808413..7db068930 100644 --- a/ts/client/src/scripts/devnet-admin.ts +++ b/ts/client/src/scripts/devnet-admin.ts @@ -17,6 +17,8 @@ import { MANGO_V4_ID } from '../constants'; const DEVNET_SERUM3_MARKETS = new Map([ ['BTC/USDC', 'DW83EpHFywBxCHmyARxwj3nzxJd7MUdSeznmrdzZKNZB'], ['SOL/USDC', '5xWpt56U1NCuHoAEtpLeUrQcxDkEpNfScjfLFaRzLPgR'], + ['ETH/USDC', 'BkAraCyL9TTLbeMY3L1VWrPcv32DvSi5QDDQjik1J6Ac'], + ['SRM/USDC', '249LDNPLLL29nRq8kjBTg9hKdXMcZf4vK2UvxszZYcuZ'], ]); const DEVNET_MINTS = new Map([ ['USDC', '8FRFC6MoGGkMFQwngccyu69VnYbzykGeez7ignHVAFSN'], // use devnet usdc @@ -24,12 +26,16 @@ const DEVNET_MINTS = new Map([ ['SOL', 'So11111111111111111111111111111111111111112'], ['ORCA', 'orcarKHSqC5CDDsGbho8GKvwExejWHxTqGzXgcewB9L'], ['MNGO', 'Bb9bsTQa1bGEtQ5KagGkvSHyuLqDWumFUcRqFusFNJWC'], + ['ETH', 'Cu84KB3tDL6SbFgToHMLYVDJJXdJjenNzSKikeAvzmkA'], + ['SRM', 'AvtB6w9xboLwA145E221vhof5TddhqsChYcx7Fy3xVMH'], ]); const DEVNET_ORACLES = new Map([ ['BTC', 'HovQMDrbAgAYPCmHVSrezcSmkMtXSSUsLDFANExrZh2J'], ['SOL', 'J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix'], ['ORCA', 'A1WttWF7X3Rg6ZRpB2YQUFHCRh1kiXV8sKKLV3S9neJV'], ['MNGO', '8k7F9Xb36oFJsjpCKpsXvg4cgBRoZtwNTc3EzG5Ttd2o'], + ['ETH', 'EdVCmQ9FSPcVe5YySXDPCRmc8aDQLKJ9xvYBMZPie1Vw'], + ['SRM', '992moaMQKs32GKZ9dxi8keyM2bUmbrwBZpK4p2K6X5Vs'], ]); const GROUP_NUM = Number(process.env.GROUP_NUM || 0); @@ -88,19 +94,19 @@ async function main() { 0.1, 0, // tokenIndex 'USDC', - 0.01, - 0.4, - 0.07, - 0.8, - 0.9, - 1.5, + 0.004, + 0.7, + 0.1, + 0.85, + 0.2, + 2.0, + 0.005, 0.0005, - 0.0005, - 0.8, - 0.6, - 1.2, - 1.4, - 0.02, + 1, + 1, + 1, + 1, + 0, ); await group.reloadAll(client); } catch (error) {} @@ -117,19 +123,19 @@ async function main() { 0.1, 1, // tokenIndex 'BTC', - 0.01, - 0.4, - 0.07, - 0.8, + 0.004, + 0.7, + 0.1, + 0.85, + 0.2, + 2.0, + 0.005, + 0.0005, 0.9, - 0.88, - 0.0005, - 0.0005, 0.8, - 0.6, + 1.1, 1.2, - 1.4, - 0.02, + 0.05, ); await group.reloadAll(client); } catch (error) { @@ -148,19 +154,19 @@ async function main() { 0.1, 2, // tokenIndex 'SOL', - 0.01, - 0.4, - 0.07, - 0.8, + 0.004, + 0.7, + 0.1, + 0.85, + 0.2, + 2.0, + 0.005, + 0.0005, 0.9, - 0.63, - 0.0005, - 0.0005, 0.8, - 0.6, + 1.1, 1.2, - 1.4, - 0.02, + 0.05, ); await group.reloadAll(client); } catch (error) { @@ -198,14 +204,75 @@ async function main() { console.log(error); } - // register token 4 + // register token 7 + console.log(`Registering ETH...`); + const ethDevnetMint = new PublicKey(DEVNET_MINTS.get('ETH')!); + const ethDevnetOracle = new PublicKey(DEVNET_ORACLES.get('ETH')!); + try { + await client.tokenRegister( + group, + ethDevnetMint, + ethDevnetOracle, + 0.1, + 7, // tokenIndex + 'ETH', + 0.004, + 0.7, + 0.1, + 0.85, + 0.2, + 2.0, + 0.005, + 0.0005, + 0.9, + 0.8, + 1.1, + 1.2, + 0.05, + ); + await group.reloadAll(client); + } catch (error) { + console.log(error); + } + + // register token 5 + console.log(`Registering SRM...`); + const srmDevnetMint = new PublicKey(DEVNET_MINTS.get('SRM')!); + const srmDevnetOracle = new PublicKey(DEVNET_ORACLES.get('SRM')!); + try { + await client.tokenRegister( + group, + srmDevnetMint, + srmDevnetOracle, + 0.1, + 5, // tokenIndex + 'SRM', + 0.004, + 0.7, + 0.1, + 0.85, + 0.2, + 2.0, + 0.005, + 0.0005, + 0.9, + 0.8, + 1.1, + 1.2, + 0.05, + ); + await group.reloadAll(client); + } catch (error) { + console.log(error); + } + console.log( `Editing group, setting existing admin as fastListingAdmin to be able to add MNGO truslessly...`, ); let sig = await client.groupEdit( group, group.admin, - new PublicKey('Efhak3qj3MiyzgJr3cUUqXXz5wr3oYHt9sPzuqJf9eBN'), + group.admin, undefined, undefined, ); @@ -231,7 +298,7 @@ async function main() { // register serum market console.log(`Registering serum3 market...`); - const serumMarketExternalPk = new PublicKey( + let serumMarketExternalPk = new PublicKey( DEVNET_SERUM3_MARKETS.get('BTC/USDC')!, ); try { @@ -251,7 +318,35 @@ async function main() { group.getFirstBankByMint(btcDevnetMint).tokenIndex, group.getFirstBankByMint(usdcDevnetMint).tokenIndex, ); - console.log(`...registerd serum3 market ${markets[0].publicKey}`); + console.log(`...registered serum3 market ${markets[0].publicKey}`); + + serumMarketExternalPk = new PublicKey(DEVNET_SERUM3_MARKETS.get('ETH/USDC')!); + try { + await client.serum3RegisterMarket( + group, + serumMarketExternalPk, + group.getFirstBankByMint(ethDevnetMint), + group.getFirstBankByMint(usdcDevnetMint), + 0, + 'ETH/USDC', + ); + } catch (error) { + console.log(error); + } + + serumMarketExternalPk = new PublicKey(DEVNET_SERUM3_MARKETS.get('SRM/USDC')!); + try { + await client.serum3RegisterMarket( + group, + serumMarketExternalPk, + group.getFirstBankByMint(srmDevnetMint), + group.getFirstBankByMint(usdcDevnetMint), + 0, + 'SRM/USDC', + ); + } catch (error) { + console.log(error); + } // register perp market console.log(`Registering perp market...`); @@ -292,114 +387,93 @@ async function main() { // edit // - console.log(`Editing USDC...`); - try { - let sig = await client.tokenEdit( - group, - usdcDevnetMint, - btcDevnetOracle, - 0.1, - undefined, - 0.01, - 0.3, - 0.08, - 0.81, - 0.91, - 0.75, - 0.0007, - 1.7, - 0.9, - 0.7, - 1.3, - 1.5, - 0.04, - ); - console.log(`https://explorer.solana.com/tx/${sig}?cluster=devnet`); - await group.reloadAll(client); - console.log(group.getFirstBankByMint(btcDevnetMint).toString()); - } catch (error) { - throw error; - } - console.log(`Resetting USDC...`); - try { - let sig = await client.tokenEdit( - group, - usdcDevnetMint, - usdcDevnetOracle.publicKey, - 0.1, - undefined, - 0.01, - 0.4, - 0.07, - 0.8, - 0.9, - 1.5, - 0.0005, - 0.0005, - 1.0, - 1.0, - 1.0, - 1.0, - 0.02, - ); - console.log(`https://explorer.solana.com/tx/${sig}?cluster=devnet`); - await group.reloadAll(client); - console.log(group.getFirstBankByMint(usdcDevnetMint).toString()); - } catch (error) { - throw error; - } + if (true) { + console.log(`Editing USDC...`); + try { + let sig = await client.tokenEdit( + group, + usdcDevnetMint, + usdcDevnetOracle.publicKey, + 0.1, + undefined, + 0.004, + 0.7, + 0.1, + 0.85, + 0.2, + 2.0, + 0.005, + 0.0005, + 1, + 1, + 1, + 1, + 0, + ); + console.log(`https://explorer.solana.com/tx/${sig}?cluster=devnet`); + await group.reloadAll(client); + console.log(group.getFirstBankByMint(btcDevnetMint).toString()); + } catch (error) { + throw error; + } - console.log(`Editing perp market...`); - try { - let sig = await client.perpEditMarket( - group, - 'BTC-PERP', - btcDevnetOracle, - 0.2, - 0, - 6, - 0.9, - 0.9, - 1.035, - 1.06, - 0.013, - 0.0003, - 0.1, - 0.07, - 0.07, - 1001, - ); - console.log(`https://explorer.solana.com/tx/${sig}?cluster=devnet`); - await group.reloadAll(client); - console.log(group.perpMarketsMap.get('BTC-PERP')!.toString()); - } catch (error) { - console.log(error); - } - console.log(`Resetting perp market...`); - try { - let sig = await client.perpEditMarket( - group, - 'BTC-PERP', - btcDevnetOracle, - 0.1, - 1, - 6, - 1, - 0.95, - 1.025, - 1.05, - 0.012, - 0.0002, - 0.0, - 0.05, - 0.05, - 100, - ); - console.log(`https://explorer.solana.com/tx/${sig}?cluster=devnet`); - await group.reloadAll(client); - console.log(group.perpMarketsMap.get('BTC-PERP')!.toString()); - } catch (error) { - console.log(error); + console.log(`Editing BTC...`); + try { + let sig = await client.tokenEdit( + group, + usdcDevnetMint, + usdcDevnetOracle.publicKey, + 0.1, + undefined, + 0.004, + 0.7, + 0.1, + 0.85, + 0.2, + 2.0, + 0.005, + 0.0005, + 0.9, + 0.8, + 1.1, + 1.2, + 0.05, + ); + console.log(`https://explorer.solana.com/tx/${sig}?cluster=devnet`); + await group.reloadAll(client); + console.log(group.getFirstBankByMint(btcDevnetMint).toString()); + } catch (error) { + throw error; + } + + console.log(`Editing SOL...`); + try { + let sig = await client.tokenEdit( + group, + usdcDevnetMint, + usdcDevnetOracle.publicKey, + 0.1, + undefined, + 0.004, + 0.7, + 0.1, + 0.85, + 0.2, + 2.0, + 0.005, + 0.0005, + 0.9, + 0.8, + 1.1, + 1.2, + 0.05, + ); + console.log(`https://explorer.solana.com/tx/${sig}?cluster=devnet`); + await group.reloadAll(client); + console.log(group.getFirstBankByMint(btcDevnetMint).toString()); + } catch (error) { + throw error; + } } process.exit(); diff --git a/ts/client/src/scripts/devnet-user-2-close-account.ts b/ts/client/src/scripts/devnet-user-2-close-account.ts new file mode 100644 index 000000000..52afc4907 --- /dev/null +++ b/ts/client/src/scripts/devnet-user-2-close-account.ts @@ -0,0 +1,136 @@ +import { AnchorProvider, Wallet } from '@project-serum/anchor'; +import { Connection, Keypair } from '@solana/web3.js'; +import fs from 'fs'; +import { Serum3Side } from '../accounts/serum3'; +import { MangoClient } from '../client'; +import { MANGO_V4_ID } from '../constants'; + +// +// script which shows how to close a mango account cleanly i.e. close all active positions, withdraw all tokens, etc. +// + +const GROUP_NUM = Number(process.env.GROUP_NUM || 0); + +// note: either use finalized or expect closing certain things to fail and having to runs scrript multiple times +async function main() { + const options = AnchorProvider.defaultOptions(); + + // note: see note above + // options.commitment = 'finalized'; + + const connection = new Connection( + 'https://mango.devnet.rpcpool.com', + options, + ); + + // user + const user = Keypair.fromSecretKey( + Buffer.from( + JSON.parse(fs.readFileSync(process.env.USER2_KEYPAIR!, 'utf-8')), + ), + ); + const userWallet = new Wallet(user); + const userProvider = new AnchorProvider(connection, userWallet, options); + const client = await MangoClient.connect( + userProvider, + 'devnet', + MANGO_V4_ID['devnet'], + {}, + 'get-program-accounts', + ); + console.log(`User ${userWallet.publicKey.toBase58()}`); + + try { + // fetch group + const admin = Keypair.fromSecretKey( + Buffer.from( + JSON.parse(fs.readFileSync(process.env.ADMIN_KEYPAIR!, 'utf-8')), + ), + ); + const group = await client.getGroupForCreator(admin.publicKey, GROUP_NUM); + console.log(`Found group ${group.publicKey.toBase58()}`); + + // fetch account + const mangoAccount = ( + await client.getMangoAccountsForOwner(group, user.publicKey) + )[0]; + console.log(`...found mangoAccount ${mangoAccount.publicKey}`); + console.log(mangoAccount.toString()); + + // close mango account serum3 positions, closing might require cancelling orders and settling + for (const serum3Account of mangoAccount.serum3Active()) { + let orders = await client.getSerum3Orders( + group, + group.findSerum3Market(serum3Account.marketIndex)!.name, + ); + for (const order of orders) { + console.log( + ` - Order orderId ${order.orderId}, ${order.side}, ${order.price}, ${order.size}`, + ); + console.log(` - Cancelling order with ${order.orderId}`); + await client.serum3CancelOrder( + group, + mangoAccount, + + 'BTC/USDC', + order.side === 'buy' ? Serum3Side.bid : Serum3Side.ask, + order.orderId, + ); + } + await client.serum3SettleFunds( + group, + mangoAccount, + group.findSerum3Market(serum3Account.marketIndex)!.name, + ); + await client.serum3CloseOpenOrders( + group, + mangoAccount, + group.findSerum3Market(serum3Account.marketIndex)!.name, + ); + } + + // we closed a serum account, this changes the health accounts we are passing in for future ixs + await mangoAccount.reload(client, group); + + // withdraw all tokens + for (const token of mangoAccount.tokensActive()) { + let native = token.balance( + group.getFirstBankByTokenIndex(token.tokenIndex), + ); + + // to avoid rounding issues + if (native.toNumber() < 1) { + continue; + } + let nativeFlooredNumber = Math.floor(native.toNumber()); + console.log( + `withdrawing token ${ + group.getFirstBankByTokenIndex(token.tokenIndex).name + } native amount ${nativeFlooredNumber} `, + ); + + await client.tokenWithdrawNative( + group, + mangoAccount, + group.getFirstBankByTokenIndex(token.tokenIndex).mint, + nativeFlooredNumber - 1 /* see comment in token_withdraw in program */, + false, + ); + } + + // reload and print current positions + await mangoAccount.reload(client, group); + console.log(`...mangoAccount ${mangoAccount.publicKey}`); + console.log(mangoAccount.toString()); + + // close account + console.log(`Close mango account...`); + const res = await client.closeMangoAccount(group, mangoAccount); + } catch (error) { + console.log(error); + } + + process.exit(); +} + +main(); diff --git a/ts/client/src/scripts/devnet-user-2.ts b/ts/client/src/scripts/devnet-user-2.ts new file mode 100644 index 000000000..14b532968 --- /dev/null +++ b/ts/client/src/scripts/devnet-user-2.ts @@ -0,0 +1,134 @@ +import { AnchorProvider, Wallet } from '@project-serum/anchor'; +import { Connection, Keypair, PublicKey } from '@solana/web3.js'; +import fs from 'fs'; +import { MangoClient } from '../client'; +import { MANGO_V4_ID } from '../constants'; + +// +// An example for users based on high level api i.e. the client +// Create +// process.env.USER_KEYPAIR - mango account owner keypair path +// process.env.ADMIN_KEYPAIR - group admin keypair path (useful for automatically finding the group) +// +// This script deposits some tokens, places some serum orders, cancels them, places some perp orders +// + +const DEVNET_MINTS = new Map([ + ['USDC', '8FRFC6MoGGkMFQwngccyu69VnYbzykGeez7ignHVAFSN'], // use devnet usdc + ['BTC', '3UNBZ6o52WTWwjac2kPUb4FyodhU1vFkRJheu1Sh2TvU'], + ['SOL', 'So11111111111111111111111111111111111111112'], + ['ORCA', 'orcarKHSqC5CDDsGbho8GKvwExejWHxTqGzXgcewB9L'], + ['MNGO', 'Bb9bsTQa1bGEtQ5KagGkvSHyuLqDWumFUcRqFusFNJWC'], + ['ETH', 'Cu84KB3tDL6SbFgToHMLYVDJJXdJjenNzSKikeAvzmkA'], + ['SRM', 'AvtB6w9xboLwA145E221vhof5TddhqsChYcx7Fy3xVMH'], +]); +const DEVNET_ORACLES = new Map([ + ['BTC', 'HovQMDrbAgAYPCmHVSrezcSmkMtXSSUsLDFANExrZh2J'], + ['SOL', 'J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix'], + ['ORCA', 'A1WttWF7X3Rg6ZRpB2YQUFHCRh1kiXV8sKKLV3S9neJV'], + ['MNGO', '8k7F9Xb36oFJsjpCKpsXvg4cgBRoZtwNTc3EzG5Ttd2o'], + ['ETH', 'EdVCmQ9FSPcVe5YySXDPCRmc8aDQLKJ9xvYBMZPie1Vw'], + ['SRM', '992moaMQKs32GKZ9dxi8keyM2bUmbrwBZpK4p2K6X5Vs'], +]); +export const DEVNET_SERUM3_MARKETS = new Map([ + ['BTC/USDC', new PublicKey('DW83EpHFywBxCHmyARxwj3nzxJd7MUdSeznmrdzZKNZB')], + ['SOL/USDC', new PublicKey('5xWpt56U1NCuHoAEtpLeUrQcxDkEpNfScjfLFaRzLPgR')], +]); + +const GROUP_NUM = Number(process.env.GROUP_NUM || 0); + +async function main() { + const options = AnchorProvider.defaultOptions(); + const connection = new Connection( + 'https://mango.devnet.rpcpool.com', + options, + ); + + const user = Keypair.fromSecretKey( + Buffer.from( + JSON.parse(fs.readFileSync(process.env.USER2_KEYPAIR!, 'utf-8')), + ), + ); + const userWallet = new Wallet(user); + const userProvider = new AnchorProvider(connection, userWallet, options); + const client = await MangoClient.connect( + userProvider, + 'devnet', + MANGO_V4_ID['devnet'], + {}, + 'get-program-accounts', + ); + console.log(`User ${userWallet.publicKey.toBase58()}`); + + // fetch group + const admin = Keypair.fromSecretKey( + Buffer.from( + JSON.parse(fs.readFileSync(process.env.ADMIN_KEYPAIR!, 'utf-8')), + ), + ); + const group = await client.getGroupForCreator(admin.publicKey, GROUP_NUM); + console.log(`${group}`); + + // create + fetch account + console.log(`Creating mangoaccount...`); + const mangoAccount = await client.getOrCreateMangoAccount( + group, + user.publicKey, + ); + console.log(`...created/found mangoAccount ${mangoAccount.publicKey}`); + console.log(mangoAccount.toString(group)); + + if (true) { + // deposit and withdraw + + try { + console.log(`...depositing`); + await client.tokenDeposit( + group, + mangoAccount, + new PublicKey(DEVNET_MINTS.get('USDC')!), + 1000, + ); + await mangoAccount.reload(client, group); + + await client.tokenDeposit( + group, + mangoAccount, + new PublicKey(DEVNET_MINTS.get('MNGO')!), + 100, + ); + await mangoAccount.reload(client, group); + + await client.tokenDeposit( + group, + mangoAccount, + new PublicKey(DEVNET_MINTS.get('ETH')!), + 500, + ); + await mangoAccount.reload(client, group); + + await client.tokenDeposit( + group, + mangoAccount, + new PublicKey(DEVNET_MINTS.get('SRM')!), + 500, + ); + await mangoAccount.reload(client, group); + + await client.tokenDeposit( + group, + mangoAccount, + new PublicKey(DEVNET_MINTS.get('BTC')!), + 1, + ); + await mangoAccount.reload(client, group); + + console.log(mangoAccount.toString(group)); + } catch (error) { + console.log(error); + } + } + process.exit(); +} + +main(); diff --git a/ts/client/src/scripts/devnet-user-close-account.ts b/ts/client/src/scripts/devnet-user-close-account.ts index a7728065b..6eb7538b0 100644 --- a/ts/client/src/scripts/devnet-user-close-account.ts +++ b/ts/client/src/scripts/devnet-user-close-account.ts @@ -94,7 +94,7 @@ async function main() { // withdraw all tokens for (const token of mangoAccount.tokensActive()) { - let native = token.native( + let native = token.balance( group.getFirstBankByTokenIndex(token.tokenIndex), ); diff --git a/ts/client/src/scripts/devnet-user.ts b/ts/client/src/scripts/devnet-user.ts index 17d2338c3..b450aad96 100644 --- a/ts/client/src/scripts/devnet-user.ts +++ b/ts/client/src/scripts/devnet-user.ts @@ -1,6 +1,7 @@ import { AnchorProvider, Wallet } from '@project-serum/anchor'; import { Connection, Keypair, PublicKey } from '@solana/web3.js'; import fs from 'fs'; +import { I80F48 } from '../accounts/I80F48'; import { HealthType } from '../accounts/mangoAccount'; import { OrderType, Side } from '../accounts/perp'; import { @@ -28,6 +29,10 @@ const DEVNET_MINTS = new Map([ ['ORCA', 'orcarKHSqC5CDDsGbho8GKvwExejWHxTqGzXgcewB9L'], ['MNGO', 'Bb9bsTQa1bGEtQ5KagGkvSHyuLqDWumFUcRqFusFNJWC'], ]); +export const DEVNET_SERUM3_MARKETS = new Map([ + ['BTC/USDC', new PublicKey('DW83EpHFywBxCHmyARxwj3nzxJd7MUdSeznmrdzZKNZB')], + ['SOL/USDC', new PublicKey('5xWpt56U1NCuHoAEtpLeUrQcxDkEpNfScjfLFaRzLPgR')], +]); const GROUP_NUM = Number(process.env.GROUP_NUM || 0); @@ -61,7 +66,7 @@ async function main() { ), ); const group = await client.getGroupForCreator(admin.publicKey, GROUP_NUM); - console.log(group.toString()); + console.log(`${group}`); // create + fetch account console.log(`Creating mangoaccount...`); @@ -70,9 +75,11 @@ async function main() { user.publicKey, ); console.log(`...created/found mangoAccount ${mangoAccount.publicKey}`); - console.log(mangoAccount.toString()); + console.log(mangoAccount.toString(group)); - if (true) { + await mangoAccount.reload(client, group); + + if (false) { // set delegate, and change name console.log(`...changing mango account name, and setting a delegate`); const randomKey = new PublicKey( @@ -99,7 +106,8 @@ async function main() { console.log(mangoAccount.toString()); } - if (true) { + if (false) { + // expand account console.log( `...expanding mango account to have serum3 and perp position slots`, ); @@ -107,11 +115,11 @@ async function main() { await mangoAccount.reload(client, group); } - if (true) { + if (false) { // deposit and withdraw try { - console.log(`...depositing 50 USDC`); + console.log(`...depositing 50 USDC, 1 SOL, 1 MNGO`); await client.tokenDeposit( group, mangoAccount, @@ -120,6 +128,22 @@ async function main() { ); await mangoAccount.reload(client, group); + await client.tokenDeposit( + group, + mangoAccount, + new PublicKey(DEVNET_MINTS.get('SOL')!), + 1, + ); + await mangoAccount.reload(client, group); + + await client.tokenDeposit( + group, + mangoAccount, + new PublicKey(DEVNET_MINTS.get('MNGO')!), + 1, + ); + await mangoAccount.reload(client, group); + console.log(`...withdrawing 1 USDC`); await client.tokenWithdraw( group, @@ -138,21 +162,44 @@ async function main() { 0.0005, ); await mangoAccount.reload(client, group); + + console.log(mangoAccount.toString(group)); } catch (error) { console.log(error); } + } + if (false) { // serum3 + const serum3Market = group.serum3MarketsMapByExternal.get( + DEVNET_SERUM3_MARKETS.get('BTC/USDC')?.toBase58()!, + ); + const serum3MarketExternal = group.serum3MarketExternalsMap.get( + DEVNET_SERUM3_MARKETS.get('BTC/USDC')?.toBase58()!, + ); + const asks = await group.loadSerum3AsksForMarket( + client, + DEVNET_SERUM3_MARKETS.get('BTC/USDC')!, + ); + const lowestAsk = Array.from(asks!)[0]; + const bids = await group.loadSerum3BidsForMarket( + client, + DEVNET_SERUM3_MARKETS.get('BTC/USDC')!, + ); + const highestBid = Array.from(asks!)![0]; + + let price = 20; + let qty = 0.0001; console.log( - `...placing serum3 bid which would not be settled since its relatively low then midprice`, + `...placing serum3 bid which would not be settled since its relatively low then midprice at ${price} for ${qty}`, ); await client.serum3PlaceOrder( group, mangoAccount, - 'BTC/USDC', + DEVNET_SERUM3_MARKETS.get('BTC/USDC')!, Serum3Side.bid, - 20, - 0.0001, + price, + qty, Serum3SelfTradeBehavior.decrementTake, Serum3OrderType.limit, Date.now(), @@ -160,15 +207,18 @@ async function main() { ); await mangoAccount.reload(client, group); - console.log(`...placing serum3 bid way above midprice`); + price = lowestAsk.price + lowestAsk.price / 2; + qty = 0.0001; + console.log( + `...placing serum3 bid way above midprice at ${price} for ${qty}`, + ); await client.serum3PlaceOrder( group, mangoAccount, - - 'BTC/USDC', + DEVNET_SERUM3_MARKETS.get('BTC/USDC')!, Serum3Side.bid, - 90000, - 0.0001, + price, + qty, Serum3SelfTradeBehavior.decrementTake, Serum3OrderType.limit, Date.now(), @@ -176,15 +226,18 @@ async function main() { ); await mangoAccount.reload(client, group); - console.log(`...placing serum3 ask way below midprice`); + price = highestBid.price - highestBid.price / 2; + qty = 0.0001; + console.log( + `...placing serum3 ask way below midprice at ${price} for ${qty}`, + ); await client.serum3PlaceOrder( group, mangoAccount, - - 'BTC/USDC', + DEVNET_SERUM3_MARKETS.get('BTC/USDC')!, Serum3Side.ask, - 30000, - 0.0001, + price, + qty, Serum3SelfTradeBehavior.decrementTake, Serum3OrderType.limit, Date.now(), @@ -192,10 +245,10 @@ async function main() { ); console.log(`...current own orders on OB`); - let orders = await client.getSerum3Orders( + let orders = await mangoAccount.loadSerum3OpenOrdersForMarket( + client, group, - - 'BTC/USDC', + DEVNET_SERUM3_MARKETS.get('BTC/USDC')!, ); for (const order of orders) { console.log( @@ -205,18 +258,17 @@ async function main() { await client.serum3CancelOrder( group, mangoAccount, - - 'BTC/USDC', + DEVNET_SERUM3_MARKETS.get('BTC/USDC')!, order.side === 'buy' ? Serum3Side.bid : Serum3Side.ask, order.orderId, ); } console.log(`...current own orders on OB`); - orders = await client.getSerum3Orders( + orders = await mangoAccount.loadSerum3OpenOrdersForMarket( + client, group, - - 'BTC/USDC', + DEVNET_SERUM3_MARKETS.get('BTC/USDC')!, ); for (const order of orders) { console.log(order); @@ -226,12 +278,19 @@ async function main() { await client.serum3SettleFunds( group, mangoAccount, - - 'BTC/USDC', + DEVNET_SERUM3_MARKETS.get('BTC/USDC')!, ); } - if (true) { + if (false) { + // serum3 market + const serum3Market = group.serum3MarketsMapByExternal.get( + DEVNET_SERUM3_MARKETS.get('BTC/USDC')!.toBase58(), + ); + console.log(await serum3Market?.logOb(client, group)); + } + + if (false) { await mangoAccount.reload(client, group); console.log( '...mangoAccount.getEquity() ' + @@ -252,13 +311,13 @@ async function main() { console.log( '...mangoAccount.getAssetsVal() ' + toUiDecimalsForQuote( - mangoAccount.getAssetsVal(HealthType.init).toNumber(), + mangoAccount.getAssetsValue(HealthType.init).toNumber(), ), ); console.log( '...mangoAccount.getLiabsVal() ' + toUiDecimalsForQuote( - mangoAccount.getLiabsVal(HealthType.init).toNumber(), + mangoAccount.getLiabsValue(HealthType.init).toNumber(), ), ); console.log( @@ -272,14 +331,80 @@ async function main() { ).toNumber(), ), ); - console.log( - "...mangoAccount.getSerum3MarketMarginAvailable(group, 'BTC/USDC') " + - toUiDecimalsForQuote( - mangoAccount - .getSerum3MarketMarginAvailable(group, 'BTC/USDC') - .toNumber(), - ), + } + + if (true) { + const asks = await group.loadSerum3AsksForMarket( + client, + DEVNET_SERUM3_MARKETS.get('BTC/USDC')!, ); + const lowestAsk = Array.from(asks!)[0]; + const bids = await group.loadSerum3BidsForMarket( + client, + DEVNET_SERUM3_MARKETS.get('BTC/USDC')!, + ); + const highestBid = Array.from(asks!)![0]; + + function getMaxSourceForTokenSwapWrapper(src, tgt) { + // console.log(); + console.log( + `getMaxSourceForTokenSwap ${src.padEnd(4)} ${tgt.padEnd(4)} ` + + mangoAccount + .getMaxSourceForTokenSwap( + group, + group.banksMapByName.get(src)![0].mint, + group.banksMapByName.get(tgt)![0].mint, + 1, + ) + .div( + I80F48.fromNumber( + Math.pow(10, group.banksMapByName.get(src)![0].mintDecimals), + ), + ) + .toNumber(), + ); + } + for (const srcToken of Array.from(group.banksMapByName.keys())) { + for (const tgtToken of Array.from(group.banksMapByName.keys())) { + getMaxSourceForTokenSwapWrapper(srcToken, tgtToken); + } + } + + const maxQuoteForSerum3BidUi = mangoAccount.getMaxQuoteForSerum3BidUi( + group, + DEVNET_SERUM3_MARKETS.get('BTC/USDC')!, + ); + console.log( + "...mangoAccount.getMaxQuoteForSerum3BidUi(group, 'BTC/USDC') " + + maxQuoteForSerum3BidUi, + ); + + const maxBaseForSerum3AskUi = mangoAccount.getMaxBaseForSerum3AskUi( + group, + DEVNET_SERUM3_MARKETS.get('BTC/USDC')!, + ); + console.log( + "...mangoAccount.getMaxBaseForSerum3AskUi(group, 'BTC/USDC') " + + maxBaseForSerum3AskUi, + ); + + console.log( + `simHealthRatioWithSerum3BidUiChanges ${mangoAccount.simHealthRatioWithSerum3BidUiChanges( + group, + 785, + DEVNET_SERUM3_MARKETS.get('BTC/USDC')!, + )}`, + ); + console.log( + `simHealthRatioWithSerum3AskUiChanges ${mangoAccount.simHealthRatioWithSerum3AskUiChanges( + group, + 0.033, + DEVNET_SERUM3_MARKETS.get('BTC/USDC')!, + )}`, + ); + } + + if (false) { console.log( "...mangoAccount.getPerpMarketMarginAvailable(group, 'BTC-PERP') " + toUiDecimalsForQuote( @@ -290,7 +415,7 @@ async function main() { ); } - if (true) { + if (false) { // perps console.log(`...placing perp bid`); try { diff --git a/ts/client/src/utils.ts b/ts/client/src/utils.ts index 2643d5f95..42f53c2c9 100644 --- a/ts/client/src/utils.ts +++ b/ts/client/src/utils.ts @@ -9,8 +9,11 @@ import { TransactionInstruction, } from '@solana/web3.js'; import BN from 'bn.js'; -import { QUOTE_DECIMALS } from './accounts/bank'; +import { Bank, QUOTE_DECIMALS } from './accounts/bank'; +import { Group } from './accounts/group'; import { I80F48 } from './accounts/I80F48'; +import { MangoAccount, Serum3Orders } from './accounts/mangoAccount'; +import { PerpMarket } from './accounts/perp'; export const I64_MAX_BN = new BN('9223372036854775807').toTwos(64); @@ -26,6 +29,59 @@ export function debugAccountMetas(ams: AccountMeta[]) { } } +export function debugHealthAccounts( + group: Group, + mangoAccount: MangoAccount, + publicKeys: PublicKey[], +) { + const banks = new Map( + Array.from(group.banksMapByName.values()).map((banks: Bank[]) => [ + banks[0].publicKey.toBase58(), + `${banks[0].name} bank`, + ]), + ); + const oracles = new Map( + Array.from(group.banksMapByName.values()).map((banks: Bank[]) => [ + banks[0].oracle.toBase58(), + `${banks[0].name} oracle`, + ]), + ); + const serum3 = new Map( + mangoAccount.serum3Active().map((serum3: Serum3Orders) => { + const serum3Market = Array.from( + group.serum3MarketsMapByExternal.values(), + ).find((serum3Market) => serum3Market.marketIndex === serum3.marketIndex); + if (!serum3Market) { + throw new Error( + `Serum3Orders for non existent market with market index ${serum3.marketIndex}`, + ); + } + return [serum3.openOrders.toBase58(), `${serum3Market.name} spot oo`]; + }), + ); + const perps = new Map( + Array.from(group.perpMarketsMap.values()).map((perpMarket: PerpMarket) => [ + perpMarket.publicKey.toBase58(), + `${perpMarket.name} perp market`, + ]), + ); + + publicKeys.map((pk) => { + if (banks.get(pk.toBase58())) { + console.log(banks.get(pk.toBase58())); + } + if (oracles.get(pk.toBase58())) { + console.log(oracles.get(pk.toBase58())); + } + if (serum3.get(pk.toBase58())) { + console.log(serum3.get(pk.toBase58())); + } + if (perps.get(pk.toBase58())) { + console.log(perps.get(pk.toBase58())); + } + }); +} + export async function findOrCreate( entityName: string, findMethod: (...x: any) => any,