From 18729cf04c86daaee73885cf41d0b11202fef450 Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Mon, 15 Jan 2024 12:45:00 +0100 Subject: [PATCH 01/42] Configurable interest rate for 0% utilization (#848) --- mango_v4.json | 39 +++++++++- .../mango-v4/src/instructions/token_edit.rs | 11 +++ .../src/instructions/token_register.rs | 4 +- .../instructions/token_register_trustless.rs | 3 +- programs/mango-v4/src/lib.rs | 4 + programs/mango-v4/src/state/bank.rs | 75 ++++++++++++++++-- .../tests/program_test/mango_client.rs | 2 + ts/client/src/accounts/bank.ts | 15 +++- ts/client/src/client.ts | 2 + ts/client/src/clientIxParamBuilder.ts | 4 + ts/client/src/mango_v4.ts | 78 ++++++++++++++++++- 11 files changed, 224 insertions(+), 13 deletions(-) diff --git a/mango_v4.json b/mango_v4.json index 125eb7644..d0e24216a 100644 --- a/mango_v4.json +++ b/mango_v4.json @@ -623,6 +623,10 @@ { "name": "depositLimit", "type": "u64" + }, + { + "name": "zeroUtilRate", + "type": "f32" } ] }, @@ -1021,6 +1025,12 @@ "type": { "option": "u64" } + }, + { + "name": "zeroUtilRateOpt", + "type": { + "option": "f32" + } } ] }, @@ -7137,6 +7147,16 @@ }, { "name": "util0", + "docs": [ + "The unscaled borrow interest curve is defined as continuous piecewise linear with the points:", + "", + "- 0% util: zero_util_rate", + "- util0% util: rate0", + "- util1% util: rate1", + "- 100% util: max_rate", + "", + "The final rate is this unscaled curve multiplied by interest_curve_scaling." + ], "type": { "defined": "I80F48" } @@ -7161,6 +7181,12 @@ }, { "name": "maxRate", + "docs": [ + "the 100% utilization rate", + "", + "This isn't the max_rate, since this still gets scaled by interest_curve_scaling,", + "which is >=1." + ], "type": { "defined": "I80F48" } @@ -7421,12 +7447,23 @@ ], "type": "u64" }, + { + "name": "zeroUtilRate", + "docs": [ + "The unscaled borrow interest curve point for zero utilization.", + "", + "See util0, rate0, util1, rate1, max_rate" + ], + "type": { + "defined": "I80F48" + } + }, { "name": "reserved", "type": { "array": [ "u8", - 1968 + 1952 ] } } diff --git a/programs/mango-v4/src/instructions/token_edit.rs b/programs/mango-v4/src/instructions/token_edit.rs index 4c3cec6f9..073f97c2b 100644 --- a/programs/mango-v4/src/instructions/token_edit.rs +++ b/programs/mango-v4/src/instructions/token_edit.rs @@ -51,6 +51,7 @@ pub fn token_edit( maint_weight_shift_abort: bool, set_fallback_oracle: bool, deposit_limit_opt: Option, + zero_util_rate: Option, ) -> Result<()> { let group = ctx.accounts.group.load()?; @@ -462,6 +463,16 @@ pub fn token_edit( bank.deposit_limit = deposit_limit; require_group_admin = true; } + + if let Some(zero_util_rate) = zero_util_rate { + msg!( + "Zero utilization rate old {:?}, new {:?}", + bank.zero_util_rate, + zero_util_rate + ); + bank.zero_util_rate = I80F48::from_num(zero_util_rate); + require_group_admin = true; + } } // account constraint #1 diff --git a/programs/mango-v4/src/instructions/token_register.rs b/programs/mango-v4/src/instructions/token_register.rs index 3169b0c9b..deaadaf95 100644 --- a/programs/mango-v4/src/instructions/token_register.rs +++ b/programs/mango-v4/src/instructions/token_register.rs @@ -42,6 +42,7 @@ pub fn token_register( interest_target_utilization: f32, group_insurance_fund: bool, deposit_limit: u64, + zero_util_rate: f32, ) -> Result<()> { // Require token 0 to be in the insurance token if token_index == INSURANCE_TOKEN_INDEX { @@ -122,7 +123,8 @@ pub fn token_register( maint_weight_shift_liab_target: I80F48::ZERO, fallback_oracle: ctx.accounts.fallback_oracle.key(), deposit_limit, - reserved: [0; 1968], + zero_util_rate: I80F48::from_num(zero_util_rate), + reserved: [0; 1952], }; let oracle_ref = &AccountInfoRef::borrow(ctx.accounts.oracle.as_ref())?; diff --git a/programs/mango-v4/src/instructions/token_register_trustless.rs b/programs/mango-v4/src/instructions/token_register_trustless.rs index d6b53c4f8..77a05da05 100644 --- a/programs/mango-v4/src/instructions/token_register_trustless.rs +++ b/programs/mango-v4/src/instructions/token_register_trustless.rs @@ -104,7 +104,8 @@ pub fn token_register_trustless( maint_weight_shift_liab_target: I80F48::ZERO, fallback_oracle: ctx.accounts.fallback_oracle.key(), deposit_limit: 0, - reserved: [0; 1968], + zero_util_rate: I80F48::ZERO, + reserved: [0; 1952], }; let oracle_ref = &AccountInfoRef::borrow(ctx.accounts.oracle.as_ref())?; if let Ok(oracle_price) = bank.oracle_price(&OracleAccountInfos::from_reader(oracle_ref), None) diff --git a/programs/mango-v4/src/lib.rs b/programs/mango-v4/src/lib.rs index 4ca27de12..01a6577ac 100644 --- a/programs/mango-v4/src/lib.rs +++ b/programs/mango-v4/src/lib.rs @@ -155,6 +155,7 @@ pub mod mango_v4 { interest_target_utilization: f32, group_insurance_fund: bool, deposit_limit: u64, + zero_util_rate: f32, ) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::token_register( @@ -186,6 +187,7 @@ pub mod mango_v4 { interest_target_utilization, group_insurance_fund, deposit_limit, + zero_util_rate, )?; Ok(()) } @@ -239,6 +241,7 @@ pub mod mango_v4 { maint_weight_shift_abort: bool, set_fallback_oracle: bool, deposit_limit_opt: Option, + zero_util_rate_opt: Option, ) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::token_edit( @@ -279,6 +282,7 @@ pub mod mango_v4 { maint_weight_shift_abort, set_fallback_oracle, deposit_limit_opt, + zero_util_rate_opt, )?; Ok(()) } diff --git a/programs/mango-v4/src/state/bank.rs b/programs/mango-v4/src/state/bank.rs index de8fc90b0..3f2187dcc 100644 --- a/programs/mango-v4/src/state/bank.rs +++ b/programs/mango-v4/src/state/bank.rs @@ -59,10 +59,24 @@ pub struct Bank { pub avg_utilization: I80F48, pub adjustment_factor: I80F48, + + /// The unscaled borrow interest curve is defined as continuous piecewise linear with the points: + /// + /// - 0% util: zero_util_rate + /// - util0% util: rate0 + /// - util1% util: rate1 + /// - 100% util: max_rate + /// + /// The final rate is this unscaled curve multiplied by interest_curve_scaling. pub util0: I80F48, pub rate0: I80F48, pub util1: I80F48, pub rate1: I80F48, + + /// the 100% utilization rate + /// + /// This isn't the max_rate, since this still gets scaled by interest_curve_scaling, + /// which is >=1. pub max_rate: I80F48, // TODO: add ix/logic to regular send this to DAO @@ -182,8 +196,13 @@ pub struct Bank { /// zero means none, in token native pub deposit_limit: u64, + /// The unscaled borrow interest curve point for zero utilization. + /// + /// See util0, rate0, util1, rate1, max_rate + pub zero_util_rate: I80F48, + #[derivative(Debug = "ignore")] - pub reserved: [u8; 1968], + pub reserved: [u8; 1952], } const_assert_eq!( size_of::(), @@ -220,7 +239,8 @@ const_assert_eq!( + 16 * 3 + 32 + 8 - + 1968 + + 16 + + 1952 ); const_assert_eq!(size_of::(), 3064); const_assert_eq!(size_of::() % 8, 0); @@ -324,7 +344,8 @@ impl Bank { maint_weight_shift_liab_target: existing_bank.maint_weight_shift_liab_target, fallback_oracle: existing_bank.oracle, deposit_limit: existing_bank.deposit_limit, - reserved: [0; 1968], + zero_util_rate: existing_bank.zero_util_rate, + reserved: [0; 1952], } } @@ -355,6 +376,7 @@ impl Bank { require_gte!(self.maint_weight_shift_duration_inv, 0.0); require_gte!(self.maint_weight_shift_asset_target, 0.0); require_gte!(self.maint_weight_shift_liab_target, 0.0); + require_gte!(self.zero_util_rate, I80F48::ZERO); Ok(()) } @@ -1000,6 +1022,7 @@ impl Bank { pub fn compute_interest_rate(&self, utilization: I80F48) -> I80F48 { Bank::interest_rate_curve_calculator( utilization, + self.zero_util_rate, self.util0, self.rate0, self.util1, @@ -1014,6 +1037,7 @@ impl Bank { #[inline(always)] pub fn interest_rate_curve_calculator( utilization: I80F48, + zero_util_rate: I80F48, util0: I80F48, rate0: I80F48, util1: I80F48, @@ -1025,8 +1049,8 @@ impl Bank { let utilization = utilization.max(I80F48::ZERO).min(I80F48::ONE); let v = if utilization <= util0 { - let slope = rate0 / util0; - slope * utilization + let slope = (rate0 - zero_util_rate) / util0; + zero_util_rate + slope * utilization } else if utilization <= util1 { let extra_util = utilization - util0; let slope = (rate1 - rate0) / (util1 - util0); @@ -1683,4 +1707,45 @@ mod tests { Ok(()) } + + #[test] + fn test_bank_interest_rate_curve() { + let mut bank = Bank::zeroed(); + bank.zero_util_rate = I80F48::from(1); + bank.rate0 = I80F48::from(3); + bank.rate1 = I80F48::from(7); + bank.max_rate = I80F48::from(13); + + bank.util0 = I80F48::from_num(0.5); + bank.util1 = I80F48::from_num(0.75); + + let interest = |v: f64| { + bank.compute_interest_rate(I80F48::from_num(v)) + .to_num::() + }; + let d = |a: f64, b: f64| (a - b).abs(); + + // the points + let eps = 0.0001; + assert!(d(interest(-0.5), 1.0) <= eps); + assert!(d(interest(0.0), 1.0) <= eps); + assert!(d(interest(0.5), 3.0) <= eps); + assert!(d(interest(0.75), 7.0) <= eps); + assert!(d(interest(1.0), 13.0) <= eps); + assert!(d(interest(1.5), 13.0) <= eps); + + // midpoints + assert!(d(interest(0.25), 2.0) <= eps); + assert!(d(interest((0.5 + 0.75) / 2.0), 5.0) <= eps); + assert!(d(interest((0.75 + 1.0) / 2.0), 10.0) <= eps); + + // around the points + let delta = 0.000001; + assert!(d(interest(0.0 + delta), 1.0) <= eps); + assert!(d(interest(0.5 - delta), 3.0) <= eps); + assert!(d(interest(0.5 + delta), 3.0) <= eps); + assert!(d(interest(0.75 - delta), 7.0) <= eps); + assert!(d(interest(0.75 + delta), 7.0) <= eps); + assert!(d(interest(1.0 - delta), 13.0) <= eps); + } } diff --git a/programs/mango-v4/tests/program_test/mango_client.rs b/programs/mango-v4/tests/program_test/mango_client.rs index 7159f2013..c9080d6a7 100644 --- a/programs/mango-v4/tests/program_test/mango_client.rs +++ b/programs/mango-v4/tests/program_test/mango_client.rs @@ -1074,6 +1074,7 @@ impl ClientInstruction for TokenRegisterInstruction { interest_target_utilization: 0.5, group_insurance_fund: true, deposit_limit: 0, + zero_util_rate: 0.0, }; let bank = Pubkey::find_program_address( @@ -1319,6 +1320,7 @@ pub fn token_edit_instruction_default() -> mango_v4::instruction::TokenEdit { maint_weight_shift_abort: false, set_fallback_oracle: false, deposit_limit_opt: None, + zero_util_rate_opt: None, } } diff --git a/ts/client/src/accounts/bank.ts b/ts/client/src/accounts/bank.ts index b46a521ae..bd2fbd206 100644 --- a/ts/client/src/accounts/bank.ts +++ b/ts/client/src/accounts/bank.ts @@ -1,6 +1,7 @@ import { BN } from '@coral-xyz/anchor'; import { utf8 } from '@coral-xyz/anchor/dist/cjs/utils/bytes'; import { PublicKey } from '@solana/web3.js'; +import { format } from 'path'; import { I80F48, I80F48Dto, ONE_I80F48, ZERO_I80F48 } from '../numbers/I80F48'; import { As, toUiDecimals } from '../utils'; import { OracleProvider, isOracleStaleOrUnconfident } from './oracle'; @@ -79,6 +80,7 @@ export class Bank implements BankForHealth { public maintWeightShiftDurationInv: I80F48; public maintWeightShiftAssetTarget: I80F48; public maintWeightShiftLiabTarget: I80F48; + public zeroUtilRate: I80F48; static from( publicKey: PublicKey, @@ -139,6 +141,7 @@ export class Bank implements BankForHealth { maintWeightShiftAssetTarget: I80F48Dto; maintWeightShiftLiabTarget: I80F48Dto; depositLimit: BN; + zeroUtilRate: I80F48Dto; }, ): Bank { return new Bank( @@ -199,6 +202,7 @@ export class Bank implements BankForHealth { obj.maintWeightShiftAssetTarget, obj.maintWeightShiftLiabTarget, obj.depositLimit, + obj.zeroUtilRate, ); } @@ -260,6 +264,7 @@ export class Bank implements BankForHealth { maintWeightShiftAssetTarget: I80F48Dto, maintWeightShiftLiabTarget: I80F48Dto, public depositLimit: BN, + zeroUtilRate: I80F48Dto, ) { this.name = utf8.decode(new Uint8Array(name)).split('\x00')[0]; this.oracleConfig = { @@ -289,6 +294,7 @@ export class Bank implements BankForHealth { this.maintWeightShiftDurationInv = I80F48.from(maintWeightShiftDurationInv); this.maintWeightShiftAssetTarget = I80F48.from(maintWeightShiftAssetTarget); this.maintWeightShiftLiabTarget = I80F48.from(maintWeightShiftLiabTarget); + this.zeroUtilRate = I80F48.from(zeroUtilRate); this._price = undefined; this._uiPrice = undefined; this._oracleLastUpdatedSlot = undefined; @@ -508,13 +514,16 @@ export class Bank implements BankForHealth { return ZERO_I80F48(); } - const utilization = totalBorrows.div(totalDeposits); + const utilization = totalBorrows + .div(totalDeposits) + .max(ZERO_I80F48()) + .min(ONE_I80F48()); const scaling = I80F48.fromNumber( this.interestCurveScaling == 0.0 ? 1.0 : this.interestCurveScaling, ); if (utilization.lt(this.util0)) { - const slope = this.rate0.div(this.util0); - return slope.mul(utilization).mul(scaling); + const slope = this.rate0.sub(this.zeroUtilRate).div(this.util0); + return this.zeroUtilRate.add(slope.mul(utilization)).mul(scaling); } else if (utilization.lt(this.util1)) { const extraUtil = utilization.sub(this.util0); const slope = this.rate1.sub(this.rate0).div(this.util1.sub(this.util0)); diff --git a/ts/client/src/client.ts b/ts/client/src/client.ts index f4ea1339a..9649ed32e 100644 --- a/ts/client/src/client.ts +++ b/ts/client/src/client.ts @@ -458,6 +458,7 @@ export class MangoClient { params.interestTargetUtilization, params.groupInsuranceFund, params.depositLimit, + params.zeroUtilRate, ) .accounts({ group: group.publicKey, @@ -542,6 +543,7 @@ export class MangoClient { params.maintWeightShiftAbort ?? false, params.setFallbackOracle ?? false, params.depositLimit, + params.zeroUtilRate, ) .accounts({ group: group.publicKey, diff --git a/ts/client/src/clientIxParamBuilder.ts b/ts/client/src/clientIxParamBuilder.ts index f4b2b73e7..03d598c11 100644 --- a/ts/client/src/clientIxParamBuilder.ts +++ b/ts/client/src/clientIxParamBuilder.ts @@ -28,6 +28,7 @@ export interface TokenRegisterParams { interestCurveScaling: number; interestTargetUtilization: number; depositLimit: BN; + zeroUtilRate: number; } export const DefaultTokenRegisterParams: TokenRegisterParams = { @@ -66,6 +67,7 @@ export const DefaultTokenRegisterParams: TokenRegisterParams = { interestCurveScaling: 4.0, interestTargetUtilization: 0.5, depositLimit: new BN(0), + zeroUtilRate: 0.0, }; export interface TokenEditParams { @@ -105,6 +107,7 @@ export interface TokenEditParams { maintWeightShiftAbort: boolean | null; setFallbackOracle: boolean | null; depositLimit: BN | null; + zeroUtilRate: number | null; } export const NullTokenEditParams: TokenEditParams = { @@ -144,6 +147,7 @@ export const NullTokenEditParams: TokenEditParams = { maintWeightShiftAbort: null, setFallbackOracle: null, depositLimit: null, + zeroUtilRate: null, }; export interface PerpEditParams { diff --git a/ts/client/src/mango_v4.ts b/ts/client/src/mango_v4.ts index 8d0922d62..bd7b6b90c 100644 --- a/ts/client/src/mango_v4.ts +++ b/ts/client/src/mango_v4.ts @@ -623,6 +623,10 @@ export type MangoV4 = { { "name": "depositLimit", "type": "u64" + }, + { + "name": "zeroUtilRate", + "type": "f32" } ] }, @@ -1021,6 +1025,12 @@ export type MangoV4 = { "type": { "option": "u64" } + }, + { + "name": "zeroUtilRateOpt", + "type": { + "option": "f32" + } } ] }, @@ -7137,6 +7147,16 @@ export type MangoV4 = { }, { "name": "util0", + "docs": [ + "The unscaled borrow interest curve is defined as continuous piecewise linear with the points:", + "", + "- 0% util: zero_util_rate", + "- util0% util: rate0", + "- util1% util: rate1", + "- 100% util: max_rate", + "", + "The final rate is this unscaled curve multiplied by interest_curve_scaling." + ], "type": { "defined": "I80F48" } @@ -7161,6 +7181,12 @@ export type MangoV4 = { }, { "name": "maxRate", + "docs": [ + "the 100% utilization rate", + "", + "This isn't the max_rate, since this still gets scaled by interest_curve_scaling,", + "which is >=1." + ], "type": { "defined": "I80F48" } @@ -7421,12 +7447,23 @@ export type MangoV4 = { ], "type": "u64" }, + { + "name": "zeroUtilRate", + "docs": [ + "The unscaled borrow interest curve point for zero utilization.", + "", + "See util0, rate0, util1, rate1, max_rate" + ], + "type": { + "defined": "I80F48" + } + }, { "name": "reserved", "type": { "array": [ "u8", - 1968 + 1952 ] } } @@ -14346,6 +14383,10 @@ export const IDL: MangoV4 = { { "name": "depositLimit", "type": "u64" + }, + { + "name": "zeroUtilRate", + "type": "f32" } ] }, @@ -14744,6 +14785,12 @@ export const IDL: MangoV4 = { "type": { "option": "u64" } + }, + { + "name": "zeroUtilRateOpt", + "type": { + "option": "f32" + } } ] }, @@ -20860,6 +20907,16 @@ export const IDL: MangoV4 = { }, { "name": "util0", + "docs": [ + "The unscaled borrow interest curve is defined as continuous piecewise linear with the points:", + "", + "- 0% util: zero_util_rate", + "- util0% util: rate0", + "- util1% util: rate1", + "- 100% util: max_rate", + "", + "The final rate is this unscaled curve multiplied by interest_curve_scaling." + ], "type": { "defined": "I80F48" } @@ -20884,6 +20941,12 @@ export const IDL: MangoV4 = { }, { "name": "maxRate", + "docs": [ + "the 100% utilization rate", + "", + "This isn't the max_rate, since this still gets scaled by interest_curve_scaling,", + "which is >=1." + ], "type": { "defined": "I80F48" } @@ -21144,12 +21207,23 @@ export const IDL: MangoV4 = { ], "type": "u64" }, + { + "name": "zeroUtilRate", + "docs": [ + "The unscaled borrow interest curve point for zero utilization.", + "", + "See util0, rate0, util1, rate1, max_rate" + ], + "type": { + "defined": "I80F48" + } + }, { "name": "reserved", "type": { "array": [ "u8", - 1968 + 1952 ] } } From 511814ca979ad848be1326f36d6b2f363941d54f Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Wed, 17 Jan 2024 10:30:25 +0100 Subject: [PATCH 02/42] Rust client: Revamp transaction confirmation (#850) - Allow user configuration of confirmation settings - Provide a timeout setting, default is 60s --- Cargo.lock | 1 + Cargo.toml | 1 + lib/client/Cargo.toml | 1 + lib/client/src/client.rs | 30 ++++++- lib/client/src/confirm_transaction.rs | 117 ++++++++++++++++++++++++++ lib/client/src/lib.rs | 1 + lib/client/src/util.rs | 70 --------------- 7 files changed, 148 insertions(+), 73 deletions(-) create mode 100644 lib/client/src/confirm_transaction.rs diff --git a/Cargo.lock b/Cargo.lock index aa168354c..b4a48109e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3464,6 +3464,7 @@ dependencies = [ "solana-client", "solana-rpc", "solana-sdk", + "solana-transaction-status", "spl-associated-token-account 1.1.3", "thiserror", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 879c1918d..afb3f2adc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ solana-program = "~1.16.7" solana-program-test = "~1.16.7" solana-rpc = "~1.16.7" solana-sdk = { version = "~1.16.7", default-features = false } +solana-transaction-status = { version = "~1.16.7" } [profile.release] overflow-checks = true diff --git a/lib/client/Cargo.toml b/lib/client/Cargo.toml index 64bc97b1b..b986fd0bf 100644 --- a/lib/client/Cargo.toml +++ b/lib/client/Cargo.toml @@ -30,6 +30,7 @@ solana-client = { workspace = true } solana-rpc = { workspace = true } solana-sdk = { workspace = true } solana-address-lookup-table-program = { workspace = true } +solana-transaction-status = { workspace = true } mango-feeds-connector = { workspace = true } spl-associated-token-account = "1.0.3" thiserror = "1.0.31" diff --git a/lib/client/src/client.rs b/lib/client/src/client.rs index a718d0475..9716df468 100644 --- a/lib/client/src/client.rs +++ b/lib/client/src/client.rs @@ -34,6 +34,7 @@ use solana_sdk::signer::keypair; use solana_sdk::transaction::TransactionError; use crate::account_fetcher::*; +use crate::confirm_transaction::{wait_for_transaction_confirmation, RpcConfirmTransactionConfig}; use crate::context::MangoGroupContext; use crate::gpa::{fetch_anchor_account, fetch_mango_accounts}; use crate::util::PreparedInstructions; @@ -66,6 +67,9 @@ pub struct Client { pub commitment: CommitmentConfig, /// Timeout, defaults to 60s + /// + /// This timeout applies to rpc requests. Note that the timeout for transaction + /// confirmation is configured separately in rpc_confirm_transaction_config. #[builder(default = "Some(Duration::from_secs(60))")] pub timeout: Option, @@ -76,6 +80,10 @@ pub struct Client { #[builder(default = "ClientBuilder::default_rpc_send_transaction_config()")] pub rpc_send_transaction_config: RpcSendTransactionConfig, + /// Defaults to waiting up to 60s for confirmation + #[builder(default = "ClientBuilder::default_rpc_confirm_transaction_config()")] + pub rpc_confirm_transaction_config: RpcConfirmTransactionConfig, + #[builder(default = "\"https://quote-api.jup.ag/v4\".into()")] pub jupiter_v4_url: String, @@ -93,6 +101,13 @@ impl ClientBuilder { ..Default::default() } } + + pub fn default_rpc_confirm_transaction_config() -> RpcConfirmTransactionConfig { + RpcConfirmTransactionConfig { + timeout: Some(Duration::from_secs(60)), + ..Default::default() + } + } } impl Client { @@ -1869,10 +1884,19 @@ impl TransactionBuilder { pub async fn send_and_confirm(&self, client: &Client) -> anyhow::Result { let rpc = client.rpc_async(); let tx = self.transaction(&rpc).await?; - // TODO: Wish we could use client.rpc_send_transaction_config here too! - rpc.send_and_confirm_transaction(&tx) + let recent_blockhash = tx.message.recent_blockhash(); + let signature = rpc + .send_transaction_with_config(&tx, client.rpc_send_transaction_config) .await - .map_err(prettify_solana_client_error) + .map_err(prettify_solana_client_error)?; + wait_for_transaction_confirmation( + &rpc, + &signature, + recent_blockhash, + &client.rpc_confirm_transaction_config, + ) + .await?; + Ok(signature) } pub fn transaction_size(&self) -> anyhow::Result { diff --git a/lib/client/src/confirm_transaction.rs b/lib/client/src/confirm_transaction.rs new file mode 100644 index 000000000..60231e2f0 --- /dev/null +++ b/lib/client/src/confirm_transaction.rs @@ -0,0 +1,117 @@ +use solana_client::nonblocking::rpc_client::RpcClient as RpcClientAsync; +use solana_client::rpc_request::RpcError; +use solana_sdk::{commitment_config::CommitmentConfig, signature::Signature}; +use solana_transaction_status::TransactionStatus; + +use crate::util::delay_interval; +use std::time::Duration; + +#[derive(thiserror::Error, Debug)] +pub enum WaitForTransactionConfirmationError { + #[error("blockhash has expired")] + BlockhashExpired, + #[error("timeout expired")] + Timeout, + #[error("client error: {0:?}")] + ClientError(#[from] solana_client::client_error::ClientError), +} + +#[derive(Clone, Debug, Builder)] +#[builder(default)] +pub struct RpcConfirmTransactionConfig { + /// If none, defaults to the RpcClient's configured default commitment. + pub commitment: Option, + + /// Time after which to start checking for blockhash expiry. + pub recent_blockhash_initial_timeout: Duration, + + /// Interval between signature status queries. + pub signature_status_interval: Duration, + + /// If none, there's no timeout. The confirmation will still abort eventually + /// when the blockhash expires. + pub timeout: Option, +} + +impl Default for RpcConfirmTransactionConfig { + fn default() -> Self { + Self { + commitment: None, + recent_blockhash_initial_timeout: Duration::from_secs(5), + signature_status_interval: Duration::from_millis(500), + timeout: None, + } + } +} + +impl RpcConfirmTransactionConfig { + pub fn builder() -> RpcConfirmTransactionConfigBuilder { + RpcConfirmTransactionConfigBuilder::default() + } +} + +/// Wait for `signature` to be confirmed at `commitment` or until either +/// - `recent_blockhash` is so old that the tx can't be confirmed _and_ +/// `blockhash_initial_timeout` is reached +/// - the `signature_status_timeout` is reached +/// While waiting, query for confirmation every `signature_status_interval` +/// +/// NOTE: RpcClient::config contains confirm_transaction_initial_timeout which is the +/// same as blockhash_initial_timeout. Unfortunately the former is private. +/// +/// Returns: +/// - blockhash and blockhash_initial_timeout expired -> BlockhashExpired error +/// - signature_status_timeout expired -> Timeout error (possibly just didn't reach commitment in time?) +/// - any rpc error -> ClientError error +/// - confirmed at commitment -> ok(slot, opt) +pub async fn wait_for_transaction_confirmation( + rpc_client: &RpcClientAsync, + signature: &Signature, + recent_blockhash: &solana_sdk::hash::Hash, + config: &RpcConfirmTransactionConfig, +) -> Result { + let mut signature_status_interval = delay_interval(config.signature_status_interval); + let commitment = config.commitment.unwrap_or(rpc_client.commitment()); + + let start = std::time::Instant::now(); + let is_timed_out = || config.timeout.map(|t| start.elapsed() > t).unwrap_or(false); + loop { + signature_status_interval.tick().await; + if is_timed_out() { + return Err(WaitForTransactionConfirmationError::Timeout); + } + + let statuses = rpc_client + .get_signature_statuses(&[signature.clone()]) + .await?; + let status_opt = match statuses.value.into_iter().next() { + Some(v) => v, + None => { + return Err(WaitForTransactionConfirmationError::ClientError( + RpcError::ParseError( + "must contain an entry for each requested signature".into(), + ) + .into(), + )); + } + }; + + // If the tx isn't seen at all (not even processed), check blockhash expiry + if status_opt.is_none() { + if start.elapsed() > config.recent_blockhash_initial_timeout { + let blockhash_is_valid = rpc_client + .is_blockhash_valid(recent_blockhash, CommitmentConfig::processed()) + .await?; + if !blockhash_is_valid { + return Err(WaitForTransactionConfirmationError::BlockhashExpired); + } + } + continue; + } + + let status = status_opt.unwrap(); + if status.satisfies_commitment(commitment) { + return Ok(status); + } + } +} diff --git a/lib/client/src/lib.rs b/lib/client/src/lib.rs index 559f6f5b7..a584630fa 100644 --- a/lib/client/src/lib.rs +++ b/lib/client/src/lib.rs @@ -8,6 +8,7 @@ pub mod account_update_stream; pub mod chain_data; mod chain_data_fetcher; mod client; +pub mod confirm_transaction; mod context; pub mod error_tracking; pub mod gpa; diff --git a/lib/client/src/util.rs b/lib/client/src/util.rs index 81669456f..f54d6cac9 100644 --- a/lib/client/src/util.rs +++ b/lib/client/src/util.rs @@ -1,17 +1,8 @@ -use solana_client::{ - client_error::Result as ClientResult, rpc_client::RpcClient, rpc_request::RpcError, -}; use solana_sdk::compute_budget::ComputeBudgetInstruction; use solana_sdk::instruction::Instruction; -use solana_sdk::transaction::Transaction; -use solana_sdk::{ - clock::Slot, commitment_config::CommitmentConfig, signature::Signature, - transaction::uses_durable_nonce, -}; use anchor_lang::prelude::{AccountMeta, Pubkey}; use anyhow::Context; -use std::{thread, time}; /// Some Result<> types don't convert to anyhow::Result nicely. Force them through stringification. pub trait AnyhowWrap { @@ -57,67 +48,6 @@ pub fn delay_interval(period: std::time::Duration) -> tokio::time::Interval { interval } -/// A copy of RpcClient::send_and_confirm_transaction that returns the slot the -/// transaction confirmed in. -pub fn send_and_confirm_transaction( - rpc_client: &RpcClient, - transaction: &Transaction, -) -> ClientResult<(Signature, Slot)> { - const SEND_RETRIES: usize = 1; - const GET_STATUS_RETRIES: usize = usize::MAX; - - 'sending: for _ in 0..SEND_RETRIES { - let signature = rpc_client.send_transaction(transaction)?; - - let recent_blockhash = if uses_durable_nonce(transaction).is_some() { - let (recent_blockhash, ..) = - rpc_client.get_latest_blockhash_with_commitment(CommitmentConfig::processed())?; - recent_blockhash - } else { - transaction.message.recent_blockhash - }; - - for status_retry in 0..GET_STATUS_RETRIES { - let response = rpc_client.get_signature_statuses(&[signature])?.value; - match response[0] - .clone() - .filter(|result| result.satisfies_commitment(rpc_client.commitment())) - { - Some(tx_status) => { - return if let Some(e) = tx_status.err { - Err(e.into()) - } else { - Ok((signature, tx_status.slot)) - }; - } - None => { - if !rpc_client - .is_blockhash_valid(&recent_blockhash, CommitmentConfig::processed())? - { - // Block hash is not found by some reason - break 'sending; - } else if cfg!(not(test)) - // Ignore sleep at last step. - && status_retry < GET_STATUS_RETRIES - { - // Retry twice a second - thread::sleep(time::Duration::from_millis(500)); - continue; - } - } - } - } - } - - Err(RpcError::ForUser( - "unable to confirm transaction. \ - This can happen in situations such as transaction expiration \ - and insufficient fee-payer funds" - .to_string(), - ) - .into()) -} - /// Convenience function used in binaries to set up the fmt tracing_subscriber, /// with cololring enabled only if logging to a terminal and with EnvFilter. pub fn tracing_subscriber_init() { From 8383109f0d3152621d24a0e421fce4bcdabdee77 Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Fri, 19 Jan 2024 16:34:55 +0100 Subject: [PATCH 03/42] Add a token-token platform liquidation fee (#849) The liqor liquidation fee and platform liquidation fee for the asset and liab token are both payed by the liqee. The platform liquidation fee is added to the Bank's collected_fees_native and tracked in collected_liquidation_fees. --- mango_v4.json | 177 ++++++++- .../mango-v4/src/instructions/token_edit.rs | 11 + .../token_force_close_borrows_with_token.rs | 35 +- .../src/instructions/token_liq_with_token.rs | 30 +- .../src/instructions/token_register.rs | 5 +- .../instructions/token_register_trustless.rs | 6 +- programs/mango-v4/src/lib.rs | 4 + programs/mango-v4/src/logs.rs | 33 ++ programs/mango-v4/src/state/bank.rs | 35 +- .../mango-v4/tests/cases/test_liq_tokens.rs | 52 ++- .../tests/program_test/mango_client.rs | 3 + .../tests/program_test/mango_setup.rs | 1 + ts/client/src/accounts/bank.ts | 10 + ts/client/src/client.ts | 2 + ts/client/src/clientIxParamBuilder.ts | 4 + ts/client/src/mango_v4.ts | 354 +++++++++++++++++- ts/client/src/risk.ts | 16 +- 17 files changed, 735 insertions(+), 43 deletions(-) diff --git a/mango_v4.json b/mango_v4.json index d0e24216a..328c40b4d 100644 --- a/mango_v4.json +++ b/mango_v4.json @@ -627,6 +627,10 @@ { "name": "zeroUtilRate", "type": "f32" + }, + { + "name": "platformLiquidationFee", + "type": "f32" } ] }, @@ -1031,6 +1035,12 @@ "type": { "option": "f32" } + }, + { + "name": "platformLiquidationFeeOpt", + "type": { + "option": "f32" + } } ] }, @@ -7193,6 +7203,12 @@ }, { "name": "collectedFeesNative", + "docs": [ + "Fees collected over the lifetime of the bank", + "", + "See fees_withdrawn for how much of the fees was withdrawn.", + "See collected_liquidation_fees for the (included) subtotal for liquidation related fees." + ], "type": { "defined": "I80F48" } @@ -7235,6 +7251,15 @@ }, { "name": "liquidationFee", + "docs": [ + "Liquidation fee that goes to the liqor.", + "", + "Liquidation always involves two tokens, and the sum of the two configured fees is used.", + "", + "A fraction of the price, like 0.05 for a 5% fee during liquidation.", + "", + "See also platform_liquidation_fee." + ], "type": { "defined": "I80F48" } @@ -7458,12 +7483,32 @@ "defined": "I80F48" } }, + { + "name": "platformLiquidationFee", + "docs": [ + "Additional to liquidation_fee, but goes to the group owner instead of the liqor" + ], + "type": { + "defined": "I80F48" + } + }, + { + "name": "collectedLiquidationFees", + "docs": [ + "Fees that were collected during liquidation (in native tokens)", + "", + "See also collected_fees_native and fees_withdrawn." + ], + "type": { + "defined": "I80F48" + } + }, { "name": "reserved", "type": { "array": [ "u8", - 1952 + 1920 ] } } @@ -11962,6 +12007,71 @@ } ] }, + { + "name": "TokenLiqWithTokenLogV2", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "liqee", + "type": "publicKey", + "index": false + }, + { + "name": "liqor", + "type": "publicKey", + "index": false + }, + { + "name": "assetTokenIndex", + "type": "u16", + "index": false + }, + { + "name": "liabTokenIndex", + "type": "u16", + "index": false + }, + { + "name": "assetTransferFromLiqee", + "type": "i128", + "index": false + }, + { + "name": "assetTransferToLiqor", + "type": "i128", + "index": false + }, + { + "name": "assetLiquidationFee", + "type": "i128", + "index": false + }, + { + "name": "liabTransfer", + "type": "i128", + "index": false + }, + { + "name": "assetPrice", + "type": "i128", + "index": false + }, + { + "name": "liabPrice", + "type": "i128", + "index": false + }, + { + "name": "bankruptcy", + "type": "bool", + "index": false + } + ] + }, { "name": "Serum3OpenOrdersBalanceLog", "fields": [ @@ -12838,6 +12948,71 @@ } ] }, + { + "name": "TokenForceCloseBorrowsWithTokenLogV2", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "liqor", + "type": "publicKey", + "index": false + }, + { + "name": "liqee", + "type": "publicKey", + "index": false + }, + { + "name": "assetTokenIndex", + "type": "u16", + "index": false + }, + { + "name": "liabTokenIndex", + "type": "u16", + "index": false + }, + { + "name": "assetTransferFromLiqee", + "type": "i128", + "index": false + }, + { + "name": "assetTransferToLiqor", + "type": "i128", + "index": false + }, + { + "name": "assetLiquidationFee", + "type": "i128", + "index": false + }, + { + "name": "liabTransfer", + "type": "i128", + "index": false + }, + { + "name": "assetPrice", + "type": "i128", + "index": false + }, + { + "name": "liabPrice", + "type": "i128", + "index": false + }, + { + "name": "feeFactor", + "type": "i128", + "index": false + } + ] + }, { "name": "TokenConditionalSwapCreateLog", "fields": [ diff --git a/programs/mango-v4/src/instructions/token_edit.rs b/programs/mango-v4/src/instructions/token_edit.rs index 073f97c2b..e44e6c2ee 100644 --- a/programs/mango-v4/src/instructions/token_edit.rs +++ b/programs/mango-v4/src/instructions/token_edit.rs @@ -52,6 +52,7 @@ pub fn token_edit( set_fallback_oracle: bool, deposit_limit_opt: Option, zero_util_rate: Option, + platform_liquidation_fee: Option, ) -> Result<()> { let group = ctx.accounts.group.load()?; @@ -473,6 +474,16 @@ pub fn token_edit( bank.zero_util_rate = I80F48::from_num(zero_util_rate); require_group_admin = true; } + + if let Some(platform_liquidation_fee) = platform_liquidation_fee { + msg!( + "Platform liquidation fee old {:?}, new {:?}", + bank.platform_liquidation_fee, + platform_liquidation_fee + ); + bank.platform_liquidation_fee = I80F48::from_num(platform_liquidation_fee); + require_group_admin = true; + } } // account constraint #1 diff --git a/programs/mango-v4/src/instructions/token_force_close_borrows_with_token.rs b/programs/mango-v4/src/instructions/token_force_close_borrows_with_token.rs index 78286b16f..1937f4dc7 100644 --- a/programs/mango-v4/src/instructions/token_force_close_borrows_with_token.rs +++ b/programs/mango-v4/src/instructions/token_force_close_borrows_with_token.rs @@ -1,7 +1,7 @@ use crate::accounts_ix::*; use crate::error::*; use crate::health::*; -use crate::logs::{emit_stack, TokenBalanceLog, TokenForceCloseBorrowsWithTokenLog}; +use crate::logs::{emit_stack, TokenBalanceLog, TokenForceCloseBorrowsWithTokenLogV2}; use crate::state::*; use anchor_lang::prelude::*; use fixed::types::I80F48; @@ -62,12 +62,18 @@ pub fn token_force_close_borrows_with_token( MangoError::TokenInReduceOnlyMode ); + let fee_factor_liqor = + (I80F48::ONE + liab_bank.liquidation_fee) * (I80F48::ONE + asset_bank.liquidation_fee); + let fee_factor_total = + (I80F48::ONE + liab_bank.liquidation_fee + liab_bank.platform_liquidation_fee) + * (I80F48::ONE + asset_bank.liquidation_fee + asset_bank.platform_liquidation_fee); + // account constraint #3 // only allow combination of asset and liab token, // where liqee's health would be guaranteed to not decrease require_gte!( liab_bank.init_liab_weight, - asset_bank.init_liab_weight * (I80F48::ONE + liab_bank.liquidation_fee), + asset_bank.init_liab_weight * fee_factor_total, MangoError::SomeError ); @@ -95,10 +101,13 @@ pub fn token_force_close_borrows_with_token( .max(I80F48::ZERO); // The amount of asset native tokens we will give up for them - let fee_factor = - (I80F48::ONE + liab_bank.liquidation_fee) * (I80F48::ONE + asset_bank.liquidation_fee); - let liab_oracle_price_adjusted = liab_oracle_price * fee_factor; - let asset_transfer = liab_transfer * liab_oracle_price_adjusted / asset_oracle_price; + let asset_transfer_base = liab_transfer * liab_oracle_price / asset_oracle_price; + let asset_transfer_to_liqor = asset_transfer_base * fee_factor_liqor; + let asset_transfer_from_liqee = asset_transfer_base * fee_factor_total; + + let asset_liquidation_fee = asset_transfer_from_liqee - asset_transfer_to_liqor; + asset_bank.collected_fees_native += asset_liquidation_fee; + asset_bank.collected_liquidation_fees += asset_liquidation_fee; // Apply the balance changes to the liqor and liqee accounts let liqee_liab_active = @@ -113,13 +122,13 @@ pub fn token_force_close_borrows_with_token( let (liqor_asset_position, liqor_asset_raw_index, _) = liqor.ensure_token_position(asset_token_index)?; let liqor_asset_active = - asset_bank.deposit(liqor_asset_position, asset_transfer, now_ts)?; + asset_bank.deposit(liqor_asset_position, asset_transfer_to_liqor, now_ts)?; let liqor_asset_indexed_position = liqor_asset_position.indexed_position; let liqee_asset_position = liqee.token_position_mut_by_raw_index(liqee_asset_raw_index); let liqee_asset_active = asset_bank.withdraw_without_fee_with_dusting( liqee_asset_position, - asset_transfer, + asset_transfer_from_liqee, now_ts, )?; let liqee_asset_indexed_position = liqee_asset_position.indexed_position; @@ -128,7 +137,7 @@ pub fn token_force_close_borrows_with_token( msg!( "Force closed {} liab for {} asset", liab_transfer, - asset_transfer + asset_transfer_from_liqee, ); // liqee asset @@ -168,17 +177,19 @@ pub fn token_force_close_borrows_with_token( borrow_index: liab_bank.borrow_index.to_bits(), }); - emit_stack(TokenForceCloseBorrowsWithTokenLog { + emit_stack(TokenForceCloseBorrowsWithTokenLogV2 { mango_group: liqee.fixed.group, liqee: liqee_key, liqor: liqor_key, asset_token_index: asset_token_index, liab_token_index: liab_token_index, - asset_transfer: asset_transfer.to_bits(), + asset_transfer_from_liqee: asset_transfer_from_liqee.to_bits(), + asset_transfer_to_liqor: asset_transfer_to_liqor.to_bits(), + asset_liquidation_fee: asset_liquidation_fee.to_bits(), liab_transfer: liab_transfer.to_bits(), asset_price: asset_oracle_price.to_bits(), liab_price: liab_oracle_price.to_bits(), - fee_factor: fee_factor.to_bits(), + fee_factor: fee_factor_total.to_bits(), }); // liqor should never have a borrow diff --git a/programs/mango-v4/src/instructions/token_liq_with_token.rs b/programs/mango-v4/src/instructions/token_liq_with_token.rs index 2ac0ef15f..c40a3a3c7 100644 --- a/programs/mango-v4/src/instructions/token_liq_with_token.rs +++ b/programs/mango-v4/src/instructions/token_liq_with_token.rs @@ -5,7 +5,7 @@ use crate::accounts_ix::*; use crate::error::*; use crate::health::*; use crate::logs::{ - emit_stack, LoanOriginationFeeInstruction, TokenBalanceLog, TokenLiqWithTokenLog, + emit_stack, LoanOriginationFeeInstruction, TokenBalanceLog, TokenLiqWithTokenLogV2, WithdrawLoanLog, }; use crate::state::*; @@ -132,9 +132,12 @@ pub(crate) fn liquidation_action( // assets = liabs * liab_oracle_price / asset_oracle_price * fee_factor // assets = liabs * liab_oracle_price_adjusted / asset_oracle_price // = liabs * lopa / aop - let fee_factor = + let fee_factor_liqor = (I80F48::ONE + liab_bank.liquidation_fee) * (I80F48::ONE + asset_bank.liquidation_fee); - let liab_oracle_price_adjusted = liab_oracle_price * fee_factor; + let fee_factor_total = + (I80F48::ONE + liab_bank.liquidation_fee + liab_bank.platform_liquidation_fee) + * (I80F48::ONE + asset_bank.liquidation_fee + asset_bank.platform_liquidation_fee); + let liab_oracle_price_adjusted = liab_oracle_price * fee_factor_total; let init_asset_weight = asset_bank.init_asset_weight; let init_liab_weight = liab_bank.init_liab_weight; @@ -218,7 +221,13 @@ pub(crate) fn liquidation_action( .max(I80F48::ZERO); // The amount of asset native tokens we will give up for them - let asset_transfer = liab_transfer * liab_oracle_price_adjusted / asset_oracle_price; + let asset_transfer_base = liab_transfer * liab_oracle_price / asset_oracle_price; + let asset_transfer_to_liqor = asset_transfer_base * fee_factor_liqor; + let asset_transfer_from_liqee = asset_transfer_base * fee_factor_total; + + let asset_liquidation_fee = asset_transfer_from_liqee - asset_transfer_to_liqor; + asset_bank.collected_fees_native += asset_liquidation_fee; + asset_bank.collected_liquidation_fees += asset_liquidation_fee; // During liquidation, we mustn't leave small positive balances in the liqee. Those // could break bankruptcy-detection. Thus we dust them even if the token position @@ -239,13 +248,14 @@ pub(crate) fn liquidation_action( let (liqor_asset_position, liqor_asset_raw_index, _) = liqor.ensure_token_position(asset_token_index)?; - let liqor_asset_active = asset_bank.deposit(liqor_asset_position, asset_transfer, now_ts)?; + let liqor_asset_active = + asset_bank.deposit(liqor_asset_position, asset_transfer_to_liqor, now_ts)?; let liqor_asset_indexed_position = liqor_asset_position.indexed_position; let liqee_asset_position = liqee.token_position_mut_by_raw_index(liqee_asset_raw_index); let liqee_asset_active = asset_bank.withdraw_without_fee_with_dusting( liqee_asset_position, - asset_transfer, + asset_transfer_from_liqee, now_ts, )?; let liqee_asset_indexed_position = liqee_asset_position.indexed_position; @@ -260,7 +270,7 @@ pub(crate) fn liquidation_action( msg!( "liquidated {} liab for {} asset", liab_transfer, - asset_transfer + asset_transfer_from_liqee, ); // liqee asset @@ -335,13 +345,15 @@ pub(crate) fn liquidation_action( .fixed .maybe_recover_from_being_liquidated(liqee_liq_end_health); - emit_stack(TokenLiqWithTokenLog { + emit_stack(TokenLiqWithTokenLogV2 { mango_group: liqee.fixed.group, liqee: liqee_key, liqor: liqor_key, asset_token_index, liab_token_index, - asset_transfer: asset_transfer.to_bits(), + asset_transfer_from_liqee: asset_transfer_from_liqee.to_bits(), + asset_transfer_to_liqor: asset_transfer_to_liqor.to_bits(), + asset_liquidation_fee: asset_liquidation_fee.to_bits(), liab_transfer: liab_transfer.to_bits(), asset_price: asset_oracle_price.to_bits(), liab_price: liab_oracle_price.to_bits(), diff --git a/programs/mango-v4/src/instructions/token_register.rs b/programs/mango-v4/src/instructions/token_register.rs index deaadaf95..e9b6f5b2f 100644 --- a/programs/mango-v4/src/instructions/token_register.rs +++ b/programs/mango-v4/src/instructions/token_register.rs @@ -43,6 +43,7 @@ pub fn token_register( group_insurance_fund: bool, deposit_limit: u64, zero_util_rate: f32, + platform_liquidation_fee: f32, ) -> Result<()> { // Require token 0 to be in the insurance token if token_index == INSURANCE_TOKEN_INDEX { @@ -124,7 +125,9 @@ pub fn token_register( fallback_oracle: ctx.accounts.fallback_oracle.key(), deposit_limit, zero_util_rate: I80F48::from_num(zero_util_rate), - reserved: [0; 1952], + platform_liquidation_fee: I80F48::from_num(platform_liquidation_fee), + collected_liquidation_fees: I80F48::ZERO, + reserved: [0; 1920], }; let oracle_ref = &AccountInfoRef::borrow(ctx.accounts.oracle.as_ref())?; diff --git a/programs/mango-v4/src/instructions/token_register_trustless.rs b/programs/mango-v4/src/instructions/token_register_trustless.rs index 77a05da05..fa2f9a3f1 100644 --- a/programs/mango-v4/src/instructions/token_register_trustless.rs +++ b/programs/mango-v4/src/instructions/token_register_trustless.rs @@ -71,7 +71,8 @@ pub fn token_register_trustless( init_asset_weight: I80F48::from_num(0), maint_liab_weight: I80F48::from_num(1.4), // 2.5x init_liab_weight: I80F48::from_num(1.8), // 1.25x - liquidation_fee: I80F48::from_num(0.2), + liquidation_fee: I80F48::from_num(0.05), + platform_liquidation_fee: I80F48::from_num(0.05), dust: I80F48::ZERO, flash_loan_token_account_initial: u64::MAX, flash_loan_approved_amount: 0, @@ -105,7 +106,8 @@ pub fn token_register_trustless( fallback_oracle: ctx.accounts.fallback_oracle.key(), deposit_limit: 0, zero_util_rate: I80F48::ZERO, - reserved: [0; 1952], + collected_liquidation_fees: I80F48::ZERO, + reserved: [0; 1920], }; let oracle_ref = &AccountInfoRef::borrow(ctx.accounts.oracle.as_ref())?; if let Ok(oracle_price) = bank.oracle_price(&OracleAccountInfos::from_reader(oracle_ref), None) diff --git a/programs/mango-v4/src/lib.rs b/programs/mango-v4/src/lib.rs index 01a6577ac..b6dcd3e3e 100644 --- a/programs/mango-v4/src/lib.rs +++ b/programs/mango-v4/src/lib.rs @@ -156,6 +156,7 @@ pub mod mango_v4 { group_insurance_fund: bool, deposit_limit: u64, zero_util_rate: f32, + platform_liquidation_fee: f32, ) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::token_register( @@ -188,6 +189,7 @@ pub mod mango_v4 { group_insurance_fund, deposit_limit, zero_util_rate, + platform_liquidation_fee, )?; Ok(()) } @@ -242,6 +244,7 @@ pub mod mango_v4 { set_fallback_oracle: bool, deposit_limit_opt: Option, zero_util_rate_opt: Option, + platform_liquidation_fee_opt: Option, ) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::token_edit( @@ -283,6 +286,7 @@ pub mod mango_v4 { set_fallback_oracle, deposit_limit_opt, zero_util_rate_opt, + platform_liquidation_fee_opt, )?; Ok(()) } diff --git a/programs/mango-v4/src/logs.rs b/programs/mango-v4/src/logs.rs index 938abded0..d02f99773 100644 --- a/programs/mango-v4/src/logs.rs +++ b/programs/mango-v4/src/logs.rs @@ -344,6 +344,22 @@ pub struct TokenLiqWithTokenLog { pub bankruptcy: bool, } +#[event] +pub struct TokenLiqWithTokenLogV2 { + pub mango_group: Pubkey, + pub liqee: Pubkey, + pub liqor: Pubkey, + pub asset_token_index: u16, + pub liab_token_index: u16, + pub asset_transfer_from_liqee: i128, // I80F48 + pub asset_transfer_to_liqor: i128, // I80F48 + pub asset_liquidation_fee: i128, // I80F48 + pub liab_transfer: i128, // I80F48 + pub asset_price: i128, // I80F48 + pub liab_price: i128, // I80F48 + pub bankruptcy: bool, +} + #[event] pub struct Serum3OpenOrdersBalanceLog { pub mango_group: Pubkey, @@ -594,6 +610,23 @@ pub struct TokenForceCloseBorrowsWithTokenLog { pub fee_factor: i128, } +#[event] +pub struct TokenForceCloseBorrowsWithTokenLogV2 { + pub mango_group: Pubkey, + pub liqor: Pubkey, + pub liqee: Pubkey, + pub asset_token_index: u16, + pub liab_token_index: u16, + pub asset_transfer_from_liqee: i128, // I80F48 + pub asset_transfer_to_liqor: i128, // I80F48 + pub asset_liquidation_fee: i128, // I80F48 + pub liab_transfer: i128, // I80F48 + pub asset_price: i128, // I80F48 + pub liab_price: i128, // I80F48 + /// including liqor and platform liquidation fees + pub fee_factor: i128, // I80F48 +} + #[event] pub struct TokenConditionalSwapCreateLog { pub mango_group: Pubkey, diff --git a/programs/mango-v4/src/state/bank.rs b/programs/mango-v4/src/state/bank.rs index 3f2187dcc..73658e6bc 100644 --- a/programs/mango-v4/src/state/bank.rs +++ b/programs/mango-v4/src/state/bank.rs @@ -79,8 +79,12 @@ pub struct Bank { /// which is >=1. pub max_rate: I80F48, - // TODO: add ix/logic to regular send this to DAO + /// Fees collected over the lifetime of the bank + /// + /// See fees_withdrawn for how much of the fees was withdrawn. + /// See collected_liquidation_fees for the (included) subtotal for liquidation related fees. pub collected_fees_native: I80F48, + pub loan_origination_fee_rate: I80F48, pub loan_fee_rate: I80F48, @@ -92,9 +96,13 @@ pub struct Bank { pub maint_liab_weight: I80F48, pub init_liab_weight: I80F48, - // a fraction of the price, like 0.05 for a 5% fee during liquidation - // - // Liquidation always involves two tokens, and the sum of the two configured fees is used. + /// Liquidation fee that goes to the liqor. + /// + /// Liquidation always involves two tokens, and the sum of the two configured fees is used. + /// + /// A fraction of the price, like 0.05 for a 5% fee during liquidation. + /// + /// See also platform_liquidation_fee. pub liquidation_fee: I80F48, // Collection of all fractions-of-native-tokens that got rounded away @@ -201,8 +209,16 @@ pub struct Bank { /// See util0, rate0, util1, rate1, max_rate pub zero_util_rate: I80F48, + /// Additional to liquidation_fee, but goes to the group owner instead of the liqor + pub platform_liquidation_fee: I80F48, + + /// Fees that were collected during liquidation (in native tokens) + /// + /// See also collected_fees_native and fees_withdrawn. + pub collected_liquidation_fees: I80F48, + #[derivative(Debug = "ignore")] - pub reserved: [u8; 1952], + pub reserved: [u8; 1920], } const_assert_eq!( size_of::(), @@ -239,8 +255,8 @@ const_assert_eq!( + 16 * 3 + 32 + 8 - + 16 - + 1952 + + 16 * 3 + + 1920 ); const_assert_eq!(size_of::(), 3064); const_assert_eq!(size_of::() % 8, 0); @@ -283,6 +299,7 @@ impl Bank { indexed_deposits: I80F48::ZERO, indexed_borrows: I80F48::ZERO, collected_fees_native: I80F48::ZERO, + collected_liquidation_fees: I80F48::ZERO, fees_withdrawn: 0, dust: I80F48::ZERO, flash_loan_approved_amount: 0, @@ -345,7 +362,8 @@ impl Bank { fallback_oracle: existing_bank.oracle, deposit_limit: existing_bank.deposit_limit, zero_util_rate: existing_bank.zero_util_rate, - reserved: [0; 1952], + platform_liquidation_fee: existing_bank.platform_liquidation_fee, + reserved: [0; 1920], } } @@ -377,6 +395,7 @@ impl Bank { require_gte!(self.maint_weight_shift_asset_target, 0.0); require_gte!(self.maint_weight_shift_liab_target, 0.0); require_gte!(self.zero_util_rate, I80F48::ZERO); + require_gte!(self.platform_liquidation_fee, 0.0); Ok(()) } diff --git a/programs/mango-v4/tests/cases/test_liq_tokens.rs b/programs/mango-v4/tests/cases/test_liq_tokens.rs index 07318dbf6..56faf7cca 100644 --- a/programs/mango-v4/tests/cases/test_liq_tokens.rs +++ b/programs/mango-v4/tests/cases/test_liq_tokens.rs @@ -192,6 +192,25 @@ async fn test_liq_tokens_with_token() -> Result<(), TransportError> { let collateral_token1 = &tokens[2]; let collateral_token2 = &tokens[3]; + for token in &tokens[0..4] { + send_tx( + solana, + TokenEdit { + group, + admin, + mint: token.mint.pubkey, + fallback_oracle: Pubkey::default(), + options: mango_v4::instruction::TokenEdit { + liquidation_fee_opt: Some(0.01), + platform_liquidation_fee_opt: Some(0.01), + ..token_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); + } + // deposit some funds, to the vaults aren't empty let vault_account = send_tx( solana, @@ -325,13 +344,42 @@ async fn test_liq_tokens_with_token() -> Result<(), TransportError> { .await .unwrap(); - // the we only have 20 collateral2, and can trade them for 20 / (1.02 * 1.02) = 19.22 borrow2 - // (liq fee is 2% for both sides) + // the liqee's 20 collateral2 are traded for 20 / (1.02 * 1.02) = 19.22 borrow2 + // (liq fee is 1% liqor + 1% platform for both sides) assert_eq!( account_position(solana, account, borrow_token2.bank).await, -50 + 19 ); + assert_eq!( + account_position(solana, vault_account, borrow_token2.bank).await, + 100000 - 19 + ); + + // All liqee collateral2 is gone assert!(account_position_closed(solana, account, collateral_token2.bank).await,); + + // The liqee pays for the 20 collateral at a price of 1.02*1.02. The liqor gets 1.01*1.01, + // so the platform fee is + let platform_fee = 20.0 * (1.0 - 1.01 * 1.01 / (1.02 * 1.02)); + assert!(assert_equal_f64_f64( + account_position_f64(solana, vault_account, collateral_token2.bank).await, + 100000.0 + 20.0 - platform_fee, + 0.001, + )); + + // Verify platform liq fee tracking + let colbank = solana.get_account::(collateral_token2.bank).await; + assert!(assert_equal_fixed_f64( + colbank.collected_fees_native, + platform_fee, + 0.001 + )); + assert!(assert_equal_fixed_f64( + colbank.collected_liquidation_fees, + platform_fee, + 0.001 + )); + let liqee = get_mango_account(solana, account).await; assert!(liqee.being_liquidated()); diff --git a/programs/mango-v4/tests/program_test/mango_client.rs b/programs/mango-v4/tests/program_test/mango_client.rs index c9080d6a7..6aa7853da 100644 --- a/programs/mango-v4/tests/program_test/mango_client.rs +++ b/programs/mango-v4/tests/program_test/mango_client.rs @@ -1012,6 +1012,7 @@ pub struct TokenRegisterInstruction { pub maint_liab_weight: f32, pub init_liab_weight: f32, pub liquidation_fee: f32, + pub platform_liquidation_fee: f32, pub min_vault_to_deposits_ratio: f64, pub net_borrow_limit_per_window_quote: i64, @@ -1075,6 +1076,7 @@ impl ClientInstruction for TokenRegisterInstruction { group_insurance_fund: true, deposit_limit: 0, zero_util_rate: 0.0, + platform_liquidation_fee: self.platform_liquidation_fee, }; let bank = Pubkey::find_program_address( @@ -1321,6 +1323,7 @@ pub fn token_edit_instruction_default() -> mango_v4::instruction::TokenEdit { set_fallback_oracle: false, deposit_limit_opt: None, zero_util_rate_opt: None, + platform_liquidation_fee_opt: None, } } diff --git a/programs/mango-v4/tests/program_test/mango_setup.rs b/programs/mango-v4/tests/program_test/mango_setup.rs index 0a722d763..6afeff4f3 100644 --- a/programs/mango-v4/tests/program_test/mango_setup.rs +++ b/programs/mango-v4/tests/program_test/mango_setup.rs @@ -112,6 +112,7 @@ impl<'a> GroupWithTokensConfig { min_vault_to_deposits_ratio: 0.2, net_borrow_limit_per_window_quote: 1_000_000_000_000, net_borrow_limit_window_size_ts: 24 * 60 * 60, + platform_liquidation_fee: 0.0, }, ) .await diff --git a/ts/client/src/accounts/bank.ts b/ts/client/src/accounts/bank.ts index bd2fbd206..0c6b7d42d 100644 --- a/ts/client/src/accounts/bank.ts +++ b/ts/client/src/accounts/bank.ts @@ -81,6 +81,8 @@ export class Bank implements BankForHealth { public maintWeightShiftAssetTarget: I80F48; public maintWeightShiftLiabTarget: I80F48; public zeroUtilRate: I80F48; + public platformLiquidationFee: I80F48; + public collectedLiquidationFees: I80F48; static from( publicKey: PublicKey, @@ -142,6 +144,8 @@ export class Bank implements BankForHealth { maintWeightShiftLiabTarget: I80F48Dto; depositLimit: BN; zeroUtilRate: I80F48Dto; + platformLiquidationFee: I80F48Dto; + collectedLiquidationFees: I80F48Dto; }, ): Bank { return new Bank( @@ -203,6 +207,8 @@ export class Bank implements BankForHealth { obj.maintWeightShiftLiabTarget, obj.depositLimit, obj.zeroUtilRate, + obj.platformLiquidationFee, + obj.collectedLiquidationFees, ); } @@ -265,6 +271,8 @@ export class Bank implements BankForHealth { maintWeightShiftLiabTarget: I80F48Dto, public depositLimit: BN, zeroUtilRate: I80F48Dto, + platformLiquidationFee: I80F48Dto, + collectedLiquidationFees: I80F48Dto, ) { this.name = utf8.decode(new Uint8Array(name)).split('\x00')[0]; this.oracleConfig = { @@ -295,6 +303,8 @@ export class Bank implements BankForHealth { this.maintWeightShiftAssetTarget = I80F48.from(maintWeightShiftAssetTarget); this.maintWeightShiftLiabTarget = I80F48.from(maintWeightShiftLiabTarget); this.zeroUtilRate = I80F48.from(zeroUtilRate); + this.platformLiquidationFee = I80F48.from(platformLiquidationFee); + this.collectedLiquidationFees = I80F48.from(collectedLiquidationFees); this._price = undefined; this._uiPrice = undefined; this._oracleLastUpdatedSlot = undefined; diff --git a/ts/client/src/client.ts b/ts/client/src/client.ts index 9649ed32e..ead319541 100644 --- a/ts/client/src/client.ts +++ b/ts/client/src/client.ts @@ -459,6 +459,7 @@ export class MangoClient { params.groupInsuranceFund, params.depositLimit, params.zeroUtilRate, + params.platformLiquidationFee, ) .accounts({ group: group.publicKey, @@ -544,6 +545,7 @@ export class MangoClient { params.setFallbackOracle ?? false, params.depositLimit, params.zeroUtilRate, + params.platformLiquidationFee, ) .accounts({ group: group.publicKey, diff --git a/ts/client/src/clientIxParamBuilder.ts b/ts/client/src/clientIxParamBuilder.ts index 03d598c11..d6e69d0bf 100644 --- a/ts/client/src/clientIxParamBuilder.ts +++ b/ts/client/src/clientIxParamBuilder.ts @@ -29,6 +29,7 @@ export interface TokenRegisterParams { interestTargetUtilization: number; depositLimit: BN; zeroUtilRate: number; + platformLiquidationFee: number; } export const DefaultTokenRegisterParams: TokenRegisterParams = { @@ -68,6 +69,7 @@ export const DefaultTokenRegisterParams: TokenRegisterParams = { interestTargetUtilization: 0.5, depositLimit: new BN(0), zeroUtilRate: 0.0, + platformLiquidationFee: 0.0, }; export interface TokenEditParams { @@ -108,6 +110,7 @@ export interface TokenEditParams { setFallbackOracle: boolean | null; depositLimit: BN | null; zeroUtilRate: number | null; + platformLiquidationFee: number | null; } export const NullTokenEditParams: TokenEditParams = { @@ -148,6 +151,7 @@ export const NullTokenEditParams: TokenEditParams = { setFallbackOracle: null, depositLimit: null, zeroUtilRate: null, + platformLiquidationFee: null, }; export interface PerpEditParams { diff --git a/ts/client/src/mango_v4.ts b/ts/client/src/mango_v4.ts index bd7b6b90c..3c77b6ea2 100644 --- a/ts/client/src/mango_v4.ts +++ b/ts/client/src/mango_v4.ts @@ -627,6 +627,10 @@ export type MangoV4 = { { "name": "zeroUtilRate", "type": "f32" + }, + { + "name": "platformLiquidationFee", + "type": "f32" } ] }, @@ -1031,6 +1035,12 @@ export type MangoV4 = { "type": { "option": "f32" } + }, + { + "name": "platformLiquidationFeeOpt", + "type": { + "option": "f32" + } } ] }, @@ -7193,6 +7203,12 @@ export type MangoV4 = { }, { "name": "collectedFeesNative", + "docs": [ + "Fees collected over the lifetime of the bank", + "", + "See fees_withdrawn for how much of the fees was withdrawn.", + "See collected_liquidation_fees for the (included) subtotal for liquidation related fees." + ], "type": { "defined": "I80F48" } @@ -7235,6 +7251,15 @@ export type MangoV4 = { }, { "name": "liquidationFee", + "docs": [ + "Liquidation fee that goes to the liqor.", + "", + "Liquidation always involves two tokens, and the sum of the two configured fees is used.", + "", + "A fraction of the price, like 0.05 for a 5% fee during liquidation.", + "", + "See also platform_liquidation_fee." + ], "type": { "defined": "I80F48" } @@ -7458,12 +7483,32 @@ export type MangoV4 = { "defined": "I80F48" } }, + { + "name": "platformLiquidationFee", + "docs": [ + "Additional to liquidation_fee, but goes to the group owner instead of the liqor" + ], + "type": { + "defined": "I80F48" + } + }, + { + "name": "collectedLiquidationFees", + "docs": [ + "Fees that were collected during liquidation (in native tokens)", + "", + "See also collected_fees_native and fees_withdrawn." + ], + "type": { + "defined": "I80F48" + } + }, { "name": "reserved", "type": { "array": [ "u8", - 1952 + 1920 ] } } @@ -11962,6 +12007,71 @@ export type MangoV4 = { } ] }, + { + "name": "TokenLiqWithTokenLogV2", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "liqee", + "type": "publicKey", + "index": false + }, + { + "name": "liqor", + "type": "publicKey", + "index": false + }, + { + "name": "assetTokenIndex", + "type": "u16", + "index": false + }, + { + "name": "liabTokenIndex", + "type": "u16", + "index": false + }, + { + "name": "assetTransferFromLiqee", + "type": "i128", + "index": false + }, + { + "name": "assetTransferToLiqor", + "type": "i128", + "index": false + }, + { + "name": "assetLiquidationFee", + "type": "i128", + "index": false + }, + { + "name": "liabTransfer", + "type": "i128", + "index": false + }, + { + "name": "assetPrice", + "type": "i128", + "index": false + }, + { + "name": "liabPrice", + "type": "i128", + "index": false + }, + { + "name": "bankruptcy", + "type": "bool", + "index": false + } + ] + }, { "name": "Serum3OpenOrdersBalanceLog", "fields": [ @@ -12838,6 +12948,71 @@ export type MangoV4 = { } ] }, + { + "name": "TokenForceCloseBorrowsWithTokenLogV2", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "liqor", + "type": "publicKey", + "index": false + }, + { + "name": "liqee", + "type": "publicKey", + "index": false + }, + { + "name": "assetTokenIndex", + "type": "u16", + "index": false + }, + { + "name": "liabTokenIndex", + "type": "u16", + "index": false + }, + { + "name": "assetTransferFromLiqee", + "type": "i128", + "index": false + }, + { + "name": "assetTransferToLiqor", + "type": "i128", + "index": false + }, + { + "name": "assetLiquidationFee", + "type": "i128", + "index": false + }, + { + "name": "liabTransfer", + "type": "i128", + "index": false + }, + { + "name": "assetPrice", + "type": "i128", + "index": false + }, + { + "name": "liabPrice", + "type": "i128", + "index": false + }, + { + "name": "feeFactor", + "type": "i128", + "index": false + } + ] + }, { "name": "TokenConditionalSwapCreateLog", "fields": [ @@ -14387,6 +14562,10 @@ export const IDL: MangoV4 = { { "name": "zeroUtilRate", "type": "f32" + }, + { + "name": "platformLiquidationFee", + "type": "f32" } ] }, @@ -14791,6 +14970,12 @@ export const IDL: MangoV4 = { "type": { "option": "f32" } + }, + { + "name": "platformLiquidationFeeOpt", + "type": { + "option": "f32" + } } ] }, @@ -20953,6 +21138,12 @@ export const IDL: MangoV4 = { }, { "name": "collectedFeesNative", + "docs": [ + "Fees collected over the lifetime of the bank", + "", + "See fees_withdrawn for how much of the fees was withdrawn.", + "See collected_liquidation_fees for the (included) subtotal for liquidation related fees." + ], "type": { "defined": "I80F48" } @@ -20995,6 +21186,15 @@ export const IDL: MangoV4 = { }, { "name": "liquidationFee", + "docs": [ + "Liquidation fee that goes to the liqor.", + "", + "Liquidation always involves two tokens, and the sum of the two configured fees is used.", + "", + "A fraction of the price, like 0.05 for a 5% fee during liquidation.", + "", + "See also platform_liquidation_fee." + ], "type": { "defined": "I80F48" } @@ -21218,12 +21418,32 @@ export const IDL: MangoV4 = { "defined": "I80F48" } }, + { + "name": "platformLiquidationFee", + "docs": [ + "Additional to liquidation_fee, but goes to the group owner instead of the liqor" + ], + "type": { + "defined": "I80F48" + } + }, + { + "name": "collectedLiquidationFees", + "docs": [ + "Fees that were collected during liquidation (in native tokens)", + "", + "See also collected_fees_native and fees_withdrawn." + ], + "type": { + "defined": "I80F48" + } + }, { "name": "reserved", "type": { "array": [ "u8", - 1952 + 1920 ] } } @@ -25722,6 +25942,71 @@ export const IDL: MangoV4 = { } ] }, + { + "name": "TokenLiqWithTokenLogV2", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "liqee", + "type": "publicKey", + "index": false + }, + { + "name": "liqor", + "type": "publicKey", + "index": false + }, + { + "name": "assetTokenIndex", + "type": "u16", + "index": false + }, + { + "name": "liabTokenIndex", + "type": "u16", + "index": false + }, + { + "name": "assetTransferFromLiqee", + "type": "i128", + "index": false + }, + { + "name": "assetTransferToLiqor", + "type": "i128", + "index": false + }, + { + "name": "assetLiquidationFee", + "type": "i128", + "index": false + }, + { + "name": "liabTransfer", + "type": "i128", + "index": false + }, + { + "name": "assetPrice", + "type": "i128", + "index": false + }, + { + "name": "liabPrice", + "type": "i128", + "index": false + }, + { + "name": "bankruptcy", + "type": "bool", + "index": false + } + ] + }, { "name": "Serum3OpenOrdersBalanceLog", "fields": [ @@ -26598,6 +26883,71 @@ export const IDL: MangoV4 = { } ] }, + { + "name": "TokenForceCloseBorrowsWithTokenLogV2", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "liqor", + "type": "publicKey", + "index": false + }, + { + "name": "liqee", + "type": "publicKey", + "index": false + }, + { + "name": "assetTokenIndex", + "type": "u16", + "index": false + }, + { + "name": "liabTokenIndex", + "type": "u16", + "index": false + }, + { + "name": "assetTransferFromLiqee", + "type": "i128", + "index": false + }, + { + "name": "assetTransferToLiqor", + "type": "i128", + "index": false + }, + { + "name": "assetLiquidationFee", + "type": "i128", + "index": false + }, + { + "name": "liabTransfer", + "type": "i128", + "index": false + }, + { + "name": "assetPrice", + "type": "i128", + "index": false + }, + { + "name": "liabPrice", + "type": "i128", + "index": false + }, + { + "name": "feeFactor", + "type": "i128", + "index": false + } + ] + }, { "name": "TokenConditionalSwapCreateLog", "fields": [ diff --git a/ts/client/src/risk.ts b/ts/client/src/risk.ts index 746ce3959..a853e2f8f 100644 --- a/ts/client/src/risk.ts +++ b/ts/client/src/risk.ts @@ -134,7 +134,7 @@ export async function getPriceImpactForLiqor( // Max liab of a particular token that would be liquidated to bring health above 0 mangoAccountsWithHealth.reduce((sum, a) => { // How much would health increase for every unit liab moved to liqor - // liabprice * (liabweight - (1+fee)*assetweight) + // liabprice * (liabweight - (1+liabfees)*(1+assetfees)*assetweight) // Choose the most valuable asset the user has const assetBank = Array.from(group.banksMapByTokenIndex.values()) .flat() @@ -152,12 +152,16 @@ export async function getPriceImpactForLiqor( ? prev : curr, ); - const tokenLiabHealthContrib = bank.price.mul( - bank.initLiabWeight.sub( + const feeFactor = ONE_I80F48() + .add(bank.liquidationFee) + .add(bank.platformLiquidationFee) + .mul( ONE_I80F48() - .add(bank.liquidationFee) - .mul(assetBank.initAssetWeight), - ), + .add(assetBank.liquidationFee) + .add(assetBank.platformLiquidationFee), + ); + const tokenLiabHealthContrib = bank.price.mul( + bank.initLiabWeight.sub(feeFactor.mul(assetBank.initAssetWeight)), ); // Abs liab/borrow const maxTokenLiab = a.account From 43b9cac3a1760b5bd3952e164ae89442662b3d44 Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Fri, 19 Jan 2024 16:35:30 +0100 Subject: [PATCH 04/42] Rust client: Allow sending transactions to multiple rpcs (#853) --- bin/cli/src/main.rs | 16 +-- bin/cli/src/save_snapshot.rs | 6 +- bin/liquidator/src/main.rs | 10 +- bin/liquidator/src/trigger_tcs.rs | 2 +- bin/service-mango-crank/src/main.rs | 2 +- bin/service-mango-fills/src/main.rs | 2 +- bin/service-mango-orderbook/src/main.rs | 2 +- bin/service-mango-pnl/src/main.rs | 4 +- bin/settler/src/main.rs | 4 +- bin/settler/src/settle.rs | 13 +-- lib/client/src/client.rs | 139 +++++++++++++++++++----- lib/client/src/jupiter/v4.rs | 12 +- lib/client/src/jupiter/v6.rs | 18 +-- 13 files changed, 156 insertions(+), 74 deletions(-) diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index 9dd1c5d84..a27e0245c 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -133,16 +133,16 @@ enum Command { impl Rpc { fn client(&self, override_fee_payer: Option<&str>) -> anyhow::Result { let fee_payer = keypair_from_cli(override_fee_payer.unwrap_or(&self.fee_payer)); - Ok(Client::new( - anchor_client::Cluster::from_str(&self.url)?, - solana_sdk::commitment_config::CommitmentConfig::confirmed(), - Arc::new(fee_payer), - None, - TransactionBuilderConfig { + Ok(Client::builder() + .cluster(anchor_client::Cluster::from_str(&self.url)?) + .commitment(solana_sdk::commitment_config::CommitmentConfig::confirmed()) + .fee_payer(Some(Arc::new(fee_payer))) + .transaction_builder_config(TransactionBuilderConfig { prioritization_micro_lamports: Some(5), compute_budget_per_instruction: Some(250_000), - }, - )) + }) + .build() + .unwrap()) } } diff --git a/bin/cli/src/save_snapshot.rs b/bin/cli/src/save_snapshot.rs index 50124b875..4c0c375eb 100644 --- a/bin/cli/src/save_snapshot.rs +++ b/bin/cli/src/save_snapshot.rs @@ -23,10 +23,10 @@ pub async fn save_snapshot( } fs::create_dir_all(out_path).unwrap(); - let rpc_url = client.cluster.url().to_string(); - let ws_url = client.cluster.ws_url().to_string(); + let rpc_url = client.config().cluster.url().to_string(); + let ws_url = client.config().cluster.ws_url().to_string(); - let group_context = MangoGroupContext::new_from_rpc(&client.rpc_async(), mango_group).await?; + let group_context = MangoGroupContext::new_from_rpc(client.rpc_async(), mango_group).await?; let oracles_and_vaults = group_context .tokens diff --git a/bin/liquidator/src/main.rs b/bin/liquidator/src/main.rs index e45d79ca3..205b05e74 100644 --- a/bin/liquidator/src/main.rs +++ b/bin/liquidator/src/main.rs @@ -93,6 +93,9 @@ struct Cli { #[clap(short, long, env)] rpc_url: String, + #[clap(long, env, value_delimiter = ';')] + override_send_transaction_url: Option>, + #[clap(long, env)] liqor_mango_account: Pubkey, @@ -207,7 +210,7 @@ async fn main() -> anyhow::Result<()> { .cluster(cluster.clone()) .commitment(commitment) .fee_payer(Some(liqor_owner.clone())) - .timeout(Some(rpc_timeout)) + .timeout(rpc_timeout) .jupiter_v4_url(cli.jupiter_v4_url) .jupiter_v6_url(cli.jupiter_v6_url) .jupiter_token(cli.jupiter_token) @@ -217,6 +220,7 @@ async fn main() -> anyhow::Result<()> { // Liquidation and tcs triggers set their own budgets, this is a default for other tx compute_budget_per_instruction: Some(250_000), }) + .override_send_transaction_urls(cli.override_send_transaction_url) .build() .unwrap(); @@ -225,7 +229,7 @@ async fn main() -> anyhow::Result<()> { // Reading accounts from chain_data let account_fetcher = Arc::new(chain_data::AccountFetcher { chain_data: chain_data.clone(), - rpc: client.rpc_async(), + rpc: client.new_rpc_async(), }); let mango_account = account_fetcher @@ -238,7 +242,7 @@ async fn main() -> anyhow::Result<()> { warn!("rebalancing on delegated accounts will be unable to free token positions reliably, withdraw dust manually"); } - let group_context = MangoGroupContext::new_from_rpc(&client.rpc_async(), mango_group).await?; + let group_context = MangoGroupContext::new_from_rpc(client.rpc_async(), mango_group).await?; let mango_oracles = group_context .tokens diff --git a/bin/liquidator/src/trigger_tcs.rs b/bin/liquidator/src/trigger_tcs.rs index 00ad4aadd..5f1a1caa6 100644 --- a/bin/liquidator/src/trigger_tcs.rs +++ b/bin/liquidator/src/trigger_tcs.rs @@ -1168,7 +1168,7 @@ impl Context { address_lookup_tables: vec![], payer: fee_payer.pubkey(), signers: vec![self.mango_client.owner.clone(), fee_payer], - config: self.mango_client.client.transaction_builder_config, + config: self.mango_client.client.config().transaction_builder_config, } }; diff --git a/bin/service-mango-crank/src/main.rs b/bin/service-mango-crank/src/main.rs index 1a36dc6e2..bb0c144a1 100644 --- a/bin/service-mango-crank/src/main.rs +++ b/bin/service-mango-crank/src/main.rs @@ -78,7 +78,7 @@ async fn main() -> anyhow::Result<()> { ); let group_pk = Pubkey::from_str(&config.mango_group).unwrap(); let group_context = - Arc::new(MangoGroupContext::new_from_rpc(&client.rpc_async(), group_pk).await?); + Arc::new(MangoGroupContext::new_from_rpc(client.rpc_async(), group_pk).await?); let perp_queue_pks: Vec<_> = group_context .perp_markets diff --git a/bin/service-mango-fills/src/main.rs b/bin/service-mango-fills/src/main.rs index 067ea32aa..77dd666b0 100644 --- a/bin/service-mango-fills/src/main.rs +++ b/bin/service-mango-fills/src/main.rs @@ -373,7 +373,7 @@ async fn main() -> anyhow::Result<()> { ); let group_context = Arc::new( MangoGroupContext::new_from_rpc( - &client.rpc_async(), + client.rpc_async(), Pubkey::from_str(&config.mango_group).unwrap(), ) .await?, diff --git a/bin/service-mango-orderbook/src/main.rs b/bin/service-mango-orderbook/src/main.rs index 47abce5e2..e2691d89d 100644 --- a/bin/service-mango-orderbook/src/main.rs +++ b/bin/service-mango-orderbook/src/main.rs @@ -357,7 +357,7 @@ async fn main() -> anyhow::Result<()> { ); let group_context = Arc::new( MangoGroupContext::new_from_rpc( - &client.rpc_async(), + client.rpc_async(), Pubkey::from_str(&config.mango_group).unwrap(), ) .await?, diff --git a/bin/service-mango-pnl/src/main.rs b/bin/service-mango-pnl/src/main.rs index 2b3d69b56..c2f2f385c 100644 --- a/bin/service-mango-pnl/src/main.rs +++ b/bin/service-mango-pnl/src/main.rs @@ -265,7 +265,7 @@ async fn main() -> anyhow::Result<()> { ); let group_context = Arc::new( MangoGroupContext::new_from_rpc( - &client.rpc_async(), + client.rpc_async(), Pubkey::from_str(&config.pnl.mango_group).unwrap(), ) .await?, @@ -273,7 +273,7 @@ async fn main() -> anyhow::Result<()> { let chain_data = Arc::new(RwLock::new(chain_data::ChainData::new())); let account_fetcher = Arc::new(chain_data::AccountFetcher { chain_data: chain_data.clone(), - rpc: client.rpc_async(), + rpc: client.new_rpc_async(), }); let metrics_tx = metrics::start(config.metrics, "pnl".into()); diff --git a/bin/settler/src/main.rs b/bin/settler/src/main.rs index 5c039b4c0..4c6e06ade 100644 --- a/bin/settler/src/main.rs +++ b/bin/settler/src/main.rs @@ -112,7 +112,7 @@ async fn main() -> anyhow::Result<()> { // Reading accounts from chain_data let account_fetcher = Arc::new(chain_data::AccountFetcher { chain_data: chain_data.clone(), - rpc: client.rpc_async(), + rpc: client.new_rpc_async(), }); let mango_account = account_fetcher @@ -120,7 +120,7 @@ async fn main() -> anyhow::Result<()> { .await?; let mango_group = mango_account.fixed.group; - let group_context = MangoGroupContext::new_from_rpc(&client.rpc_async(), mango_group).await?; + let group_context = MangoGroupContext::new_from_rpc(client.rpc_async(), mango_group).await?; let mango_oracles = group_context .tokens diff --git a/bin/settler/src/settle.rs b/bin/settler/src/settle.rs index 6a5485cc7..51b91d5fd 100644 --- a/bin/settler/src/settle.rs +++ b/bin/settler/src/settle.rs @@ -6,8 +6,7 @@ use mango_v4::accounts_zerocopy::KeyedAccountSharedData; use mango_v4::health::HealthType; use mango_v4::state::{OracleAccountInfos, PerpMarket, PerpMarketIndex}; use mango_v4_client::{ - chain_data, health_cache, prettify_solana_client_error, MangoClient, PreparedInstructions, - TransactionBuilder, + chain_data, health_cache, MangoClient, PreparedInstructions, TransactionBuilder, }; use solana_sdk::address_lookup_table_account::AddressLookupTableAccount; use solana_sdk::commitment_config::CommitmentConfig; @@ -273,7 +272,7 @@ impl<'a> SettleBatchProcessor<'a> { address_lookup_tables: self.address_lookup_tables.clone(), payer: fee_payer.pubkey(), signers: vec![fee_payer], - config: client.transaction_builder_config, + config: client.config().transaction_builder_config, } .transaction_with_blockhash(self.blockhash) } @@ -286,13 +285,7 @@ impl<'a> SettleBatchProcessor<'a> { let tx = self.transaction()?; self.instructions.clear(); - let send_result = self - .mango_client - .client - .rpc_async() - .send_transaction_with_config(&tx, self.mango_client.client.rpc_send_transaction_config) - .await - .map_err(prettify_solana_client_error); + let send_result = self.mango_client.client.send_transaction(&tx).await; if let Err(err) = send_result { info!("error while sending settle batch: {}", err); diff --git a/lib/client/src/client.rs b/lib/client/src/client.rs index 9716df468..fd051d26e 100644 --- a/lib/client/src/client.rs +++ b/lib/client/src/client.rs @@ -12,8 +12,9 @@ use anchor_spl::associated_token::get_associated_token_address; use anchor_spl::token::Token; use fixed::types::I80F48; -use futures::{stream, StreamExt, TryStreamExt}; +use futures::{stream, StreamExt, TryFutureExt, TryStreamExt}; use itertools::Itertools; +use tracing::*; use mango_v4::accounts_ix::{Serum3OrderType, Serum3SelfTradeBehavior, Serum3Side}; use mango_v4::accounts_zerocopy::KeyedAccountSharedData; @@ -24,6 +25,7 @@ use mango_v4::state::{ use solana_address_lookup_table_program::state::AddressLookupTable; use solana_client::nonblocking::rpc_client::RpcClient as RpcClientAsync; +use solana_client::rpc_client::SerializableTransaction; use solana_client::rpc_config::RpcSendTransactionConfig; use solana_client::rpc_response::RpcSimulateTransactionResult; use solana_sdk::address_lookup_table_account::AddressLookupTableAccount; @@ -51,7 +53,8 @@ pub const MAX_ACCOUNTS_PER_TRANSACTION: usize = 64; // very close to anchor_client::Client, which unfortunately has no accessors or Clone #[derive(Clone, Debug, Builder)] -pub struct Client { +#[builder(name = "ClientBuilder", build_fn(name = "build_config"))] +pub struct ClientConfig { /// RPC url /// /// Defaults to Cluster::Mainnet, using the public crowded mainnet-beta rpc endpoint. @@ -70,8 +73,8 @@ pub struct Client { /// /// This timeout applies to rpc requests. Note that the timeout for transaction /// confirmation is configured separately in rpc_confirm_transaction_config. - #[builder(default = "Some(Duration::from_secs(60))")] - pub timeout: Option, + #[builder(default = "Duration::from_secs(60)")] + pub timeout: Duration, #[builder(default)] pub transaction_builder_config: TransactionBuilderConfig, @@ -92,9 +95,19 @@ pub struct Client { #[builder(default = "\"\".into()")] pub jupiter_token: String, + + /// If set, don't use `cluster` for sending transactions and send to all + /// addresses configured here instead. + #[builder(default = "None")] + pub override_send_transaction_urls: Option>, } impl ClientBuilder { + pub fn build(&self) -> Result { + let config = self.build_config()?; + Ok(Client::new_from_config(config)) + } + pub fn default_rpc_send_transaction_config() -> RpcSendTransactionConfig { RpcSendTransactionConfig { preflight_commitment: Some(CommitmentLevel::Processed), @@ -109,6 +122,11 @@ impl ClientBuilder { } } } +pub struct Client { + config: ClientConfig, + rpc_async: RpcClientAsync, + send_transaction_rpc_asyncs: Vec, +} impl Client { pub fn builder() -> ClientBuilder { @@ -127,35 +145,101 @@ impl Client { .cluster(cluster) .commitment(commitment) .fee_payer(Some(fee_payer)) - .timeout(timeout) + .timeout(timeout.unwrap_or(Duration::from_secs(30))) .transaction_builder_config(transaction_builder_config) .build() .unwrap() } - pub fn rpc_async(&self) -> RpcClientAsync { - let url = self.cluster.url().to_string(); - if let Some(timeout) = self.timeout.as_ref() { - RpcClientAsync::new_with_timeout_and_commitment(url, *timeout, self.commitment) - } else { - RpcClientAsync::new_with_commitment(url, self.commitment) + pub fn new_from_config(config: ClientConfig) -> Self { + Self { + rpc_async: RpcClientAsync::new_with_timeout_and_commitment( + config.cluster.url().to_string(), + config.timeout, + config.commitment, + ), + send_transaction_rpc_asyncs: config + .override_send_transaction_urls + .clone() + .unwrap_or_else(|| vec![config.cluster.url().to_string()]) + .into_iter() + .map(|url| { + RpcClientAsync::new_with_timeout_and_commitment( + url, + config.timeout, + config.commitment, + ) + }) + .collect_vec(), + config, } } + pub fn config(&self) -> &ClientConfig { + &self.config + } + + pub fn rpc_async(&self) -> &RpcClientAsync { + &self.rpc_async + } + + /// Sometimes clients don't want to borrow the Client instance and just pass on RpcClientAsync + pub fn new_rpc_async(&self) -> RpcClientAsync { + let url = self.config.cluster.url().to_string(); + RpcClientAsync::new_with_timeout_and_commitment( + url, + self.config.timeout, + self.config.commitment, + ) + } + // TODO: this function here is awkward, since it (intentionally) doesn't use MangoClient::account_fetcher pub async fn rpc_anchor_account( &self, address: &Pubkey, ) -> anyhow::Result { - fetch_anchor_account(&self.rpc_async(), address).await + fetch_anchor_account(self.rpc_async(), address).await } pub fn fee_payer(&self) -> Arc { - self.fee_payer + self.config + .fee_payer .as_ref() .expect("fee payer must be set") .clone() } + + /// Sends a transaction via the configured cluster (or all override_send_transaction_urls). + /// + /// Returns the tx signature if at least one send returned ok. + /// Note that a success does not mean that the transaction is confirmed. + pub async fn send_transaction( + &self, + tx: &impl SerializableTransaction, + ) -> anyhow::Result { + let futures = self.send_transaction_rpc_asyncs.iter().map(|rpc| { + rpc.send_transaction_with_config(tx, self.config.rpc_send_transaction_config) + .map_err(prettify_solana_client_error) + }); + let mut results = futures::future::join_all(futures).await; + + // If all fail, return the first + let successful_sends = results.iter().filter(|r| r.is_ok()).count(); + if successful_sends == 0 { + results.remove(0)?; + } + + // Otherwise just log errors + for (result, rpc) in results.iter().zip(self.send_transaction_rpc_asyncs.iter()) { + if let Err(err) = result { + info!( + rpc = rpc.url(), + successful_sends, "one of the transaction sends failed: {err:?}", + ) + } + } + return Ok(*tx.get_signature()); + } } // todo: might want to integrate geyser, websockets, or simple http polling for keeping data fresh @@ -193,7 +277,7 @@ impl MangoClient { group: Pubkey, owner: &Keypair, ) -> anyhow::Result> { - fetch_mango_accounts(&client.rpc_async(), mango_v4::ID, group, owner.pubkey()).await + fetch_mango_accounts(client.rpc_async(), mango_v4::ID, group, owner.pubkey()).await } pub async fn find_or_create_account( @@ -287,7 +371,7 @@ impl MangoClient { address_lookup_tables: vec![], payer: payer.pubkey(), signers: vec![owner, payer], - config: client.transaction_builder_config, + config: client.config.transaction_builder_config, } .send_and_confirm(&client) .await?; @@ -301,7 +385,7 @@ impl MangoClient { account: Pubkey, owner: Arc, ) -> anyhow::Result { - let rpc = client.rpc_async(); + let rpc = client.new_rpc_async(); let account_fetcher = Arc::new(CachedAccountFetcher::new(Arc::new(RpcAccountFetcher { rpc, }))); @@ -1679,7 +1763,7 @@ impl MangoClient { address_lookup_tables: self.mango_address_lookup_tables().await?, payer: fee_payer.pubkey(), signers: vec![self.owner.clone(), fee_payer], - config: self.client.transaction_builder_config, + config: self.client.config.transaction_builder_config, } .send_and_confirm(&self.client) .await @@ -1695,7 +1779,7 @@ impl MangoClient { address_lookup_tables: self.mango_address_lookup_tables().await?, payer: fee_payer.pubkey(), signers: vec![fee_payer], - config: self.client.transaction_builder_config, + config: self.client.config.transaction_builder_config, } .send_and_confirm(&self.client) .await @@ -1711,7 +1795,7 @@ impl MangoClient { address_lookup_tables: vec![], payer: fee_payer.pubkey(), signers: vec![fee_payer], - config: self.client.transaction_builder_config, + config: self.client.config.transaction_builder_config, } .simulate(&self.client) .await @@ -1730,7 +1814,7 @@ impl MangoClient { match MangoGroupContext::new_from_rpc(&rpc_async, mango_client.group()).await { Ok(v) => v, Err(e) => { - tracing::warn!("could not fetch context to check for changes: {e:?}"); + warn!("could not fetch context to check for changes: {e:?}"); continue; } }; @@ -1775,9 +1859,9 @@ impl TransactionSize { #[derive(Copy, Clone, Debug, Default)] pub struct TransactionBuilderConfig { - // adds a SetComputeUnitPrice instruction in front if none exists + /// adds a SetComputeUnitPrice instruction in front if none exists pub prioritization_micro_lamports: Option, - // adds a SetComputeUnitBudget instruction if none exists + /// adds a SetComputeUnitBudget instruction if none exists pub compute_budget_per_instruction: Option, } @@ -1870,9 +1954,7 @@ impl TransactionBuilder { pub async fn send(&self, client: &Client) -> anyhow::Result { let rpc = client.rpc_async(); let tx = self.transaction(&rpc).await?; - rpc.send_transaction_with_config(&tx, client.rpc_send_transaction_config) - .await - .map_err(prettify_solana_client_error) + client.send_transaction(&tx).await } pub async fn simulate(&self, client: &Client) -> anyhow::Result { @@ -1885,15 +1967,12 @@ impl TransactionBuilder { let rpc = client.rpc_async(); let tx = self.transaction(&rpc).await?; let recent_blockhash = tx.message.recent_blockhash(); - let signature = rpc - .send_transaction_with_config(&tx, client.rpc_send_transaction_config) - .await - .map_err(prettify_solana_client_error)?; + let signature = client.send_transaction(&tx).await?; wait_for_transaction_confirmation( &rpc, &signature, recent_blockhash, - &client.rpc_confirm_transaction_config, + &client.config.rpc_confirm_transaction_config, ) .await?; Ok(signature) diff --git a/lib/client/src/jupiter/v4.rs b/lib/client/src/jupiter/v4.rs index 29cf2be06..770ddf3e9 100644 --- a/lib/client/src/jupiter/v4.rs +++ b/lib/client/src/jupiter/v4.rs @@ -107,7 +107,10 @@ impl<'a> JupiterV4<'a> { let response = self .mango_client .http_client - .get(format!("{}/quote", self.mango_client.client.jupiter_v4_url)) + .get(format!( + "{}/quote", + self.mango_client.client.config().jupiter_v4_url + )) .query(&[ ("inputMint", input_mint.to_string()), ("outputMint", output_mint.to_string()), @@ -158,7 +161,10 @@ impl<'a> JupiterV4<'a> { let swap_response = self .mango_client .http_client - .post(format!("{}/swap", self.mango_client.client.jupiter_v4_url)) + .post(format!( + "{}/swap", + self.mango_client.client.config().jupiter_v4_url + )) .json(&SwapRequest { route: route.clone(), user_public_key: self.mango_client.owner.pubkey().to_string(), @@ -330,7 +336,7 @@ impl<'a> JupiterV4<'a> { address_lookup_tables, payer, signers: vec![self.mango_client.owner.clone()], - config: self.mango_client.client.transaction_builder_config, + config: self.mango_client.client.config().transaction_builder_config, }) } diff --git a/lib/client/src/jupiter/v6.rs b/lib/client/src/jupiter/v6.rs index 3b4ab074e..09ccd6cf1 100644 --- a/lib/client/src/jupiter/v6.rs +++ b/lib/client/src/jupiter/v6.rs @@ -194,15 +194,15 @@ impl<'a> JupiterV6<'a> { ), ), ]; - let client = &self.mango_client.client; - if !client.jupiter_token.is_empty() { - query_args.push(("token", client.jupiter_token.clone())); + let config = self.mango_client.client.config(); + if !config.jupiter_token.is_empty() { + query_args.push(("token", config.jupiter_token.clone())); } let response = self .mango_client .http_client - .get(format!("{}/quote", client.jupiter_v6_url)) + .get(format!("{}/quote", config.jupiter_v6_url)) .query(&query_args) .send() .await @@ -267,15 +267,15 @@ impl<'a> JupiterV6<'a> { .context("building health accounts")?; let mut query_args = vec![]; - let client = &self.mango_client.client; - if !client.jupiter_token.is_empty() { - query_args.push(("token", client.jupiter_token.clone())); + let config = self.mango_client.client.config(); + if !config.jupiter_token.is_empty() { + query_args.push(("token", config.jupiter_token.clone())); } let swap_response = self .mango_client .http_client - .post(format!("{}/swap-instructions", client.jupiter_v6_url)) + .post(format!("{}/swap-instructions", config.jupiter_v6_url)) .query(&query_args) .json(&SwapRequest { user_public_key: owner.to_string(), @@ -386,7 +386,7 @@ impl<'a> JupiterV6<'a> { address_lookup_tables, payer, signers: vec![self.mango_client.owner.clone()], - config: self.mango_client.client.transaction_builder_config, + config: self.mango_client.client.config().transaction_builder_config, }) } From 40b6b496804c0fc54cebba99c5ab1fab67d980b9 Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Mon, 22 Jan 2024 12:56:37 +0100 Subject: [PATCH 05/42] Test: Max borrow with init asset weight scaling --- programs/mango-v4/src/health/client.rs | 116 ++++++++++++++++++------- 1 file changed, 87 insertions(+), 29 deletions(-) diff --git a/programs/mango-v4/src/health/client.rs b/programs/mango-v4/src/health/client.rs index 73b003280..0f0bcad5f 100644 --- a/programs/mango-v4/src/health/client.rs +++ b/programs/mango-v4/src/health/client.rs @@ -1461,27 +1461,49 @@ mod tests { I80F48::ZERO ); - let find_max_borrow = |c: &HealthCache, ratio: f64| { - let max_borrow = c - .max_borrow_for_health_ratio(&account, bank0_data, I80F48::from_num(ratio)) - .unwrap(); - // compute the health ratio we'd get when executing the trade - let actual_ratio = { - let mut c = c.clone(); - c.token_infos[0].balance_spot -= max_borrow; - c.health_ratio(HealthType::Init).to_num::() - }; - // the ratio for borrowing one native token extra - let plus_ratio = { - let mut c = c.clone(); - c.token_infos[0].balance_spot -= max_borrow + I80F48::ONE; - c.health_ratio(HealthType::Init).to_num::() - }; - (max_borrow, actual_ratio, plus_ratio) + let now_ts = system_epoch_secs(); + + let cache_after_borrow = |account: &MangoAccountValue, + c: &HealthCache, + bank: &Bank, + amount: I80F48| + -> Result { + let mut position = account.token_position(bank.token_index)?.clone(); + + let mut bank = bank.clone(); + bank.withdraw_with_fee(&mut position, amount, now_ts)?; + bank.check_net_borrows(c.token_info(bank.token_index)?.prices.oracle)?; + + let mut resulting_cache = c.clone(); + resulting_cache.adjust_token_balance(&bank, -amount)?; + + Ok(resulting_cache) }; - let check_max_borrow = |c: &HealthCache, ratio: f64| -> f64 { + + let find_max_borrow = + |account: &MangoAccountValue, c: &HealthCache, ratio: f64, bank: &Bank| { + let max_borrow = c + .max_borrow_for_health_ratio(account, bank, I80F48::from_num(ratio)) + .unwrap(); + // compute the health ratio we'd get when executing the trade + let actual_ratio = { + let c = cache_after_borrow(account, c, bank, max_borrow).unwrap(); + c.health_ratio(HealthType::Init).to_num::() + }; + // the ratio for borrowing one native token extra + let plus_ratio = { + let c = cache_after_borrow(account, c, bank, max_borrow + I80F48::ONE).unwrap(); + c.health_ratio(HealthType::Init).to_num::() + }; + (max_borrow, actual_ratio, plus_ratio) + }; + let check_max_borrow = |account: &MangoAccountValue, + c: &HealthCache, + ratio: f64, + bank: &Bank| + -> f64 { let initial_ratio = c.health_ratio(HealthType::Init).to_num::(); - let (max_borrow, actual_ratio, plus_ratio) = find_max_borrow(c, ratio); + let (max_borrow, actual_ratio, plus_ratio) = find_max_borrow(account, c, ratio, bank); println!( "checking target ratio {ratio}: initial ratio: {initial_ratio}, actual ratio: {actual_ratio}, plus ratio: {plus_ratio}, borrow: {max_borrow}", ); @@ -1496,30 +1518,66 @@ mod tests { { let mut health_cache = health_cache.clone(); health_cache.token_infos[0].balance_spot = I80F48::from_num(100.0); - assert_eq!(check_max_borrow(&health_cache, 50.0), 100.0); + assert_eq!( + check_max_borrow(&account, &health_cache, 50.0, bank0_data), + 100.0 + ); } { let mut health_cache = health_cache.clone(); health_cache.token_infos[1].balance_spot = I80F48::from_num(50.0); // price 2, so 2*50*0.8 = 80 health - check_max_borrow(&health_cache, 100.0); - check_max_borrow(&health_cache, 50.0); - check_max_borrow(&health_cache, 0.0); + check_max_borrow(&account, &health_cache, 100.0, bank0_data); + check_max_borrow(&account, &health_cache, 50.0, bank0_data); + check_max_borrow(&account, &health_cache, 0.0, bank0_data); } { let mut health_cache = health_cache.clone(); health_cache.token_infos[0].balance_spot = I80F48::from_num(50.0); health_cache.token_infos[1].balance_spot = I80F48::from_num(50.0); - check_max_borrow(&health_cache, 100.0); - check_max_borrow(&health_cache, 50.0); - check_max_borrow(&health_cache, 0.0); + check_max_borrow(&account, &health_cache, 100.0, bank0_data); + check_max_borrow(&account, &health_cache, 50.0, bank0_data); + check_max_borrow(&account, &health_cache, 0.0, bank0_data); } { let mut health_cache = health_cache.clone(); health_cache.token_infos[0].balance_spot = I80F48::from_num(-50.0); health_cache.token_infos[1].balance_spot = I80F48::from_num(50.0); - check_max_borrow(&health_cache, 100.0); - check_max_borrow(&health_cache, 50.0); - check_max_borrow(&health_cache, 0.0); + check_max_borrow(&account, &health_cache, 100.0, bank0_data); + check_max_borrow(&account, &health_cache, 50.0, bank0_data); + check_max_borrow(&account, &health_cache, 0.0, bank0_data); + } + + // A test that includes init weight scaling + { + let mut account = account.clone(); + let mut bank0 = bank0_data.clone(); + let mut health_cache = health_cache.clone(); + let tok0_deposits = I80F48::from_num(500.0); + health_cache.token_infos[0].balance_spot = tok0_deposits; + health_cache.token_infos[1].balance_spot = I80F48::from_num(-100.0); // 2 * 100 * 1.2 = 240 liab + + // This test case needs the bank to know about the deposits + let position = account.token_position_mut(bank0.token_index).unwrap().0; + bank0.deposit(position, tok0_deposits, now_ts).unwrap(); + + // Set up scaling such that token0 health contrib is 500 * 1.0 * 1.0 * (600 / (500 + 300)) = 375 + bank0.deposit_weight_scale_start_quote = 600.0; + bank0.potential_serum_tokens = 300; + health_cache.token_infos[0].init_scaled_asset_weight = + bank0.scaled_init_asset_weight(I80F48::ONE); + + check_max_borrow(&account, &health_cache, 100.0, &bank0); + check_max_borrow(&account, &health_cache, 50.0, &bank0); + + let max_borrow = check_max_borrow(&account, &health_cache, 0.0, &bank0); + // that borrow leaves 240 tokens in the account and <600 total in bank + assert!((260.0 - max_borrow).abs() < 0.3); + + bank0.deposit_weight_scale_start_quote = 500.0; + let max_borrow = check_max_borrow(&account, &health_cache, 0.0, &bank0); + // 500 - 222.6 = 277.4 remaining token 0 deposits + // 277.4 * 500 / (277.4 + 300) = 240.2 (compensating the -240 liab) + assert!((222.6 - max_borrow).abs() < 0.3); } } From db98ba5edf48b06adce485c97a8b12cd0bd8c9d5 Mon Sep 17 00:00:00 2001 From: Lou-Kamades <128186011+Lou-Kamades@users.noreply.github.com> Date: Tue, 23 Jan 2024 10:26:31 -0600 Subject: [PATCH 06/42] Use fallback oracles in Rust client (#838) * rename usd_opt to usdc_opt in OracleAccountInfos * use fallbacks when fetching bank+ price in AccountFetcher struct * feat: add derive_fallback_oracle_keys to MangoGroupContext * test: properly assert failure CU in test_health_compute_tokens_fallback_oracles * provide fallback oracle accounts in the rust client * liquidator: update for fallback oracles * set fallback config in mango services * support fallback oracles in rust settler + keeper * fix send error related to fetching fallbacks dynamically in tcs_start * drop derive_fallback_oracle_keys_sync * add fetch_multiple_accounts to AccountFetcher trait * revert client::new() api * deriving oracle keys uses account_fetcher * use client helpers for deriving health_check account_metas * add health_cache helper to mango client * add get_slot to account_fetcher * lint * cached account fetcher only fetches uncached accounts * ensure keeper client does not use cached oracles for staleness checks * address minor review comments * create unique job keys for CachedAccountFetcher.fetch_multiple_accounts * fmt * improve hashing in CachedAccountFetcher.fetch_multiple_accounts --------- Co-authored-by: Christian Kamm --- bin/keeper/src/main.rs | 24 +- bin/liquidator/src/liquidate.rs | 13 +- bin/liquidator/src/rebalance.rs | 1 + bin/liquidator/src/trigger_tcs.rs | 11 +- bin/service-mango-pnl/src/main.rs | 11 +- bin/settler/src/settle.rs | 20 +- bin/settler/src/tcs_start.rs | 18 +- lib/client/src/account_fetcher.rs | 82 ++++++ lib/client/src/chain_data_fetcher.rs | 49 +++- lib/client/src/client.rs | 239 ++++++++++++------ lib/client/src/context.rs | 169 ++++++++++++- lib/client/src/gpa.rs | 21 +- lib/client/src/health_cache.rs | 43 +++- lib/client/src/jupiter/v4.rs | 2 + lib/client/src/jupiter/v6.rs | 2 + lib/client/src/perp_pnl.rs | 8 +- .../mango-v4/src/health/account_retriever.rs | 10 +- programs/mango-v4/src/state/oracle.rs | 20 +- programs/mango-v4/src/state/orca_cpi.rs | 28 ++ .../tests/cases/test_health_compute.rs | 2 +- 20 files changed, 610 insertions(+), 163 deletions(-) diff --git a/bin/keeper/src/main.rs b/bin/keeper/src/main.rs index 4e006363d..02f374f19 100644 --- a/bin/keeper/src/main.rs +++ b/bin/keeper/src/main.rs @@ -7,7 +7,9 @@ use std::time::Duration; use anchor_client::Cluster; use clap::{Parser, Subcommand}; -use mango_v4_client::{keypair_from_cli, Client, MangoClient, TransactionBuilderConfig}; +use mango_v4_client::{ + keypair_from_cli, Client, FallbackOracleConfig, MangoClient, TransactionBuilderConfig, +}; use solana_sdk::commitment_config::CommitmentConfig; use solana_sdk::pubkey::Pubkey; use tokio::time; @@ -98,19 +100,21 @@ async fn main() -> Result<(), anyhow::Error> { let mango_client = Arc::new( MangoClient::new_for_existing_account( - Client::new( - cluster, - commitment, - owner.clone(), - Some(Duration::from_secs(cli.timeout)), - TransactionBuilderConfig { + Client::builder() + .cluster(cluster) + .commitment(commitment) + .fee_payer(Some(owner.clone())) + .timeout(Duration::from_secs(cli.timeout)) + .transaction_builder_config(TransactionBuilderConfig { prioritization_micro_lamports: (cli.prioritization_micro_lamports > 0) .then_some(cli.prioritization_micro_lamports), compute_budget_per_instruction: None, - }, - ), + }) + .fallback_oracle_config(FallbackOracleConfig::Never) + .build() + .unwrap(), cli.mango_account, - owner.clone(), + owner, ) .await?, ); diff --git a/bin/liquidator/src/liquidate.rs b/bin/liquidator/src/liquidate.rs index 2ddd43606..b0d280207 100644 --- a/bin/liquidator/src/liquidate.rs +++ b/bin/liquidator/src/liquidate.rs @@ -4,7 +4,7 @@ use std::time::Duration; use itertools::Itertools; use mango_v4::health::{HealthCache, HealthType}; use mango_v4::state::{MangoAccountValue, PerpMarketIndex, Side, TokenIndex, QUOTE_TOKEN_INDEX}; -use mango_v4_client::{chain_data, health_cache, MangoClient}; +use mango_v4_client::{chain_data, MangoClient}; use solana_sdk::signature::Signature; use futures::{stream, StreamExt, TryStreamExt}; @@ -155,10 +155,7 @@ impl<'a> LiquidateHelper<'a> { .await .context("getting liquidator account")?; liqor.ensure_perp_position(*perp_market_index, QUOTE_TOKEN_INDEX)?; - let mut health_cache = - health_cache::new(&self.client.context, self.account_fetcher, &liqor) - .await - .context("health cache")?; + let mut health_cache = self.client.health_cache(&liqor).await.expect("always ok"); let quote_bank = self .client .first_bank(QUOTE_TOKEN_INDEX) @@ -589,7 +586,8 @@ pub async fn maybe_liquidate_account( let liqor_min_health_ratio = I80F48::from_num(config.min_health_ratio); let account = account_fetcher.fetch_mango_account(pubkey)?; - let health_cache = health_cache::new(&mango_client.context, account_fetcher, &account) + let health_cache = mango_client + .health_cache(&account) .await .context("creating health cache 1")?; let maint_health = health_cache.health(HealthType::Maint); @@ -607,7 +605,8 @@ pub async fn maybe_liquidate_account( // This is -- unfortunately -- needed because the websocket streams seem to not // be great at providing timely updates to the account data. let account = account_fetcher.fetch_fresh_mango_account(pubkey).await?; - let health_cache = health_cache::new(&mango_client.context, account_fetcher, &account) + let health_cache = mango_client + .health_cache(&account) .await .context("creating health cache 2")?; if !health_cache.is_liquidatable() { diff --git a/bin/liquidator/src/rebalance.rs b/bin/liquidator/src/rebalance.rs index 01c750e84..23757976a 100644 --- a/bin/liquidator/src/rebalance.rs +++ b/bin/liquidator/src/rebalance.rs @@ -520,6 +520,7 @@ impl Rebalancer { }; let counters = perp_pnl::fetch_top( &self.mango_client.context, + &self.mango_client.client.config().fallback_oracle_config, self.account_fetcher.as_ref(), perp_position.market_index, direction, diff --git a/bin/liquidator/src/trigger_tcs.rs b/bin/liquidator/src/trigger_tcs.rs index 5f1a1caa6..c8b914698 100644 --- a/bin/liquidator/src/trigger_tcs.rs +++ b/bin/liquidator/src/trigger_tcs.rs @@ -11,7 +11,7 @@ use mango_v4::{ i80f48::ClampToInt, state::{Bank, MangoAccountValue, TokenConditionalSwap, TokenIndex}, }; -use mango_v4_client::{chain_data, health_cache, jupiter, MangoClient, TransactionBuilder}; +use mango_v4_client::{chain_data, jupiter, MangoClient, TransactionBuilder}; use anyhow::Context as AnyhowContext; use solana_sdk::{signature::Signature, signer::Signer}; @@ -665,8 +665,9 @@ impl Context { liqee_old: &MangoAccountValue, tcs_id: u64, ) -> anyhow::Result> { - let fetcher = self.account_fetcher.as_ref(); - let health_cache = health_cache::new(&self.mango_client.context, fetcher, liqee_old) + let health_cache = self + .mango_client + .health_cache(liqee_old) .await .context("creating health cache 1")?; if health_cache.is_liquidatable() { @@ -685,7 +686,9 @@ impl Context { return Ok(None); } - let health_cache = health_cache::new(&self.mango_client.context, fetcher, &liqee) + let health_cache = self + .mango_client + .health_cache(&liqee) .await .context("creating health cache 2")?; if health_cache.is_liquidatable() { diff --git a/bin/service-mango-pnl/src/main.rs b/bin/service-mango-pnl/src/main.rs index c2f2f385c..49869f379 100644 --- a/bin/service-mango-pnl/src/main.rs +++ b/bin/service-mango-pnl/src/main.rs @@ -21,7 +21,8 @@ use fixed::types::I80F48; use mango_feeds_connector::metrics::*; use mango_v4::state::{MangoAccount, MangoAccountValue, PerpMarketIndex}; use mango_v4_client::{ - chain_data, health_cache, AccountFetcher, Client, MangoGroupContext, TransactionBuilderConfig, + chain_data, health_cache, AccountFetcher, Client, FallbackOracleConfig, MangoGroupContext, + TransactionBuilderConfig, }; use solana_sdk::commitment_config::CommitmentConfig; use solana_sdk::{account::ReadableAccount, signature::Keypair}; @@ -52,7 +53,13 @@ async fn compute_pnl( account_fetcher: Arc, account: &MangoAccountValue, ) -> anyhow::Result> { - let health_cache = health_cache::new(&context, account_fetcher.as_ref(), account).await?; + let health_cache = health_cache::new( + &context, + &FallbackOracleConfig::Dynamic, + account_fetcher.as_ref(), + account, + ) + .await?; let pnls = account .active_perp_positions() diff --git a/bin/settler/src/settle.rs b/bin/settler/src/settle.rs index 51b91d5fd..3534091d7 100644 --- a/bin/settler/src/settle.rs +++ b/bin/settler/src/settle.rs @@ -5,9 +5,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use mango_v4::accounts_zerocopy::KeyedAccountSharedData; use mango_v4::health::HealthType; use mango_v4::state::{OracleAccountInfos, PerpMarket, PerpMarketIndex}; -use mango_v4_client::{ - chain_data, health_cache, MangoClient, PreparedInstructions, TransactionBuilder, -}; +use mango_v4_client::{chain_data, MangoClient, PreparedInstructions, TransactionBuilder}; use solana_sdk::address_lookup_table_account::AddressLookupTableAccount; use solana_sdk::commitment_config::CommitmentConfig; use solana_sdk::signature::Signature; @@ -113,7 +111,8 @@ impl SettlementState { continue; } - let health_cache = health_cache::new(&mango_client.context, account_fetcher, &account) + let health_cache = mango_client + .health_cache(&account) .await .context("creating health cache")?; let liq_end_health = health_cache.health(HealthType::LiquidationEnd); @@ -304,11 +303,14 @@ impl<'a> SettleBatchProcessor<'a> { ) -> anyhow::Result> { let a_value = self.account_fetcher.fetch_mango_account(&account_a)?; let b_value = self.account_fetcher.fetch_mango_account(&account_b)?; - let new_ixs = self.mango_client.perp_settle_pnl_instruction( - self.perp_market_index, - (&account_a, &a_value), - (&account_b, &b_value), - )?; + let new_ixs = self + .mango_client + .perp_settle_pnl_instruction( + self.perp_market_index, + (&account_a, &a_value), + (&account_b, &b_value), + ) + .await?; let previous = self.instructions.clone(); self.instructions.append(new_ixs.clone()); diff --git a/bin/settler/src/tcs_start.rs b/bin/settler/src/tcs_start.rs index d481e3946..b10a2ac12 100644 --- a/bin/settler/src/tcs_start.rs +++ b/bin/settler/src/tcs_start.rs @@ -113,14 +113,18 @@ impl State { } // Clear newly created token positions, so the liqor account is mostly empty - for token_index in startable_chunk.iter().map(|(_, _, ti)| *ti).unique() { + let new_token_pos_indices = startable_chunk + .iter() + .map(|(_, _, ti)| *ti) + .unique() + .collect_vec(); + for token_index in new_token_pos_indices { let mint = mango_client.context.token(token_index).mint; - instructions.append(mango_client.token_withdraw_instructions( - &liqor_account, - mint, - u64::MAX, - false, - )?); + let ix = mango_client + .token_withdraw_instructions(&liqor_account, mint, u64::MAX, false) + .await?; + + instructions.append(ix) } let txsig = match mango_client diff --git a/lib/client/src/account_fetcher.rs b/lib/client/src/account_fetcher.rs index 6c1273a0f..9b769a56c 100644 --- a/lib/client/src/account_fetcher.rs +++ b/lib/client/src/account_fetcher.rs @@ -11,10 +11,14 @@ use anchor_lang::AccountDeserialize; use solana_client::nonblocking::rpc_client::RpcClient as RpcClientAsync; use solana_sdk::account::{AccountSharedData, ReadableAccount}; +use solana_sdk::hash::Hash; +use solana_sdk::hash::Hasher; use solana_sdk::pubkey::Pubkey; use mango_v4::state::MangoAccountValue; +use crate::gpa; + #[async_trait::async_trait] pub trait AccountFetcher: Sync + Send { async fn fetch_raw_account(&self, address: &Pubkey) -> anyhow::Result; @@ -29,6 +33,13 @@ pub trait AccountFetcher: Sync + Send { program: &Pubkey, discriminator: [u8; 8], ) -> anyhow::Result>; + + async fn fetch_multiple_accounts( + &self, + keys: &[Pubkey], + ) -> anyhow::Result>; + + async fn get_slot(&self) -> anyhow::Result; } // Can't be in the trait, since then it would no longer be object-safe... @@ -100,6 +111,17 @@ impl AccountFetcher for RpcAccountFetcher { .map(|(pk, acc)| (pk, acc.into())) .collect::>()) } + + async fn fetch_multiple_accounts( + &self, + keys: &[Pubkey], + ) -> anyhow::Result> { + gpa::fetch_multiple_accounts(&self.rpc, keys).await + } + + async fn get_slot(&self) -> anyhow::Result { + Ok(self.rpc.get_slot().await?) + } } struct CoalescedAsyncJob { @@ -138,6 +160,8 @@ struct AccountCache { keys_for_program_and_discriminator: HashMap<(Pubkey, [u8; 8]), Vec>, account_jobs: CoalescedAsyncJob>, + multiple_accounts_jobs: + CoalescedAsyncJob>>, program_accounts_jobs: CoalescedAsyncJob<(Pubkey, [u8; 8]), anyhow::Result>>, } @@ -261,4 +285,62 @@ impl AccountFetcher for CachedAccountFetcher { )), } } + + async fn fetch_multiple_accounts( + &self, + keys: &[Pubkey], + ) -> anyhow::Result> { + let fetch_job = { + let mut cache = self.cache.lock().unwrap(); + let mut missing_keys: Vec = keys + .iter() + .filter(|k| !cache.accounts.contains_key(k)) + .cloned() + .collect(); + if missing_keys.len() == 0 { + return Ok(keys + .iter() + .map(|pk| (*pk, cache.accounts.get(&pk).unwrap().clone())) + .collect::>()); + } + + let self_copy = self.clone(); + missing_keys.sort(); + let mut hasher = Hasher::default(); + for key in missing_keys.iter() { + hasher.hash(key.as_ref()); + } + let job_key = hasher.result(); + cache + .multiple_accounts_jobs + .run_coalesced(job_key.clone(), async move { + let result = self_copy + .fetcher + .fetch_multiple_accounts(&missing_keys) + .await; + let mut cache = self_copy.cache.lock().unwrap(); + cache.multiple_accounts_jobs.remove(&job_key); + + if let Ok(results) = result.as_ref() { + for (key, account) in results { + cache.accounts.insert(*key, account.clone()); + } + } + result + }) + }; + + match fetch_job.get().await { + Ok(v) => Ok(v.clone()), + // Can't clone the stored error, so need to stringize it + Err(err) => Err(anyhow::format_err!( + "fetch error in CachedAccountFetcher: {:?}", + err + )), + } + } + + async fn get_slot(&self) -> anyhow::Result { + self.fetcher.get_slot().await + } } diff --git a/lib/client/src/chain_data_fetcher.rs b/lib/client/src/chain_data_fetcher.rs index 856453619..2fe9313c9 100644 --- a/lib/client/src/chain_data_fetcher.rs +++ b/lib/client/src/chain_data_fetcher.rs @@ -8,7 +8,10 @@ use anchor_lang::Discriminator; use fixed::types::I80F48; use mango_v4::accounts_zerocopy::{KeyedAccountSharedData, LoadZeroCopy}; -use mango_v4::state::{Bank, MangoAccount, MangoAccountValue, OracleAccountInfos}; +use mango_v4::state::{ + pyth_mainnet_sol_oracle, pyth_mainnet_usdc_oracle, Bank, MangoAccount, MangoAccountValue, + OracleAccountInfos, +}; use anyhow::Context; @@ -64,12 +67,34 @@ impl AccountFetcher { pub fn fetch_bank_and_price(&self, bank: &Pubkey) -> anyhow::Result<(Bank, I80F48)> { let bank: Bank = self.fetch(bank)?; - let oracle = self.fetch_raw(&bank.oracle)?; - let oracle_acc = &KeyedAccountSharedData::new(bank.oracle, oracle.into()); - let price = bank.oracle_price(&OracleAccountInfos::from_reader(oracle_acc), None)?; + let oracle_data = self.fetch_raw(&bank.oracle)?; + let oracle = &KeyedAccountSharedData::new(bank.oracle, oracle_data.into()); + + let fallback_opt = self.fetch_keyed_account_data(bank.fallback_oracle)?; + let sol_opt = self.fetch_keyed_account_data(pyth_mainnet_sol_oracle::ID)?; + let usdc_opt = self.fetch_keyed_account_data(pyth_mainnet_usdc_oracle::ID)?; + + let oracle_acc_infos = OracleAccountInfos { + oracle, + fallback_opt: fallback_opt.as_ref(), + usdc_opt: usdc_opt.as_ref(), + sol_opt: sol_opt.as_ref(), + }; + let price = bank.oracle_price(&oracle_acc_infos, None)?; Ok((bank, price)) } + #[inline(always)] + fn fetch_keyed_account_data( + &self, + key: Pubkey, + ) -> anyhow::Result> { + Ok(self + .fetch_raw(&key) + .ok() + .map(|data| KeyedAccountSharedData::new(key, data))) + } + pub fn fetch_bank_price(&self, bank: &Pubkey) -> anyhow::Result { self.fetch_bank_and_price(bank).map(|(_, p)| p) } @@ -217,4 +242,20 @@ impl crate::AccountFetcher for AccountFetcher { }) .collect::>()) } + + async fn fetch_multiple_accounts( + &self, + keys: &[Pubkey], + ) -> anyhow::Result> { + let chain_data = self.chain_data.read().unwrap(); + Ok(keys + .iter() + .map(|pk| (*pk, chain_data.account(pk).unwrap().account.clone())) + .collect::>()) + } + + async fn get_slot(&self) -> anyhow::Result { + let chain_data = self.chain_data.read().unwrap(); + Ok(chain_data.newest_processed_slot()) + } } diff --git a/lib/client/src/client.rs b/lib/client/src/client.rs index fd051d26e..2caa5fb19 100644 --- a/lib/client/src/client.rs +++ b/lib/client/src/client.rs @@ -18,11 +18,19 @@ use tracing::*; use mango_v4::accounts_ix::{Serum3OrderType, Serum3SelfTradeBehavior, Serum3Side}; use mango_v4::accounts_zerocopy::KeyedAccountSharedData; +use mango_v4::health::HealthCache; use mango_v4::state::{ Bank, Group, MangoAccountValue, OracleAccountInfos, PerpMarket, PerpMarketIndex, PlaceOrderType, SelfTradeBehavior, Serum3MarketIndex, Side, TokenIndex, INSURANCE_TOKEN_INDEX, }; +use crate::account_fetcher::*; +use crate::confirm_transaction::{wait_for_transaction_confirmation, RpcConfirmTransactionConfig}; +use crate::context::MangoGroupContext; +use crate::gpa::{fetch_anchor_account, fetch_mango_accounts}; +use crate::health_cache; +use crate::util::PreparedInstructions; +use crate::{jupiter, util}; use solana_address_lookup_table_program::state::AddressLookupTable; use solana_client::nonblocking::rpc_client::RpcClient as RpcClientAsync; use solana_client::rpc_client::SerializableTransaction; @@ -35,13 +43,6 @@ use solana_sdk::hash::Hash; use solana_sdk::signer::keypair; use solana_sdk::transaction::TransactionError; -use crate::account_fetcher::*; -use crate::confirm_transaction::{wait_for_transaction_confirmation, RpcConfirmTransactionConfig}; -use crate::context::MangoGroupContext; -use crate::gpa::{fetch_anchor_account, fetch_mango_accounts}; -use crate::util::PreparedInstructions; -use crate::{jupiter, util}; - use anyhow::Context; use solana_sdk::account::ReadableAccount; use solana_sdk::instruction::{AccountMeta, Instruction}; @@ -96,6 +97,10 @@ pub struct ClientConfig { #[builder(default = "\"\".into()")] pub jupiter_token: String, + /// Determines how fallback oracle accounts are provided to instructions. Defaults to Dynamic. + #[builder(default = "FallbackOracleConfig::Dynamic")] + pub fallback_oracle_config: FallbackOracleConfig, + /// If set, don't use `cluster` for sending transactions and send to all /// addresses configured here instead. #[builder(default = "None")] @@ -445,35 +450,65 @@ impl MangoClient { pub async fn derive_health_check_remaining_account_metas( &self, + account: &MangoAccountValue, affected_tokens: Vec, writable_banks: Vec, affected_perp_markets: Vec, ) -> anyhow::Result<(Vec, u32)> { - let account = self.mango_account().await?; + let fallback_contexts = self + .context + .derive_fallback_oracle_keys( + &self.client.config.fallback_oracle_config, + &*self.account_fetcher, + ) + .await?; self.context.derive_health_check_remaining_account_metas( &account, affected_tokens, writable_banks, affected_perp_markets, + fallback_contexts, ) } - pub async fn derive_liquidation_health_check_remaining_account_metas( + pub async fn derive_health_check_remaining_account_metas_two_accounts( &self, - liqee: &MangoAccountValue, + account_1: &MangoAccountValue, + account_2: &MangoAccountValue, affected_tokens: &[TokenIndex], writable_banks: &[TokenIndex], ) -> anyhow::Result<(Vec, u32)> { - let account = self.mango_account().await?; + let fallback_contexts = self + .context + .derive_fallback_oracle_keys( + &self.client.config.fallback_oracle_config, + &*self.account_fetcher, + ) + .await?; + self.context .derive_health_check_remaining_account_metas_two_accounts( - &account, - liqee, + account_1, + account_2, affected_tokens, writable_banks, + fallback_contexts, ) } + pub async fn health_cache( + &self, + mango_account: &MangoAccountValue, + ) -> anyhow::Result { + health_cache::new( + &self.context, + &self.client.config.fallback_oracle_config, + &*self.account_fetcher, + mango_account, + ) + .await + } + pub async fn token_deposit( &self, mint: Pubkey, @@ -482,9 +517,15 @@ impl MangoClient { ) -> anyhow::Result { let token = self.context.token_by_mint(&mint)?; let token_index = token.token_index; + let mango_account = &self.mango_account().await?; let (health_check_metas, health_cu) = self - .derive_health_check_remaining_account_metas(vec![token_index], vec![], vec![]) + .derive_health_check_remaining_account_metas( + mango_account, + vec![token_index], + vec![], + vec![], + ) .await?; let ixs = PreparedInstructions::from_single( @@ -521,7 +562,7 @@ impl MangoClient { /// Creates token withdraw instructions for the MangoClient's account/owner. /// The `account` state is passed in separately so changes during the tx can be /// accounted for when deriving health accounts. - pub fn token_withdraw_instructions( + pub async fn token_withdraw_instructions( &self, account: &MangoAccountValue, mint: Pubkey, @@ -531,13 +572,9 @@ impl MangoClient { let token = self.context.token_by_mint(&mint)?; let token_index = token.token_index; - let (health_check_metas, health_cu) = - self.context.derive_health_check_remaining_account_metas( - account, - vec![token_index], - vec![], - vec![], - )?; + let (health_check_metas, health_cu) = self + .derive_health_check_remaining_account_metas(account, vec![token_index], vec![], vec![]) + .await?; let ixs = PreparedInstructions::from_vec( vec![ @@ -587,7 +624,9 @@ impl MangoClient { allow_borrow: bool, ) -> anyhow::Result { let account = self.mango_account().await?; - let ixs = self.token_withdraw_instructions(&account, mint, amount, allow_borrow)?; + let ixs = self + .token_withdraw_instructions(&account, mint, amount, allow_borrow) + .await?; self.send_and_confirm_owner_tx(ixs.to_instructions()).await } @@ -667,7 +706,7 @@ impl MangoClient { } #[allow(clippy::too_many_arguments)] - pub fn serum3_place_order_instruction( + pub async fn serum3_place_order_instruction( &self, account: &MangoAccountValue, market_index: Serum3MarketIndex, @@ -689,8 +728,8 @@ impl MangoClient { .open_orders; let (health_check_metas, health_cu) = self - .context - .derive_health_check_remaining_account_metas(account, vec![], vec![], vec![])?; + .derive_health_check_remaining_account_metas(account, vec![], vec![], vec![]) + .await?; let payer_token = match side { Serum3Side::Bid => "e, @@ -762,18 +801,20 @@ impl MangoClient { ) -> anyhow::Result { let account = self.mango_account().await?; let market_index = self.context.serum3_market_index(name); - let ixs = self.serum3_place_order_instruction( - &account, - market_index, - side, - limit_price, - max_base_qty, - max_native_quote_qty_including_fees, - self_trade_behavior, - order_type, - client_order_id, - limit, - )?; + let ixs = self + .serum3_place_order_instruction( + &account, + market_index, + side, + limit_price, + max_base_qty, + max_native_quote_qty_including_fees, + self_trade_behavior, + order_type, + client_order_id, + limit, + ) + .await?; self.send_and_confirm_owner_tx(ixs.to_instructions()).await } @@ -899,10 +940,9 @@ impl MangoClient { let s3 = self.context.serum3(market_index); let base = self.context.serum3_base_token(market_index); let quote = self.context.serum3_quote_token(market_index); - let (health_remaining_ams, health_cu) = self - .context .derive_health_check_remaining_account_metas(liqee.1, vec![], vec![], vec![]) + .await .unwrap(); let limit = 5; @@ -990,7 +1030,7 @@ impl MangoClient { // #[allow(clippy::too_many_arguments)] - pub fn perp_place_order_instruction( + pub async fn perp_place_order_instruction( &self, account: &MangoAccountValue, market_index: PerpMarketIndex, @@ -1006,13 +1046,14 @@ impl MangoClient { self_trade_behavior: SelfTradeBehavior, ) -> anyhow::Result { let perp = self.context.perp(market_index); - let (health_remaining_metas, health_cu) = - self.context.derive_health_check_remaining_account_metas( + let (health_remaining_metas, health_cu) = self + .derive_health_check_remaining_account_metas( account, vec![], vec![], vec![market_index], - )?; + ) + .await?; let ixs = PreparedInstructions::from_single( Instruction { @@ -1072,20 +1113,22 @@ impl MangoClient { self_trade_behavior: SelfTradeBehavior, ) -> anyhow::Result { let account = self.mango_account().await?; - let ixs = self.perp_place_order_instruction( - &account, - market_index, - side, - price_lots, - max_base_lots, - max_quote_lots, - client_order_id, - order_type, - reduce_only, - expiry_timestamp, - limit, - self_trade_behavior, - )?; + let ixs = self + .perp_place_order_instruction( + &account, + market_index, + side, + price_lots, + max_base_lots, + max_quote_lots, + client_order_id, + order_type, + reduce_only, + expiry_timestamp, + limit, + self_trade_behavior, + ) + .await?; self.send_and_confirm_owner_tx(ixs.to_instructions()).await } @@ -1127,9 +1170,10 @@ impl MangoClient { market_index: PerpMarketIndex, ) -> anyhow::Result { let perp = self.context.perp(market_index); + let mango_account = &self.mango_account().await?; let (health_check_metas, health_cu) = self - .derive_health_check_remaining_account_metas(vec![], vec![], vec![]) + .derive_health_check_remaining_account_metas(mango_account, vec![], vec![], vec![]) .await?; let ixs = PreparedInstructions::from_single( @@ -1157,7 +1201,7 @@ impl MangoClient { self.send_and_confirm_owner_tx(ixs.to_instructions()).await } - pub fn perp_settle_pnl_instruction( + pub async fn perp_settle_pnl_instruction( &self, market_index: PerpMarketIndex, account_a: (&Pubkey, &MangoAccountValue), @@ -1167,13 +1211,13 @@ impl MangoClient { let settlement_token = self.context.token(perp.settle_token_index); let (health_remaining_ams, health_cu) = self - .context .derive_health_check_remaining_account_metas_two_accounts( account_a.1, account_b.1, &[], &[], ) + .await .unwrap(); let ixs = PreparedInstructions::from_single( @@ -1210,7 +1254,9 @@ impl MangoClient { account_a: (&Pubkey, &MangoAccountValue), account_b: (&Pubkey, &MangoAccountValue), ) -> anyhow::Result { - let ixs = self.perp_settle_pnl_instruction(market_index, account_a, account_b)?; + let ixs = self + .perp_settle_pnl_instruction(market_index, account_a, account_b) + .await?; self.send_and_confirm_permissionless_tx(ixs.to_instructions()) .await } @@ -1223,8 +1269,8 @@ impl MangoClient { let perp = self.context.perp(market_index); let (health_remaining_ams, health_cu) = self - .context .derive_health_check_remaining_account_metas(liqee.1, vec![], vec![], vec![]) + .await .unwrap(); let limit = 5; @@ -1265,9 +1311,15 @@ impl MangoClient { ) -> anyhow::Result { let perp = self.context.perp(market_index); let settle_token_info = self.context.token(perp.settle_token_index); + let mango_account = &self.mango_account().await?; let (health_remaining_ams, health_cu) = self - .derive_liquidation_health_check_remaining_account_metas(liqee.1, &[], &[]) + .derive_health_check_remaining_account_metas_two_accounts( + mango_account, + liqee.1, + &[], + &[], + ) .await .unwrap(); @@ -1316,12 +1368,14 @@ impl MangoClient { ) .await?; + let mango_account = &self.mango_account().await?; let perp = self.context.perp(market_index); let settle_token_info = self.context.token(perp.settle_token_index); let insurance_token_info = self.context.token(INSURANCE_TOKEN_INDEX); let (health_remaining_ams, health_cu) = self - .derive_liquidation_health_check_remaining_account_metas( + .derive_health_check_remaining_account_metas_two_accounts( + mango_account, liqee.1, &[INSURANCE_TOKEN_INDEX], &[], @@ -1375,8 +1429,10 @@ impl MangoClient { liab_token_index: TokenIndex, max_liab_transfer: I80F48, ) -> anyhow::Result { + let mango_account = &self.mango_account().await?; let (health_remaining_ams, health_cu) = self - .derive_liquidation_health_check_remaining_account_metas( + .derive_health_check_remaining_account_metas_two_accounts( + mango_account, liqee.1, &[], &[asset_token_index, liab_token_index], @@ -1417,6 +1473,7 @@ impl MangoClient { liab_token_index: TokenIndex, max_liab_transfer: I80F48, ) -> anyhow::Result { + let mango_account = &self.mango_account().await?; let quote_token_index = 0; let quote_info = self.context.token(quote_token_index); @@ -1429,7 +1486,8 @@ impl MangoClient { .collect::>(); let (health_remaining_ams, health_cu) = self - .derive_liquidation_health_check_remaining_account_metas( + .derive_health_check_remaining_account_metas_two_accounts( + mango_account, liqee.1, &[INSURANCE_TOKEN_INDEX], &[quote_token_index, liab_token_index], @@ -1483,6 +1541,7 @@ impl MangoClient { min_taker_price: f32, extra_affected_tokens: &[TokenIndex], ) -> anyhow::Result { + let mango_account = &self.mango_account().await?; let (tcs_index, tcs) = liqee .1 .token_conditional_swap_by_id(token_conditional_swap_id)?; @@ -1493,7 +1552,8 @@ impl MangoClient { .copied() .collect_vec(); let (health_remaining_ams, health_cu) = self - .derive_liquidation_health_check_remaining_account_metas( + .derive_health_check_remaining_account_metas_two_accounts( + mango_account, liqee.1, &affected_tokens, &[tcs.buy_token_index, tcs.sell_token_index], @@ -1538,13 +1598,19 @@ impl MangoClient { account: (&Pubkey, &MangoAccountValue), token_conditional_swap_id: u64, ) -> anyhow::Result { + let mango_account = &self.mango_account().await?; let (tcs_index, tcs) = account .1 .token_conditional_swap_by_id(token_conditional_swap_id)?; let affected_tokens = vec![tcs.buy_token_index, tcs.sell_token_index]; let (health_remaining_ams, health_cu) = self - .derive_health_check_remaining_account_metas(vec![], affected_tokens, vec![]) + .derive_health_check_remaining_account_metas( + mango_account, + vec![], + affected_tokens, + vec![], + ) .await .unwrap(); @@ -1578,20 +1644,21 @@ impl MangoClient { // health region - pub fn health_region_begin_instruction( + pub async fn health_region_begin_instruction( &self, account: &MangoAccountValue, affected_tokens: Vec, writable_banks: Vec, affected_perp_markets: Vec, ) -> anyhow::Result { - let (health_remaining_metas, _health_cu) = - self.context.derive_health_check_remaining_account_metas( + let (health_remaining_metas, _health_cu) = self + .derive_health_check_remaining_account_metas( account, affected_tokens, writable_banks, affected_perp_markets, - )?; + ) + .await?; let ix = Instruction { program_id: mango_v4::id(), @@ -1617,20 +1684,21 @@ impl MangoClient { )) } - pub fn health_region_end_instruction( + pub async fn health_region_end_instruction( &self, account: &MangoAccountValue, affected_tokens: Vec, writable_banks: Vec, affected_perp_markets: Vec, ) -> anyhow::Result { - let (health_remaining_metas, health_cu) = - self.context.derive_health_check_remaining_account_metas( + let (health_remaining_metas, health_cu) = self + .derive_health_check_remaining_account_metas( account, affected_tokens, writable_banks, affected_perp_markets, - )?; + ) + .await?; let ix = Instruction { program_id: mango_v4::id(), @@ -1857,6 +1925,23 @@ impl TransactionSize { } } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FallbackOracleConfig { + /// No fallback oracles + Never, + /// Only provided fallback oracles are used + Fixed(Vec), + /// The account_fetcher checks for stale oracles and uses fallbacks only for stale oracles + Dynamic, + /// Every possible fallback oracle (may cause serious issues with the 64 accounts-per-tx limit) + All, +} +impl Default for FallbackOracleConfig { + fn default() -> Self { + FallbackOracleConfig::Dynamic + } +} + #[derive(Copy, Clone, Debug, Default)] pub struct TransactionBuilderConfig { /// adds a SetComputeUnitPrice instruction in front if none exists diff --git a/lib/client/src/context.rs b/lib/client/src/context.rs index fca1afcf5..3b8267b67 100644 --- a/lib/client/src/context.rs +++ b/lib/client/src/context.rs @@ -4,15 +4,20 @@ use anchor_client::ClientError; use anchor_lang::__private::bytemuck; -use mango_v4::state::{ - Group, MangoAccountValue, PerpMarketIndex, Serum3MarketIndex, TokenIndex, MAX_BANKS, +use mango_v4::{ + accounts_zerocopy::{KeyedAccountReader, KeyedAccountSharedData}, + state::{ + determine_oracle_type, load_whirlpool_state, oracle_state_unchecked, Group, + MangoAccountValue, OracleAccountInfos, OracleConfig, OracleConfigParams, OracleType, + PerpMarketIndex, Serum3MarketIndex, TokenIndex, MAX_BANKS, + }, }; use fixed::types::I80F48; use futures::{stream, StreamExt, TryStreamExt}; use itertools::Itertools; -use crate::gpa::*; +use crate::{gpa::*, AccountFetcher, FallbackOracleConfig}; use solana_client::nonblocking::rpc_client::RpcClient as RpcClientAsync; use solana_sdk::account::Account; @@ -28,9 +33,10 @@ pub struct TokenContext { pub oracle: Pubkey, pub banks: [Pubkey; MAX_BANKS], pub vaults: [Pubkey; MAX_BANKS], - pub fallback_oracle: Pubkey, + pub fallback_context: FallbackOracleContext, pub mint_info_address: Pubkey, pub decimals: u8, + pub oracle_config: OracleConfig, } impl TokenContext { @@ -56,6 +62,18 @@ impl TokenContext { } } +#[derive(Clone, PartialEq, Eq)] +pub struct FallbackOracleContext { + pub key: Pubkey, + // only used for CLMM fallback oracles, otherwise Pubkey::default + pub quote_key: Pubkey, +} +impl FallbackOracleContext { + pub fn keys(&self) -> Vec { + vec![self.key, self.quote_key] + } +} + #[derive(Clone, PartialEq, Eq)] pub struct Serum3MarketContext { pub address: Pubkey, @@ -101,6 +119,7 @@ pub struct ComputeEstimates { pub cu_per_serum3_order_cancel: u32, pub cu_per_perp_order_match: u32, pub cu_per_perp_order_cancel: u32, + pub cu_per_oracle_fallback: u32, } impl Default for ComputeEstimates { @@ -118,25 +137,36 @@ impl Default for ComputeEstimates { cu_per_perp_order_match: 7_000, // measured around 3.5k, see test_perp_compute cu_per_perp_order_cancel: 7_000, + // measured around 2k, see test_health_compute_tokens_fallback_oracles + cu_per_oracle_fallback: 2000, } } } impl ComputeEstimates { - pub fn health_for_counts(&self, tokens: usize, perps: usize, serums: usize) -> u32 { + pub fn health_for_counts( + &self, + tokens: usize, + perps: usize, + serums: usize, + fallbacks: usize, + ) -> u32 { let tokens: u32 = tokens.try_into().unwrap(); let perps: u32 = perps.try_into().unwrap(); let serums: u32 = serums.try_into().unwrap(); + let fallbacks: u32 = fallbacks.try_into().unwrap(); tokens * self.health_cu_per_token + perps * self.health_cu_per_perp + serums * self.health_cu_per_serum + + fallbacks * self.cu_per_oracle_fallback } - pub fn health_for_account(&self, account: &MangoAccountValue) -> u32 { + pub fn health_for_account(&self, account: &MangoAccountValue, num_fallbacks: usize) -> u32 { self.health_for_counts( account.active_token_positions().count(), account.active_perp_positions().count(), account.active_serum3_orders().count(), + num_fallbacks, ) } } @@ -227,8 +257,12 @@ impl MangoGroupContext { decimals: u8::MAX, banks: mi.banks, vaults: mi.vaults, - fallback_oracle: mi.fallback_oracle, oracle: mi.oracle, + fallback_context: FallbackOracleContext { + key: mi.fallback_oracle, + quote_key: Pubkey::default(), + }, + oracle_config: OracleConfigParams::default().to_oracle_config(), group: mi.group, mint: mi.mint, }, @@ -236,14 +270,23 @@ impl MangoGroupContext { }) .collect::>(); - // reading the banks is only needed for the token names and decimals + // reading the banks is only needed for the token names, decimals and oracle configs // FUTURE: either store the names on MintInfo as well, or maybe don't store them at all // because they are in metaplex? let bank_tuples = fetch_banks(rpc, program, group).await?; - for (_, bank) in bank_tuples { + let fallback_keys: Vec = bank_tuples + .iter() + .map(|tup| tup.1.fallback_oracle) + .collect(); + let fallback_oracle_accounts = fetch_multiple_accounts(rpc, &fallback_keys[..]).await?; + for (index, (_, bank)) in bank_tuples.iter().enumerate() { let token = tokens.get_mut(&bank.token_index).unwrap(); token.name = bank.name().into(); token.decimals = bank.mint_decimals; + token.oracle_config = bank.oracle_config; + let (key, acc_info) = fallback_oracle_accounts[index].clone(); + token.fallback_context.quote_key = + get_fallback_quote_key(&KeyedAccountSharedData::new(key, acc_info)); } assert!(tokens.values().all(|t| t.decimals != u8::MAX)); @@ -357,6 +400,7 @@ impl MangoGroupContext { affected_tokens: Vec, writable_banks: Vec, affected_perp_markets: Vec, + fallback_contexts: HashMap, ) -> anyhow::Result<(Vec, u32)> { let mut account = account.clone(); for affected_token_index in affected_tokens.iter().chain(writable_banks.iter()) { @@ -370,6 +414,7 @@ impl MangoGroupContext { // figure out all the banks/oracles that need to be passed for the health check let mut banks = vec![]; let mut oracles = vec![]; + let mut fallbacks = vec![]; for position in account.active_token_positions() { let token = self.token(position.token_index); banks.push(( @@ -377,6 +422,9 @@ impl MangoGroupContext { writable_banks.iter().any(|&ti| ti == position.token_index), )); oracles.push(token.oracle); + if let Some(fallback_context) = fallback_contexts.get(&token.oracle) { + fallbacks.extend(fallback_context.keys()); + } } let serum_oos = account.active_serum3_orders().map(|&s| s.open_orders); @@ -386,6 +434,14 @@ impl MangoGroupContext { let perp_oracles = account .active_perp_positions() .map(|&pa| self.perp(pa.market_index).oracle); + // FUTURE: implement fallback oracles for perps + + let fallback_oracles: Vec = fallbacks + .into_iter() + .unique() + .filter(|key| !oracles.contains(key) && key != &Pubkey::default()) + .collect(); + let fallbacks_len = fallback_oracles.len(); let to_account_meta = |pubkey| AccountMeta { pubkey, @@ -404,9 +460,12 @@ impl MangoGroupContext { .chain(perp_markets.map(to_account_meta)) .chain(perp_oracles.map(to_account_meta)) .chain(serum_oos.map(to_account_meta)) + .chain(fallback_oracles.into_iter().map(to_account_meta)) .collect(); - let cu = self.compute_estimates.health_for_account(&account); + let cu = self + .compute_estimates + .health_for_account(&account, fallbacks_len); Ok((accounts, cu)) } @@ -417,10 +476,12 @@ impl MangoGroupContext { account2: &MangoAccountValue, affected_tokens: &[TokenIndex], writable_banks: &[TokenIndex], + fallback_contexts: HashMap, ) -> anyhow::Result<(Vec, u32)> { // figure out all the banks/oracles that need to be passed for the health check let mut banks = vec![]; let mut oracles = vec![]; + let mut fallbacks = vec![]; let token_indexes = account2 .active_token_positions() @@ -434,6 +495,9 @@ impl MangoGroupContext { let writable_bank = writable_banks.iter().contains(&token_index); banks.push((token.first_bank(), writable_bank)); oracles.push(token.oracle); + if let Some(fallback_context) = fallback_contexts.get(&token.oracle) { + fallbacks.extend(fallback_context.keys()); + } } let serum_oos = account2 @@ -452,6 +516,14 @@ impl MangoGroupContext { let perp_oracles = perp_market_indexes .iter() .map(|&index| self.perp(index).oracle); + // FUTURE: implement fallback oracles for perps + + let fallback_oracles: Vec = fallbacks + .into_iter() + .unique() + .filter(|key| !oracles.contains(key) && key != &Pubkey::default()) + .collect(); + let fallbacks_len = fallback_oracles.len(); let to_account_meta = |pubkey| AccountMeta { pubkey, @@ -470,6 +542,7 @@ impl MangoGroupContext { .chain(perp_markets.map(to_account_meta)) .chain(perp_oracles.map(to_account_meta)) .chain(serum_oos.map(to_account_meta)) + .chain(fallback_oracles.into_iter().map(to_account_meta)) .collect(); // Since health is likely to be computed separately for both accounts, we don't use the @@ -490,10 +563,12 @@ impl MangoGroupContext { account1_token_count, account1.active_perp_positions().count(), account1.active_serum3_orders().count(), + fallbacks_len, ) + self.compute_estimates.health_for_counts( account2_token_count, account2.active_perp_positions().count(), account2.active_serum3_orders().count(), + fallbacks_len, ); Ok((accounts, cu)) @@ -554,6 +629,61 @@ impl MangoGroupContext { let new_perp_markets = fetch_perp_markets(rpc, mango_v4::id(), self.group).await?; Ok(new_perp_markets.len() > self.perp_markets.len()) } + + /// Returns a map of oracle pubkey -> FallbackOracleContext + pub async fn derive_fallback_oracle_keys( + &self, + fallback_oracle_config: &FallbackOracleConfig, + account_fetcher: &dyn AccountFetcher, + ) -> anyhow::Result> { + // FUTURE: implement for perp oracles as well + let fallbacks_by_oracle = match fallback_oracle_config { + FallbackOracleConfig::Never => HashMap::new(), + FallbackOracleConfig::Fixed(keys) => self + .tokens + .iter() + .filter(|token| { + token.1.fallback_context.key != Pubkey::default() + && keys.contains(&token.1.fallback_context.key) + }) + .map(|t| (t.1.oracle, t.1.fallback_context.clone())) + .collect(), + FallbackOracleConfig::All => self + .tokens + .iter() + .filter(|token| token.1.fallback_context.key != Pubkey::default()) + .map(|t| (t.1.oracle, t.1.fallback_context.clone())) + .collect(), + FallbackOracleConfig::Dynamic => { + let tokens_by_oracle: HashMap = + self.tokens.iter().map(|t| (t.1.oracle, t.1)).collect(); + let oracle_keys: Vec = + tokens_by_oracle.values().map(|b| b.oracle).collect(); + let oracle_accounts = account_fetcher + .fetch_multiple_accounts(&oracle_keys) + .await?; + let now_slot = account_fetcher.get_slot().await?; + + let mut stale_oracles_with_fallbacks = vec![]; + for (key, acc) in oracle_accounts { + let token = tokens_by_oracle.get(&key).unwrap(); + let state = oracle_state_unchecked( + &OracleAccountInfos::from_reader(&KeyedAccountSharedData::new(key, acc)), + token.decimals, + )?; + let oracle_is_valid = state + .check_confidence_and_maybe_staleness(&token.oracle_config, Some(now_slot)); + if oracle_is_valid.is_err() && token.fallback_context.key != Pubkey::default() { + stale_oracles_with_fallbacks + .push((token.oracle, token.fallback_context.clone())); + } + } + stale_oracles_with_fallbacks.into_iter().collect() + } + }; + + Ok(fallbacks_by_oracle) + } } fn from_serum_style_pubkey(d: [u64; 4]) -> Pubkey { @@ -567,3 +697,22 @@ async fn fetch_raw_account(rpc: &RpcClientAsync, address: Pubkey) -> Result Pubkey { + let maybe_key = match determine_oracle_type(acc_info).ok() { + Some(oracle_type) => match oracle_type { + OracleType::OrcaCLMM => match load_whirlpool_state(acc_info).ok() { + Some(whirlpool) => whirlpool.get_quote_oracle().ok(), + None => None, + }, + _ => None, + }, + None => None, + }; + + maybe_key.unwrap_or_else(|| Pubkey::default()) +} diff --git a/lib/client/src/gpa.rs b/lib/client/src/gpa.rs index 5dbd1106c..e96aa5418 100644 --- a/lib/client/src/gpa.rs +++ b/lib/client/src/gpa.rs @@ -1,11 +1,11 @@ use anchor_lang::{AccountDeserialize, Discriminator}; - use mango_v4::state::{Bank, MangoAccount, MangoAccountValue, MintInfo, PerpMarket, Serum3Market}; use solana_account_decoder::UiAccountEncoding; use solana_client::nonblocking::rpc_client::RpcClient as RpcClientAsync; use solana_client::rpc_config::{RpcAccountInfoConfig, RpcProgramAccountsConfig}; use solana_client::rpc_filter::{Memcmp, RpcFilterType}; +use solana_sdk::account::AccountSharedData; use solana_sdk::pubkey::Pubkey; pub async fn fetch_mango_accounts( @@ -129,3 +129,22 @@ pub async fn fetch_perp_markets( ) .await } + +pub async fn fetch_multiple_accounts( + rpc: &RpcClientAsync, + keys: &[Pubkey], +) -> anyhow::Result> { + let config = RpcAccountInfoConfig { + encoding: Some(UiAccountEncoding::Base64), + ..RpcAccountInfoConfig::default() + }; + Ok(rpc + .get_multiple_accounts_with_config(keys, config) + .await? + .value + .into_iter() + .zip(keys.iter()) + .filter(|(maybe_acc, _)| maybe_acc.is_some()) + .map(|(acc, key)| (*key, acc.unwrap().into())) + .collect()) +} diff --git a/lib/client/src/health_cache.rs b/lib/client/src/health_cache.rs index 14716fe51..47a176f54 100644 --- a/lib/client/src/health_cache.rs +++ b/lib/client/src/health_cache.rs @@ -1,22 +1,32 @@ -use crate::{AccountFetcher, MangoGroupContext}; +use crate::{AccountFetcher, FallbackOracleConfig, MangoGroupContext}; use anyhow::Context; use futures::{stream, StreamExt, TryStreamExt}; use mango_v4::accounts_zerocopy::KeyedAccountSharedData; use mango_v4::health::{FixedOrderAccountRetriever, HealthCache}; -use mango_v4::state::MangoAccountValue; +use mango_v4::state::{pyth_mainnet_sol_oracle, pyth_mainnet_usdc_oracle, MangoAccountValue}; +use std::collections::HashMap; use std::time::{SystemTime, UNIX_EPOCH}; pub async fn new( context: &MangoGroupContext, - account_fetcher: &impl AccountFetcher, + fallback_config: &FallbackOracleConfig, + account_fetcher: &dyn AccountFetcher, account: &MangoAccountValue, ) -> anyhow::Result { let active_token_len = account.active_token_positions().count(); let active_perp_len = account.active_perp_positions().count(); - let (metas, _health_cu) = - context.derive_health_check_remaining_account_metas(account, vec![], vec![], vec![])?; + let fallback_keys = context + .derive_fallback_oracle_keys(fallback_config, account_fetcher) + .await?; + let (metas, _health_cu) = context.derive_health_check_remaining_account_metas( + account, + vec![], + vec![], + vec![], + fallback_keys, + )?; let accounts: anyhow::Result> = stream::iter(metas.iter()) .then(|meta| async { Ok(KeyedAccountSharedData::new( @@ -34,9 +44,13 @@ pub async fn new( begin_perp: active_token_len * 2, begin_serum3: active_token_len * 2 + active_perp_len * 2, staleness_slot: None, - begin_fallback_oracles: metas.len(), // TODO: add support for fallback oracle accounts - usd_oracle_index: None, - sol_oracle_index: None, + begin_fallback_oracles: metas.len(), + usdc_oracle_index: metas + .iter() + .position(|m| m.pubkey == pyth_mainnet_usdc_oracle::ID), + sol_oracle_index: metas + .iter() + .position(|m| m.pubkey == pyth_mainnet_sol_oracle::ID), }; let now_ts = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); mango_v4::health::new_health_cache(&account.borrow(), &retriever, now_ts) @@ -51,8 +65,13 @@ pub fn new_sync( let active_token_len = account.active_token_positions().count(); let active_perp_len = account.active_perp_positions().count(); - let (metas, _health_cu) = - context.derive_health_check_remaining_account_metas(account, vec![], vec![], vec![])?; + let (metas, _health_cu) = context.derive_health_check_remaining_account_metas( + account, + vec![], + vec![], + vec![], + HashMap::new(), + )?; let accounts = metas .iter() .map(|meta| { @@ -70,8 +89,8 @@ pub fn new_sync( begin_perp: active_token_len * 2, begin_serum3: active_token_len * 2 + active_perp_len * 2, staleness_slot: None, - begin_fallback_oracles: metas.len(), // TODO: add support for fallback oracle accounts - usd_oracle_index: None, + begin_fallback_oracles: metas.len(), + usdc_oracle_index: None, sol_oracle_index: None, }; let now_ts = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); diff --git a/lib/client/src/jupiter/v4.rs b/lib/client/src/jupiter/v4.rs index 770ddf3e9..85bbb6eea 100644 --- a/lib/client/src/jupiter/v4.rs +++ b/lib/client/src/jupiter/v4.rs @@ -228,6 +228,7 @@ impl<'a> JupiterV4<'a> { .collect::>(); let owner = self.mango_client.owner(); + let account = &self.mango_client.mango_account().await?; let token_ams = [source_token.mint, target_token.mint] .into_iter() @@ -252,6 +253,7 @@ impl<'a> JupiterV4<'a> { let (health_ams, _health_cu) = self .mango_client .derive_health_check_remaining_account_metas( + account, vec![source_token.token_index, target_token.token_index], vec![source_token.token_index, target_token.token_index], vec![], diff --git a/lib/client/src/jupiter/v6.rs b/lib/client/src/jupiter/v6.rs index 09ccd6cf1..1d79371d9 100644 --- a/lib/client/src/jupiter/v6.rs +++ b/lib/client/src/jupiter/v6.rs @@ -237,6 +237,7 @@ impl<'a> JupiterV6<'a> { .collect::>(); let owner = self.mango_client.owner(); + let account = &self.mango_client.mango_account().await?; let token_ams = [source_token.mint, target_token.mint] .into_iter() @@ -259,6 +260,7 @@ impl<'a> JupiterV6<'a> { let (health_ams, _health_cu) = self .mango_client .derive_health_check_remaining_account_metas( + account, vec![source_token.token_index, target_token.token_index], vec![source_token.token_index, target_token.token_index], vec![], diff --git a/lib/client/src/perp_pnl.rs b/lib/client/src/perp_pnl.rs index 86bd3de33..7d76f8918 100644 --- a/lib/client/src/perp_pnl.rs +++ b/lib/client/src/perp_pnl.rs @@ -17,6 +17,7 @@ pub enum Direction { /// Note: keep in sync with perp.ts:getSettlePnlCandidates pub async fn fetch_top( context: &crate::context::MangoGroupContext, + fallback_config: &FallbackOracleConfig, account_fetcher: &impl AccountFetcher, perp_market_index: PerpMarketIndex, direction: Direction, @@ -91,9 +92,10 @@ pub async fn fetch_top( } else { I80F48::ZERO }; - let perp_max_settle = crate::health_cache::new(context, account_fetcher, &acc) - .await? - .perp_max_settle(perp_market.settle_token_index)?; + let perp_max_settle = + crate::health_cache::new(context, fallback_config, account_fetcher, &acc) + .await? + .perp_max_settle(perp_market.settle_token_index)?; let settleable_pnl = if perp_max_settle > 0 { (*pnl).max(-perp_max_settle) } else { diff --git a/programs/mango-v4/src/health/account_retriever.rs b/programs/mango-v4/src/health/account_retriever.rs index 8296bc2af..27bc0f14d 100644 --- a/programs/mango-v4/src/health/account_retriever.rs +++ b/programs/mango-v4/src/health/account_retriever.rs @@ -59,7 +59,7 @@ pub struct FixedOrderAccountRetriever { pub begin_serum3: usize, pub staleness_slot: Option, pub begin_fallback_oracles: usize, - pub usd_oracle_index: Option, + pub usdc_oracle_index: Option, pub sol_oracle_index: Option, } @@ -78,7 +78,7 @@ pub fn new_fixed_order_account_retriever<'a, 'info>( ais.len(), expected_ais, active_token_len, active_token_len, active_perp_len, active_perp_len, active_serum3_len ); - let usd_oracle_index = ais[..] + let usdc_oracle_index = ais[..] .iter() .position(|o| o.key == &pyth_mainnet_usdc_oracle::ID); let sol_oracle_index = ais[..] @@ -93,7 +93,7 @@ pub fn new_fixed_order_account_retriever<'a, 'info>( begin_serum3: active_token_len * 2 + active_perp_len * 2, staleness_slot: Some(Clock::get()?.slot), begin_fallback_oracles: expected_ais, - usd_oracle_index, + usdc_oracle_index, sol_oracle_index, }) } @@ -139,7 +139,7 @@ impl FixedOrderAccountRetriever { OracleAccountInfos { oracle, fallback_opt, - usd_opt: self.usd_oracle_index.map(|i| &self.ais[i]), + usdc_opt: self.usdc_oracle_index.map(|i| &self.ais[i]), sol_opt: self.sol_oracle_index.map(|i| &self.ais[i]), } } @@ -324,7 +324,7 @@ impl<'a, 'info> ScannedBanksAndOracles<'a, 'info> { OracleAccountInfos { oracle, fallback_opt, - usd_opt: self.usd_oracle_index.map(|i| &self.fallback_oracles[i]), + usdc_opt: self.usd_oracle_index.map(|i| &self.fallback_oracles[i]), sol_opt: self.sol_oracle_index.map(|i| &self.fallback_oracles[i]), } } diff --git a/programs/mango-v4/src/state/oracle.rs b/programs/mango-v4/src/state/oracle.rs index f69e2d9fc..fc4106941 100644 --- a/programs/mango-v4/src/state/oracle.rs +++ b/programs/mango-v4/src/state/oracle.rs @@ -82,7 +82,7 @@ pub mod sol_mint_mainnet { } #[zero_copy] -#[derive(AnchorDeserialize, AnchorSerialize, Derivative)] +#[derive(AnchorDeserialize, AnchorSerialize, Derivative, PartialEq, Eq)] #[derivative(Debug)] pub struct OracleConfig { pub conf_filter: I80F48, @@ -94,7 +94,7 @@ const_assert_eq!(size_of::(), 16 + 8 + 72); const_assert_eq!(size_of::(), 96); const_assert_eq!(size_of::() % 8, 0); -#[derive(AnchorDeserialize, AnchorSerialize, Debug)] +#[derive(AnchorDeserialize, AnchorSerialize, Debug, Default)] pub struct OracleConfigParams { pub conf_filter: f32, pub max_staleness_slots: Option, @@ -278,7 +278,7 @@ fn get_pyth_state( pub struct OracleAccountInfos<'a, T: KeyedAccountReader> { pub oracle: &'a T, pub fallback_opt: Option<&'a T>, - pub usd_opt: Option<&'a T>, + pub usdc_opt: Option<&'a T>, pub sol_opt: Option<&'a T>, } @@ -287,7 +287,7 @@ impl<'a, T: KeyedAccountReader> OracleAccountInfos<'a, T> { OracleAccountInfos { oracle: acc_reader, fallback_opt: None, - usd_opt: None, + usdc_opt: None, sol_opt: None, } } @@ -406,9 +406,7 @@ fn oracle_state_unchecked_inner( OracleType::OrcaCLMM => { let whirlpool = load_whirlpool_state(oracle_info)?; - let inverted = whirlpool.token_mint_a == usdc_mint_mainnet::ID - || (whirlpool.token_mint_a == sol_mint_mainnet::ID - && whirlpool.token_mint_b != usdc_mint_mainnet::ID); + let inverted = whirlpool.is_inverted(); let quote_state = if inverted { quote_state_unchecked(acc_infos, &whirlpool.token_mint_a)? } else { @@ -441,7 +439,7 @@ fn quote_state_unchecked( ) -> Result { if quote_mint == &usdc_mint_mainnet::ID { let usd_feed = acc_infos - .usd_opt + .usdc_opt .ok_or_else(|| error!(MangoError::MissingFeedForCLMMOracle))?; let usd_state = get_pyth_state(usd_feed, QUOTE_DECIMALS as u8)?; return Ok(usd_state); @@ -590,13 +588,13 @@ mod tests { let usdc_ais = OracleAccountInfos { oracle: usdc_ai, fallback_opt: None, - usd_opt: None, + usdc_opt: None, sol_opt: None, }; let orca_ais = OracleAccountInfos { oracle: ai, fallback_opt: None, - usd_opt: Some(usdc_ai), + usdc_opt: Some(usdc_ai), sol_opt: None, }; let usdc = oracle_state_unchecked(&usdc_ais, usdc_decimals).unwrap(); @@ -635,7 +633,7 @@ mod tests { let oracle_infos = OracleAccountInfos { oracle: ai, fallback_opt: None, - usd_opt: None, + usdc_opt: None, sol_opt: None, }; assert!(oracle_state_unchecked(&oracle_infos, base_decimals) diff --git a/programs/mango-v4/src/state/orca_cpi.rs b/programs/mango-v4/src/state/orca_cpi.rs index 19c14d870..f33f7ff0b 100644 --- a/programs/mango-v4/src/state/orca_cpi.rs +++ b/programs/mango-v4/src/state/orca_cpi.rs @@ -3,6 +3,10 @@ use solana_program::pubkey::Pubkey; use crate::{accounts_zerocopy::KeyedAccountReader, error::MangoError}; +use super::{ + pyth_mainnet_sol_oracle, pyth_mainnet_usdc_oracle, sol_mint_mainnet, usdc_mint_mainnet, +}; + pub mod orca_mainnet_whirlpool { use solana_program::declare_id; declare_id!("whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc"); @@ -18,6 +22,30 @@ pub struct WhirlpoolState { pub token_mint_b: Pubkey, // 32 } +impl WhirlpoolState { + pub fn is_inverted(&self) -> bool { + self.token_mint_a == usdc_mint_mainnet::ID + || (self.token_mint_a == sol_mint_mainnet::ID + && self.token_mint_b != usdc_mint_mainnet::ID) + } + + pub fn get_quote_oracle(&self) -> Result { + let mint = if self.is_inverted() { + self.token_mint_a + } else { + self.token_mint_b + }; + + if mint == usdc_mint_mainnet::ID { + return Ok(pyth_mainnet_usdc_oracle::ID); + } else if mint == sol_mint_mainnet::ID { + return Ok(pyth_mainnet_sol_oracle::ID); + } else { + return Err(MangoError::MissingFeedForCLMMOracle.into()); + } + } +} + pub fn load_whirlpool_state(acc_info: &impl KeyedAccountReader) -> Result { let data = &acc_info.data(); require!( diff --git a/programs/mango-v4/tests/cases/test_health_compute.rs b/programs/mango-v4/tests/cases/test_health_compute.rs index 68e6c9d38..091205042 100644 --- a/programs/mango-v4/tests/cases/test_health_compute.rs +++ b/programs/mango-v4/tests/cases/test_health_compute.rs @@ -335,7 +335,7 @@ async fn test_health_compute_tokens_fallback_oracles() -> Result<(), TransportEr println!("average success increase: {avg_success_increase}"); println!("average failure increase: {avg_failure_increase}"); assert!(avg_success_increase < 2_050); - assert!(avg_success_increase < 18_500); + assert!(avg_failure_increase < 19_500); Ok(()) } From fcc8c85f6e11788dc76a261c9421c66318f47e2c Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Fri, 26 Jan 2024 10:24:20 +0100 Subject: [PATCH 07/42] perp: Add platform liquidation fee for base (#858) --- mango_v4.json | 95 ++++++++- .../src/instructions/perp_create_market.rs | 5 +- .../src/instructions/perp_edit_market.rs | 11 + .../perp_liq_base_or_positive_pnl.rs | 87 +++++--- programs/mango-v4/src/lib.rs | 4 + programs/mango-v4/src/logs.rs | 15 ++ programs/mango-v4/src/state/bank.rs | 2 +- programs/mango-v4/src/state/perp_market.rs | 18 +- .../test_liq_perps_base_and_bankruptcy.rs | 69 +++++-- .../tests/program_test/mango_client.rs | 3 + ts/client/src/accounts/perp.ts | 13 ++ ts/client/src/client.ts | 3 + ts/client/src/clientIxParamBuilder.ts | 2 + ts/client/src/mango_v4.ts | 190 +++++++++++++++++- 14 files changed, 458 insertions(+), 59 deletions(-) diff --git a/mango_v4.json b/mango_v4.json index 328c40b4d..27495ca7d 100644 --- a/mango_v4.json +++ b/mango_v4.json @@ -3953,6 +3953,10 @@ { "name": "positivePnlLiquidationFee", "type": "f32" + }, + { + "name": "platformLiquidationFee", + "type": "f32" } ] }, @@ -4167,6 +4171,12 @@ "type": { "option": "bool" } + }, + { + "name": "platformLiquidationFeeOpt", + "type": { + "option": "f32" + } } ] }, @@ -7495,7 +7505,7 @@ { "name": "collectedLiquidationFees", "docs": [ - "Fees that were collected during liquidation (in native tokens)", + "Platform fees that were collected during liquidation (in native tokens)", "", "See also collected_fees_native and fees_withdrawn." ], @@ -8562,12 +8572,33 @@ "name": "feesWithdrawn", "type": "u64" }, + { + "name": "platformLiquidationFee", + "docs": [ + "Additional to liquidation_fee, but goes to the group owner instead of the liqor" + ], + "type": { + "defined": "I80F48" + } + }, + { + "name": "accruedLiquidationFees", + "docs": [ + "Platform fees that were accrued during liquidation (in native tokens)", + "", + "These fees are also added to fees_accrued, this is just for bookkeeping the total", + "liquidation fees that happened. So never decreases (different to fees_accrued)." + ], + "type": { + "defined": "I80F48" + } + }, { "name": "reserved", "type": { "array": [ "u8", - 1880 + 1848 ] } } @@ -12593,6 +12624,66 @@ } ] }, + { + "name": "PerpLiqBaseOrPositivePnlLogV2", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "perpMarketIndex", + "type": "u16", + "index": false + }, + { + "name": "liqor", + "type": "publicKey", + "index": false + }, + { + "name": "liqee", + "type": "publicKey", + "index": false + }, + { + "name": "baseTransferLiqee", + "type": "i64", + "index": false + }, + { + "name": "quoteTransferLiqee", + "type": "i128", + "index": false + }, + { + "name": "quoteTransferLiqor", + "type": "i128", + "index": false + }, + { + "name": "quotePlatformFee", + "type": "i128", + "index": false + }, + { + "name": "pnlTransfer", + "type": "i128", + "index": false + }, + { + "name": "pnlSettleLimitTransfer", + "type": "i128", + "index": false + }, + { + "name": "price", + "type": "i128", + "index": false + } + ] + }, { "name": "PerpLiqBankruptcyLog", "fields": [ diff --git a/programs/mango-v4/src/instructions/perp_create_market.rs b/programs/mango-v4/src/instructions/perp_create_market.rs index 1b81a5d8c..4415688dc 100644 --- a/programs/mango-v4/src/instructions/perp_create_market.rs +++ b/programs/mango-v4/src/instructions/perp_create_market.rs @@ -39,6 +39,7 @@ pub fn perp_create_market( settle_pnl_limit_factor: f32, settle_pnl_limit_window_size_ts: u64, positive_pnl_liquidation_fee: f32, + platform_liquidation_fee: f32, ) -> Result<()> { let now_ts: u64 = Clock::get()?.unix_timestamp.try_into().unwrap(); @@ -92,7 +93,9 @@ pub fn perp_create_market( init_overall_asset_weight: I80F48::from_num(init_overall_asset_weight), positive_pnl_liquidation_fee: I80F48::from_num(positive_pnl_liquidation_fee), fees_withdrawn: 0, - reserved: [0; 1880], + platform_liquidation_fee: I80F48::from_num(platform_liquidation_fee), + accrued_liquidation_fees: I80F48::ZERO, + reserved: [0; 1848], }; let oracle_ref = &AccountInfoRef::borrow(ctx.accounts.oracle.as_ref())?; diff --git a/programs/mango-v4/src/instructions/perp_edit_market.rs b/programs/mango-v4/src/instructions/perp_edit_market.rs index 96ffe9096..821f7ab53 100644 --- a/programs/mango-v4/src/instructions/perp_edit_market.rs +++ b/programs/mango-v4/src/instructions/perp_edit_market.rs @@ -39,6 +39,7 @@ pub fn perp_edit_market( positive_pnl_liquidation_fee_opt: Option, name_opt: Option, force_close_opt: Option, + platform_liquidation_fee_opt: Option, ) -> Result<()> { let group = ctx.accounts.group.load()?; @@ -345,6 +346,16 @@ pub fn perp_edit_market( require_group_admin = true; }; + if let Some(platform_liquidation_fee) = platform_liquidation_fee_opt { + msg!( + "Platform liquidation fee: old - {:?}, new - {:?}", + perp_market.platform_liquidation_fee, + platform_liquidation_fee + ); + perp_market.platform_liquidation_fee = I80F48::from_num(platform_liquidation_fee); + require_group_admin = true; + }; + // account constraint #1 if require_group_admin { require!( diff --git a/programs/mango-v4/src/instructions/perp_liq_base_or_positive_pnl.rs b/programs/mango-v4/src/instructions/perp_liq_base_or_positive_pnl.rs index 1b8433c0e..246ddd9bf 100644 --- a/programs/mango-v4/src/instructions/perp_liq_base_or_positive_pnl.rs +++ b/programs/mango-v4/src/instructions/perp_liq_base_or_positive_pnl.rs @@ -8,7 +8,7 @@ use crate::health::*; use crate::state::*; use crate::accounts_ix::*; -use crate::logs::{emit_perp_balances, emit_stack, PerpLiqBaseOrPositivePnlLog, TokenBalanceLog}; +use crate::logs::{emit_perp_balances, emit_stack, PerpLiqBaseOrPositivePnlLogV2, TokenBalanceLog}; /// This instruction deals with increasing health by: /// - reducing the liqee's base position @@ -94,18 +94,24 @@ pub fn perp_liq_base_or_positive_pnl( // // Perform the liquidation // - let (base_transfer, quote_transfer, pnl_transfer, pnl_settle_limit_transfer) = - liquidation_action( - &mut perp_market, - &mut settle_bank, - &mut liqor.borrow_mut(), - &mut liqee.borrow_mut(), - &mut liqee_health_cache, - liqee_liq_end_health, - now_ts, - max_base_transfer, - max_pnl_transfer, - )?; + let ( + base_transfer, + quote_transfer_liqee, + quote_transfer_liqor, + platform_fee, + pnl_transfer, + pnl_settle_limit_transfer, + ) = liquidation_action( + &mut perp_market, + &mut settle_bank, + &mut liqor.borrow_mut(), + &mut liqee.borrow_mut(), + &mut liqee_health_cache, + liqee_liq_end_health, + now_ts, + max_base_transfer, + max_pnl_transfer, + )?; // // Log changes @@ -152,13 +158,15 @@ pub fn perp_liq_base_or_positive_pnl( } if base_transfer != 0 || pnl_transfer != 0 { - emit_stack(PerpLiqBaseOrPositivePnlLog { + emit_stack(PerpLiqBaseOrPositivePnlLogV2 { mango_group: ctx.accounts.group.key(), perp_market_index: perp_market.perp_market_index, liqor: ctx.accounts.liqor.key(), liqee: ctx.accounts.liqee.key(), - base_transfer, - quote_transfer: quote_transfer.to_bits(), + base_transfer_liqee: base_transfer, + quote_transfer_liqee: quote_transfer_liqee.to_bits(), + quote_transfer_liqor: quote_transfer_liqor.to_bits(), + quote_platform_fee: platform_fee.to_bits(), pnl_transfer: pnl_transfer.to_bits(), pnl_settle_limit_transfer: pnl_settle_limit_transfer.to_bits(), price: oracle_price.to_bits(), @@ -207,7 +215,7 @@ pub(crate) fn liquidation_action( now_ts: u64, max_base_transfer: i64, max_pnl_transfer: u64, -) -> Result<(i64, I80F48, I80F48, I80F48)> { +) -> Result<(i64, I80F48, I80F48, I80F48, I80F48, I80F48)> { let liq_end_type = HealthType::LiquidationEnd; let perp_market_index = perp_market.perp_market_index; @@ -279,7 +287,8 @@ pub(crate) fn liquidation_action( let direction: i64; // Either 1+fee or 1-fee, depending on direction. - let base_fee_factor; + let base_fee_factor_liqor; + let base_fee_factor_all; if liqee_base_lots > 0 { require_msg!( @@ -288,11 +297,12 @@ pub(crate) fn liquidation_action( ); // the health_unsettled_pnl gets reduced by `base * base_price * perp_init_asset_weight` - // and increased by `base * base_price * (1 - liq_fee)` + // and increased by `base * base_price * (1 - liq_fees)` direction = -1; - base_fee_factor = I80F48::ONE - perp_market.base_liquidation_fee; + base_fee_factor_liqor = I80F48::ONE - perp_market.base_liquidation_fee; + base_fee_factor_all = base_fee_factor_liqor - perp_market.platform_liquidation_fee; uhupnl_per_lot = - oracle_price_per_lot * (-perp_market.init_base_asset_weight + base_fee_factor); + oracle_price_per_lot * (-perp_market.init_base_asset_weight + base_fee_factor_all); } else { // liqee_base_lots <= 0 require_msg!( @@ -301,11 +311,12 @@ pub(crate) fn liquidation_action( ); // health gets increased by `base * base_price * perp_init_liab_weight` - // and reduced by `base * base_price * (1 + liq_fee)` + // and reduced by `base * base_price * (1 + liq_fees)` direction = 1; - base_fee_factor = I80F48::ONE + perp_market.base_liquidation_fee; + base_fee_factor_liqor = I80F48::ONE + perp_market.base_liquidation_fee; + base_fee_factor_all = base_fee_factor_liqor + perp_market.platform_liquidation_fee; uhupnl_per_lot = - oracle_price_per_lot * (perp_market.init_base_liab_weight - base_fee_factor); + oracle_price_per_lot * (perp_market.init_base_liab_weight - base_fee_factor_all); }; assert!(uhupnl_per_lot > 0); @@ -537,17 +548,28 @@ pub(crate) fn liquidation_action( // assert!(base_reduction <= liqee_base_lots.abs()); let base_transfer = direction * base_reduction; - let quote_transfer = -I80F48::from(base_transfer) * oracle_price_per_lot * base_fee_factor; + let quote_transfer_base = -I80F48::from(base_transfer) * oracle_price_per_lot; + let quote_transfer_liqee = quote_transfer_base * base_fee_factor_all; + let quote_transfer_liqor = -quote_transfer_base * base_fee_factor_liqor; if base_transfer != 0 { msg!( "transfering: {} base lots and {} quote", base_transfer, - quote_transfer + quote_transfer_liqee ); - liqee_perp_position.record_trade(perp_market, base_transfer, quote_transfer); - liqor_perp_position.record_trade(perp_market, -base_transfer, -quote_transfer); + liqee_perp_position.record_trade(perp_market, base_transfer, quote_transfer_liqee); + liqor_perp_position.record_trade(perp_market, -base_transfer, quote_transfer_liqor); } + // We know that this is positive: + // liq a long: base_transfer < 0, quote_transfer_base > 0, base_fee_factor < 1 + // and -q_t_liqor >= q_t_liqee (both sides positive; take more from the liqor than we give to the liqee) + // liq a short: base_transfer > 0, quote_transfer_base < 0, base_fee_factor > 1 + // and -q_t_liqor >= q_t_liqee (both sides negative; we take more from the liqee than we give to the liqor) + let platform_fee = (-quote_transfer_liqor - quote_transfer_liqee).max(I80F48::ZERO); + perp_market.fees_accrued += platform_fee; + perp_market.accrued_liquidation_fees += platform_fee; + // // Let the liqor take over positive pnl until the account health is positive, // but only while the health_unsettled_pnl is positive (otherwise it would decrease liqee health!) @@ -607,7 +629,14 @@ pub(crate) fn liquidation_action( let liqee_perp_position = liqee.perp_position_mut(perp_market_index)?; liqee_health_cache.recompute_perp_info(liqee_perp_position, &perp_market)?; - Ok((base_transfer, quote_transfer, pnl_transfer, limit_transfer)) + Ok(( + base_transfer, + quote_transfer_liqee, + quote_transfer_liqor, + platform_fee, + pnl_transfer, + limit_transfer, + )) } #[cfg(test)] diff --git a/programs/mango-v4/src/lib.rs b/programs/mango-v4/src/lib.rs index b6dcd3e3e..bc5deb976 100644 --- a/programs/mango-v4/src/lib.rs +++ b/programs/mango-v4/src/lib.rs @@ -841,6 +841,7 @@ pub mod mango_v4 { settle_pnl_limit_factor: f32, settle_pnl_limit_window_size_ts: u64, positive_pnl_liquidation_fee: f32, + platform_liquidation_fee: f32, ) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::perp_create_market( @@ -872,6 +873,7 @@ pub mod mango_v4 { settle_pnl_limit_factor, settle_pnl_limit_window_size_ts, positive_pnl_liquidation_fee, + platform_liquidation_fee, )?; Ok(()) } @@ -909,6 +911,7 @@ pub mod mango_v4 { positive_pnl_liquidation_fee_opt: Option, name_opt: Option, force_close_opt: Option, + platform_liquidation_fee_opt: Option, ) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::perp_edit_market( @@ -943,6 +946,7 @@ pub mod mango_v4 { positive_pnl_liquidation_fee_opt, name_opt, force_close_opt, + platform_liquidation_fee_opt, )?; Ok(()) } diff --git a/programs/mango-v4/src/logs.rs b/programs/mango-v4/src/logs.rs index d02f99773..d920e04d6 100644 --- a/programs/mango-v4/src/logs.rs +++ b/programs/mango-v4/src/logs.rs @@ -512,6 +512,21 @@ pub struct PerpLiqBaseOrPositivePnlLog { pub price: i128, } +#[event] +pub struct PerpLiqBaseOrPositivePnlLogV2 { + pub mango_group: Pubkey, + pub perp_market_index: u16, + pub liqor: Pubkey, + pub liqee: Pubkey, + pub base_transfer_liqee: i64, + pub quote_transfer_liqee: i128, + pub quote_transfer_liqor: i128, + pub quote_platform_fee: i128, + pub pnl_transfer: i128, + pub pnl_settle_limit_transfer: i128, + pub price: i128, +} + #[event] pub struct PerpLiqBankruptcyLog { pub mango_group: Pubkey, diff --git a/programs/mango-v4/src/state/bank.rs b/programs/mango-v4/src/state/bank.rs index 73658e6bc..d2e4b7afc 100644 --- a/programs/mango-v4/src/state/bank.rs +++ b/programs/mango-v4/src/state/bank.rs @@ -212,7 +212,7 @@ pub struct Bank { /// Additional to liquidation_fee, but goes to the group owner instead of the liqor pub platform_liquidation_fee: I80F48, - /// Fees that were collected during liquidation (in native tokens) + /// Platform fees that were collected during liquidation (in native tokens) /// /// See also collected_fees_native and fees_withdrawn. pub collected_liquidation_fees: I80F48, diff --git a/programs/mango-v4/src/state/perp_market.rs b/programs/mango-v4/src/state/perp_market.rs index 849299d77..2b1c795a3 100644 --- a/programs/mango-v4/src/state/perp_market.rs +++ b/programs/mango-v4/src/state/perp_market.rs @@ -189,8 +189,17 @@ pub struct PerpMarket { // This ensures that fees_settled is strictly increasing for stats gathering purposes pub fees_withdrawn: u64, + /// Additional to liquidation_fee, but goes to the group owner instead of the liqor + pub platform_liquidation_fee: I80F48, + + /// Platform fees that were accrued during liquidation (in native tokens) + /// + /// These fees are also added to fees_accrued, this is just for bookkeeping the total + /// liquidation fees that happened. So never decreases (different to fees_accrued). + pub accrued_liquidation_fees: I80F48, + #[derivative(Debug = "ignore")] - pub reserved: [u8; 1880], + pub reserved: [u8; 1848], } const_assert_eq!( @@ -227,7 +236,8 @@ const_assert_eq!( + 7 + 3 * 16 + 8 - + 1880 + + 2 * 16 + + 1848 ); const_assert_eq!(size_of::(), 2808); const_assert_eq!(size_of::() % 8, 0); @@ -525,7 +535,9 @@ impl PerpMarket { init_overall_asset_weight: I80F48::ONE, positive_pnl_liquidation_fee: I80F48::ZERO, fees_withdrawn: 0, - reserved: [0; 1880], + platform_liquidation_fee: I80F48::ZERO, + accrued_liquidation_fees: I80F48::ZERO, + reserved: [0; 1848], } } } diff --git a/programs/mango-v4/tests/cases/test_liq_perps_base_and_bankruptcy.rs b/programs/mango-v4/tests/cases/test_liq_perps_base_and_bankruptcy.rs index b4ff495dd..9381e9249 100644 --- a/programs/mango-v4/tests/cases/test_liq_perps_base_and_bankruptcy.rs +++ b/programs/mango-v4/tests/cases/test_liq_perps_base_and_bankruptcy.rs @@ -88,7 +88,8 @@ async fn test_liq_perps_base_and_bankruptcy() -> Result<(), TransportError> { init_base_asset_weight: 0.6, maint_base_liab_weight: 1.3, init_base_liab_weight: 1.4, - base_liquidation_fee: 0.05, + base_liquidation_fee: 0.03, + platform_liquidation_fee: 0.02, maker_fee: 0.0, taker_fee: 0.0, group_insurance_fund: true, @@ -196,6 +197,7 @@ async fn test_liq_perps_base_and_bankruptcy() -> Result<(), TransportError> { // // TEST: Liquidate base position with limit // + let perp_market_before = solana.get_account::(perp_market).await; send_tx( solana, PerpLiqBaseOrPositivePnlInstruction { @@ -209,29 +211,41 @@ async fn test_liq_perps_base_and_bankruptcy() -> Result<(), TransportError> { ) .await .unwrap(); + let perp_market_after = solana.get_account::(perp_market).await; - let liq_amount = 10.0 * 100.0 * 0.6 * (1.0 - 0.05); + let liqor_amount = 10.0 * 100.0 * 0.6 * (1.0 - 0.03); + let liqee_amount = 10.0 * 100.0 * 0.6 * (1.0 - 0.05); let liqor_data = solana.get_account::(liqor).await; assert_eq!(liqor_data.perps[0].base_position_lots(), 10); assert!(assert_equal( liqor_data.perps[0].quote_position_native(), - -liq_amount, + -liqor_amount, 0.1 )); let liqee_data = solana.get_account::(account_0).await; assert_eq!(liqee_data.perps[0].base_position_lots(), 10); assert!(assert_equal( liqee_data.perps[0].quote_position_native(), - -20.0 * 100.0 + liq_amount, + -20.0 * 100.0 + liqee_amount, 0.1 )); assert!(assert_equal( liqee_data.perps[0].realized_trade_pnl_native, - liq_amount - 1000.0, + liqee_amount - 1000.0, 0.1 )); // stable price is 1.0, so 0.2 * 1000 assert_eq!(liqee_data.perps[0].settle_pnl_limit_realized_trade, -201); + assert!(assert_equal( + perp_market_after.fees_accrued - perp_market_before.fees_accrued, + liqor_amount - liqee_amount, + 0.1, + )); + assert!(assert_equal( + perp_market_after.accrued_liquidation_fees - perp_market_before.accrued_liquidation_fees, + liqor_amount - liqee_amount, + 0.1, + )); // // TEST: Liquidate base position max @@ -250,19 +264,20 @@ async fn test_liq_perps_base_and_bankruptcy() -> Result<(), TransportError> { .await .unwrap(); - let liq_amount_2 = 6.0 * 100.0 * 0.6 * (1.0 - 0.05); + let liqor_amount_2 = 6.0 * 100.0 * 0.6 * (1.0 - 0.03); + let liqee_amount_2 = 6.0 * 100.0 * 0.6 * (1.0 - 0.05); let liqor_data = solana.get_account::(liqor).await; assert_eq!(liqor_data.perps[0].base_position_lots(), 10 + 6); assert!(assert_equal( liqor_data.perps[0].quote_position_native(), - -liq_amount - liq_amount_2, + -liqor_amount - liqor_amount_2, 0.1 )); let liqee_data = solana.get_account::(account_0).await; assert_eq!(liqee_data.perps[0].base_position_lots(), 4); assert!(assert_equal( liqee_data.perps[0].quote_position_native(), - -20.0 * 100.0 + liq_amount + liq_amount_2, + -20.0 * 100.0 + liqee_amount + liqee_amount_2, 0.1 )); @@ -304,6 +319,7 @@ async fn test_liq_perps_base_and_bankruptcy() -> Result<(), TransportError> { // // TEST: Liquidate base position // + let perp_market_before = solana.get_account::(perp_market).await; send_tx( solana, PerpLiqBaseOrPositivePnlInstruction { @@ -317,22 +333,34 @@ async fn test_liq_perps_base_and_bankruptcy() -> Result<(), TransportError> { ) .await .unwrap(); + let perp_market_after = solana.get_account::(perp_market).await; - let liq_amount_3 = 10.0 * 100.0 * 1.32 * (1.0 + 0.05); + let liqor_amount_3 = 10.0 * 100.0 * 1.32 * (1.0 + 0.03); + let liqee_amount_3 = 10.0 * 100.0 * 1.32 * (1.0 + 0.05); let liqor_data = solana.get_account::(liqor).await; assert_eq!(liqor_data.perps[0].base_position_lots(), 16 - 10); assert!(assert_equal( liqor_data.perps[0].quote_position_native(), - -liq_amount - liq_amount_2 + liq_amount_3, + -liqor_amount - liqor_amount_2 + liqor_amount_3, 0.1 )); let liqee_data = solana.get_account::(account_1).await; assert_eq!(liqee_data.perps[0].base_position_lots(), -10); assert!(assert_equal( liqee_data.perps[0].quote_position_native(), - 20.0 * 100.0 - liq_amount_3, + 20.0 * 100.0 - liqee_amount_3, 0.1 )); + assert!(assert_equal( + perp_market_after.fees_accrued - perp_market_before.fees_accrued, + liqee_amount_3 - liqor_amount_3, + 0.1, + )); + assert!(assert_equal( + perp_market_after.accrued_liquidation_fees - perp_market_before.accrued_liquidation_fees, + liqee_amount_3 - liqor_amount_3, + 0.1, + )); // // TEST: Liquidate base position max @@ -351,19 +379,20 @@ async fn test_liq_perps_base_and_bankruptcy() -> Result<(), TransportError> { .await .unwrap(); - let liq_amount_4 = 7.0 * 100.0 * 1.32 * (1.0 + 0.05); + let liqor_amount_4 = 7.0 * 100.0 * 1.32 * (1.0 + 0.03); + let liqee_amount_4 = 7.0 * 100.0 * 1.32 * (1.0 + 0.05); let liqor_data = solana.get_account::(liqor).await; assert_eq!(liqor_data.perps[0].base_position_lots(), 6 - 7); assert!(assert_equal( liqor_data.perps[0].quote_position_native(), - -liq_amount - liq_amount_2 + liq_amount_3 + liq_amount_4, + -liqor_amount - liqor_amount_2 + liqor_amount_3 + liqor_amount_4, 0.1 )); let liqee_data = solana.get_account::(account_1).await; assert_eq!(liqee_data.perps[0].base_position_lots(), -3); assert!(assert_equal( liqee_data.perps[0].quote_position_native(), - 20.0 * 100.0 - liq_amount_3 - liq_amount_4, + 20.0 * 100.0 - liqee_amount_3 - liqee_amount_4, 0.1 )); @@ -405,19 +434,20 @@ async fn test_liq_perps_base_and_bankruptcy() -> Result<(), TransportError> { .await .unwrap(); - let liq_amount_5 = 3.0 * 100.0 * 2.0 * (1.0 + 0.05); + let liqor_amount_5 = 3.0 * 100.0 * 2.0 * (1.0 + 0.03); + let liqee_amount_5 = 3.0 * 100.0 * 2.0 * (1.0 + 0.05); let liqor_data = solana.get_account::(liqor).await; assert_eq!(liqor_data.perps[0].base_position_lots(), -1 - 3); assert!(assert_equal( liqor_data.perps[0].quote_position_native(), - -liq_amount - liq_amount_2 + liq_amount_3 + liq_amount_4 + liq_amount_5, + -liqor_amount - liqor_amount_2 + liqor_amount_3 + liqor_amount_4 + liqor_amount_5, 0.1 )); let liqee_data = solana.get_account::(account_1).await; assert_eq!(liqee_data.perps[0].base_position_lots(), 0); assert!(assert_equal( liqee_data.perps[0].quote_position_native(), - 20.0 * 100.0 - liq_amount_3 - liq_amount_4 - liq_amount_5, + 20.0 * 100.0 - liqee_amount_3 - liqee_amount_4 - liqee_amount_5, 0.1 )); @@ -446,7 +476,8 @@ async fn test_liq_perps_base_and_bankruptcy() -> Result<(), TransportError> { let liqee_quote_deposits_before: f64 = 1329.0; // the liqor's settle limit means we can't settle everything let settle_amount = liqee_quote_deposits_before.min(liqor_max_settle as f64); - let remaining_pnl = 20.0 * 100.0 - liq_amount_3 - liq_amount_4 - liq_amount_5 + settle_amount; + let remaining_pnl = + 20.0 * 100.0 - liqee_amount_3 - liqee_amount_4 - liqee_amount_5 + settle_amount; assert!(remaining_pnl < 0.0); let liqee_data = solana.get_account::(account_1).await; assert_eq!(liqee_data.perps[0].base_position_lots(), 0); @@ -531,7 +562,7 @@ async fn test_liq_perps_base_and_bankruptcy() -> Result<(), TransportError> { // the amount of perp quote transfered let liq_perp_quote_amount = - (insurance_vault_funding as f64) / 1.05 + (-liqee_settle_limit_before) as f64; + (insurance_vault_funding as f64) / 1.03 + (-liqee_settle_limit_before) as f64; // insurance fund was depleted and the liqor received it assert_eq!(solana.token_account_balance(insurance_vault).await, 0); diff --git a/programs/mango-v4/tests/program_test/mango_client.rs b/programs/mango-v4/tests/program_test/mango_client.rs index 6aa7853da..e0b5bcd13 100644 --- a/programs/mango-v4/tests/program_test/mango_client.rs +++ b/programs/mango-v4/tests/program_test/mango_client.rs @@ -3294,6 +3294,7 @@ pub struct PerpCreateMarketInstruction { pub settle_fee_fraction_low_health: f32, pub settle_pnl_limit_factor: f32, pub settle_pnl_limit_window_size_ts: u64, + pub platform_liquidation_fee: f32, } impl PerpCreateMarketInstruction { pub async fn with_new_book_and_queue( @@ -3356,6 +3357,7 @@ impl ClientInstruction for PerpCreateMarketInstruction { settle_pnl_limit_factor: self.settle_pnl_limit_factor, settle_pnl_limit_window_size_ts: self.settle_pnl_limit_window_size_ts, positive_pnl_liquidation_fee: self.positive_pnl_liquidation_fee, + platform_liquidation_fee: self.platform_liquidation_fee, }; let perp_market = Pubkey::find_program_address( @@ -3421,6 +3423,7 @@ fn perp_edit_instruction_default() -> mango_v4::instruction::PerpEditMarket { positive_pnl_liquidation_fee_opt: None, name_opt: None, force_close_opt: None, + platform_liquidation_fee_opt: None, } } diff --git a/ts/client/src/accounts/perp.ts b/ts/client/src/accounts/perp.ts index 797b35ba8..3c9c0fe5f 100644 --- a/ts/client/src/accounts/perp.ts +++ b/ts/client/src/accounts/perp.ts @@ -52,6 +52,8 @@ export class PerpMarket { public maintOverallAssetWeight: I80F48; public initOverallAssetWeight: I80F48; public positivePnlLiquidationFee: I80F48; + public platformLiquidationFee: I80F48; + public accruedLiquidationFees: I80F48; public _price: I80F48; public _uiPrice: number; @@ -112,6 +114,9 @@ export class PerpMarket { maintOverallAssetWeight: I80F48Dto; initOverallAssetWeight: I80F48Dto; positivePnlLiquidationFee: I80F48Dto; + feesWithdrawn: BN; + platformLiquidationFee: I80F48Dto; + accruedLiquidationFees: I80F48Dto; }, ): PerpMarket { return new PerpMarket( @@ -159,6 +164,9 @@ export class PerpMarket { obj.maintOverallAssetWeight, obj.initOverallAssetWeight, obj.positivePnlLiquidationFee, + obj.feesWithdrawn, + obj.platformLiquidationFee, + obj.accruedLiquidationFees, ); } @@ -207,6 +215,9 @@ export class PerpMarket { maintOverallAssetWeight: I80F48Dto, initOverallAssetWeight: I80F48Dto, positivePnlLiquidationFee: I80F48Dto, + public feesWithdrawn: BN, + platformLiquidationFee: I80F48Dto, + accruedLiquidationFees: I80F48Dto, ) { this.name = utf8.decode(new Uint8Array(name)).split('\x00')[0]; this.oracleConfig = { @@ -229,6 +240,8 @@ export class PerpMarket { this.maintOverallAssetWeight = I80F48.from(maintOverallAssetWeight); this.initOverallAssetWeight = I80F48.from(initOverallAssetWeight); this.positivePnlLiquidationFee = I80F48.from(positivePnlLiquidationFee); + this.platformLiquidationFee = I80F48.from(platformLiquidationFee); + this.accruedLiquidationFees = I80F48.from(accruedLiquidationFees); this.priceLotsToUiConverter = new Big(10) .pow(baseDecimals - QUOTE_DECIMALS) diff --git a/ts/client/src/client.ts b/ts/client/src/client.ts index ead319541..d6ad75491 100644 --- a/ts/client/src/client.ts +++ b/ts/client/src/client.ts @@ -2513,6 +2513,7 @@ export class MangoClient { settlePnlLimitFactor: number, settlePnlLimitWindowSize: number, positivePnlLiquidationFee: number, + platformLiquidationFee: number, ): Promise { const bids = new Keypair(); const asks = new Keypair(); @@ -2554,6 +2555,7 @@ export class MangoClient { settlePnlLimitFactor, new BN(settlePnlLimitWindowSize), positivePnlLiquidationFee, + platformLiquidationFee, ) .accounts({ group: group.publicKey, @@ -2649,6 +2651,7 @@ export class MangoClient { params.positivePnlLiquidationFee, params.name, params.forceClose, + params.platformLiquidationFee, ) .accounts({ group: group.publicKey, diff --git a/ts/client/src/clientIxParamBuilder.ts b/ts/client/src/clientIxParamBuilder.ts index d6e69d0bf..db1ffe1f8 100644 --- a/ts/client/src/clientIxParamBuilder.ts +++ b/ts/client/src/clientIxParamBuilder.ts @@ -185,6 +185,7 @@ export interface PerpEditParams { positivePnlLiquidationFee: number | null; name: string | null; forceClose: boolean | null; + platformLiquidationFee: number | null; } export const NullPerpEditParams: PerpEditParams = { @@ -218,6 +219,7 @@ export const NullPerpEditParams: PerpEditParams = { positivePnlLiquidationFee: null, name: null, forceClose: null, + platformLiquidationFee: null, }; // Use with TrueIxGateParams and buildIxGate diff --git a/ts/client/src/mango_v4.ts b/ts/client/src/mango_v4.ts index 3c77b6ea2..61243c5ca 100644 --- a/ts/client/src/mango_v4.ts +++ b/ts/client/src/mango_v4.ts @@ -3953,6 +3953,10 @@ export type MangoV4 = { { "name": "positivePnlLiquidationFee", "type": "f32" + }, + { + "name": "platformLiquidationFee", + "type": "f32" } ] }, @@ -4167,6 +4171,12 @@ export type MangoV4 = { "type": { "option": "bool" } + }, + { + "name": "platformLiquidationFeeOpt", + "type": { + "option": "f32" + } } ] }, @@ -7495,7 +7505,7 @@ export type MangoV4 = { { "name": "collectedLiquidationFees", "docs": [ - "Fees that were collected during liquidation (in native tokens)", + "Platform fees that were collected during liquidation (in native tokens)", "", "See also collected_fees_native and fees_withdrawn." ], @@ -8562,12 +8572,33 @@ export type MangoV4 = { "name": "feesWithdrawn", "type": "u64" }, + { + "name": "platformLiquidationFee", + "docs": [ + "Additional to liquidation_fee, but goes to the group owner instead of the liqor" + ], + "type": { + "defined": "I80F48" + } + }, + { + "name": "accruedLiquidationFees", + "docs": [ + "Platform fees that were accrued during liquidation (in native tokens)", + "", + "These fees are also added to fees_accrued, this is just for bookkeeping the total", + "liquidation fees that happened. So never decreases (different to fees_accrued)." + ], + "type": { + "defined": "I80F48" + } + }, { "name": "reserved", "type": { "array": [ "u8", - 1880 + 1848 ] } } @@ -12593,6 +12624,66 @@ export type MangoV4 = { } ] }, + { + "name": "PerpLiqBaseOrPositivePnlLogV2", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "perpMarketIndex", + "type": "u16", + "index": false + }, + { + "name": "liqor", + "type": "publicKey", + "index": false + }, + { + "name": "liqee", + "type": "publicKey", + "index": false + }, + { + "name": "baseTransferLiqee", + "type": "i64", + "index": false + }, + { + "name": "quoteTransferLiqee", + "type": "i128", + "index": false + }, + { + "name": "quoteTransferLiqor", + "type": "i128", + "index": false + }, + { + "name": "quotePlatformFee", + "type": "i128", + "index": false + }, + { + "name": "pnlTransfer", + "type": "i128", + "index": false + }, + { + "name": "pnlSettleLimitTransfer", + "type": "i128", + "index": false + }, + { + "name": "price", + "type": "i128", + "index": false + } + ] + }, { "name": "PerpLiqBankruptcyLog", "fields": [ @@ -17888,6 +17979,10 @@ export const IDL: MangoV4 = { { "name": "positivePnlLiquidationFee", "type": "f32" + }, + { + "name": "platformLiquidationFee", + "type": "f32" } ] }, @@ -18102,6 +18197,12 @@ export const IDL: MangoV4 = { "type": { "option": "bool" } + }, + { + "name": "platformLiquidationFeeOpt", + "type": { + "option": "f32" + } } ] }, @@ -21430,7 +21531,7 @@ export const IDL: MangoV4 = { { "name": "collectedLiquidationFees", "docs": [ - "Fees that were collected during liquidation (in native tokens)", + "Platform fees that were collected during liquidation (in native tokens)", "", "See also collected_fees_native and fees_withdrawn." ], @@ -22497,12 +22598,33 @@ export const IDL: MangoV4 = { "name": "feesWithdrawn", "type": "u64" }, + { + "name": "platformLiquidationFee", + "docs": [ + "Additional to liquidation_fee, but goes to the group owner instead of the liqor" + ], + "type": { + "defined": "I80F48" + } + }, + { + "name": "accruedLiquidationFees", + "docs": [ + "Platform fees that were accrued during liquidation (in native tokens)", + "", + "These fees are also added to fees_accrued, this is just for bookkeeping the total", + "liquidation fees that happened. So never decreases (different to fees_accrued)." + ], + "type": { + "defined": "I80F48" + } + }, { "name": "reserved", "type": { "array": [ "u8", - 1880 + 1848 ] } } @@ -26528,6 +26650,66 @@ export const IDL: MangoV4 = { } ] }, + { + "name": "PerpLiqBaseOrPositivePnlLogV2", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "perpMarketIndex", + "type": "u16", + "index": false + }, + { + "name": "liqor", + "type": "publicKey", + "index": false + }, + { + "name": "liqee", + "type": "publicKey", + "index": false + }, + { + "name": "baseTransferLiqee", + "type": "i64", + "index": false + }, + { + "name": "quoteTransferLiqee", + "type": "i128", + "index": false + }, + { + "name": "quoteTransferLiqor", + "type": "i128", + "index": false + }, + { + "name": "quotePlatformFee", + "type": "i128", + "index": false + }, + { + "name": "pnlTransfer", + "type": "i128", + "index": false + }, + { + "name": "pnlSettleLimitTransfer", + "type": "i128", + "index": false + }, + { + "name": "price", + "type": "i128", + "index": false + } + ] + }, { "name": "PerpLiqBankruptcyLog", "fields": [ From 5fc5646affe22178a4f9858355187acc8b8dac0d Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Fri, 26 Jan 2024 21:10:33 +0100 Subject: [PATCH 08/42] ts: Fix registering tokens and stub oracles (#859) --- ts/client/src/client.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ts/client/src/client.ts b/ts/client/src/client.ts index d6ad75491..157c18028 100644 --- a/ts/client/src/client.ts +++ b/ts/client/src/client.ts @@ -425,6 +425,7 @@ export class MangoClient { group: Group, mintPk: PublicKey, oraclePk: PublicKey, + fallbackOraclePk: PublicKey, tokenIndex: number, name: string, params: TokenRegisterParams, @@ -466,6 +467,7 @@ export class MangoClient { admin: (this.program.provider as AnchorProvider).wallet.publicKey, mint: mintPk, oracle: oraclePk, + fallbackOracle: fallbackOraclePk, payer: (this.program.provider as AnchorProvider).wallet.publicKey, rent: SYSVAR_RENT_PUBKEY, }) @@ -719,16 +721,20 @@ export class MangoClient { mintPk: PublicKey, price: number, ): Promise { + const stubOracle = Keypair.generate(); const ix = await this.program.methods .stubOracleCreate({ val: I80F48.fromNumber(price).getData() }) .accounts({ group: group.publicKey, admin: (this.program.provider as AnchorProvider).wallet.publicKey, + oracle: stubOracle.publicKey, mint: mintPk, payer: (this.program.provider as AnchorProvider).wallet.publicKey, }) .instruction(); - return await this.sendAndConfirmTransactionForGroup(group, [ix]); + return await this.sendAndConfirmTransactionForGroup(group, [ix], { + additionalSigners: [stubOracle], + }); } public async stubOracleClose( From 5519f77d8e1520afd2ceb353551afd365902114d Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Mon, 29 Jan 2024 12:09:20 +0100 Subject: [PATCH 09/42] ts: fallback oracles in bank and token_edit --- ts/client/src/accounts/bank.ts | 3 +++ ts/client/src/client.ts | 3 ++- ts/client/src/clientIxParamBuilder.ts | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/ts/client/src/accounts/bank.ts b/ts/client/src/accounts/bank.ts index 0c6b7d42d..4aead0732 100644 --- a/ts/client/src/accounts/bank.ts +++ b/ts/client/src/accounts/bank.ts @@ -142,6 +142,7 @@ export class Bank implements BankForHealth { maintWeightShiftDurationInv: I80F48Dto; maintWeightShiftAssetTarget: I80F48Dto; maintWeightShiftLiabTarget: I80F48Dto; + fallbackOracle: PublicKey; depositLimit: BN; zeroUtilRate: I80F48Dto; platformLiquidationFee: I80F48Dto; @@ -205,6 +206,7 @@ export class Bank implements BankForHealth { obj.maintWeightShiftDurationInv, obj.maintWeightShiftAssetTarget, obj.maintWeightShiftLiabTarget, + obj.fallbackOracle, obj.depositLimit, obj.zeroUtilRate, obj.platformLiquidationFee, @@ -269,6 +271,7 @@ export class Bank implements BankForHealth { maintWeightShiftDurationInv: I80F48Dto, maintWeightShiftAssetTarget: I80F48Dto, maintWeightShiftLiabTarget: I80F48Dto, + public fallbackOracle: PublicKey, public depositLimit: BN, zeroUtilRate: I80F48Dto, platformLiquidationFee: I80F48Dto, diff --git a/ts/client/src/client.ts b/ts/client/src/client.ts index 157c18028..212b48650 100644 --- a/ts/client/src/client.ts +++ b/ts/client/src/client.ts @@ -544,7 +544,7 @@ export class MangoClient { params.maintWeightShiftAssetTarget, params.maintWeightShiftLiabTarget, params.maintWeightShiftAbort ?? false, - params.setFallbackOracle ?? false, + params.fallbackOracle !== null, // setFallbackOracle params.depositLimit, params.zeroUtilRate, params.platformLiquidationFee, @@ -552,6 +552,7 @@ export class MangoClient { .accounts({ group: group.publicKey, oracle: params.oracle ?? bank.oracle, + fallbackOracle: params.fallbackOracle ?? bank.fallbackOracle, admin: (this.program.provider as AnchorProvider).wallet.publicKey, mintInfo: mintInfo.publicKey, }) diff --git a/ts/client/src/clientIxParamBuilder.ts b/ts/client/src/clientIxParamBuilder.ts index db1ffe1f8..7a9255ede 100644 --- a/ts/client/src/clientIxParamBuilder.ts +++ b/ts/client/src/clientIxParamBuilder.ts @@ -107,7 +107,7 @@ export interface TokenEditParams { maintWeightShiftAssetTarget: number | null; maintWeightShiftLiabTarget: number | null; maintWeightShiftAbort: boolean | null; - setFallbackOracle: boolean | null; + fallbackOracle: PublicKey | null; depositLimit: BN | null; zeroUtilRate: number | null; platformLiquidationFee: number | null; @@ -148,7 +148,7 @@ export const NullTokenEditParams: TokenEditParams = { maintWeightShiftAssetTarget: null, maintWeightShiftLiabTarget: null, maintWeightShiftAbort: null, - setFallbackOracle: null, + fallbackOracle: null, depositLimit: null, zeroUtilRate: null, platformLiquidationFee: null, From ed682cfd8498a606ccdc6cf66b26d8df3da09a8c Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Mon, 29 Jan 2024 12:10:19 +0100 Subject: [PATCH 10/42] rs client: fix construction without explicit errors-by-type --- lib/client/src/error_tracking.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/client/src/error_tracking.rs b/lib/client/src/error_tracking.rs index a466287ac..1e0166e52 100644 --- a/lib/client/src/error_tracking.rs +++ b/lib/client/src/error_tracking.rs @@ -32,7 +32,7 @@ impl Default for ErrorTypeState { #[derive(Builder)] pub struct ErrorTracking { - #[builder(setter(custom))] + #[builder(default, setter(custom))] errors_by_type: HashMap>, /// number of errors of a type after which had_too_many_errors returns true From 2f10a710c984102e3c0249c5939b848c0fca0a35 Mon Sep 17 00:00:00 2001 From: GoodDaisy <90915921+GoodDaisy@users.noreply.github.com> Date: Mon, 29 Jan 2024 20:39:33 +0800 Subject: [PATCH 11/42] chore: fix typos (#854) --- programs/mango-v4/src/instructions/flash_loan.rs | 2 +- programs/mango-v4/src/state/bank.rs | 4 ++-- ts/client/scripts/maintain-alts.ts | 2 +- ts/client/src/accounts/perp.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/programs/mango-v4/src/instructions/flash_loan.rs b/programs/mango-v4/src/instructions/flash_loan.rs index 7fce888c5..cdd3a59cd 100644 --- a/programs/mango-v4/src/instructions/flash_loan.rs +++ b/programs/mango-v4/src/instructions/flash_loan.rs @@ -337,7 +337,7 @@ pub fn flash_loan_end<'key, 'accounts, 'remaining, 'info>( // Create the token position now, so we can compute the pre-health with fixed order health accounts let (_, raw_token_index, _) = account.ensure_token_position(bank.token_index)?; - // Transfer any excess over the inital balance of the token account back + // Transfer any excess over the initial balance of the token account back // into the vault. Compute the total change in the vault balance. let mut change = -I80F48::from(bank.flash_loan_approved_amount); if token_account.amount > bank.flash_loan_token_account_initial { diff --git a/programs/mango-v4/src/state/bank.rs b/programs/mango-v4/src/state/bank.rs index d2e4b7afc..7a9fbbde7 100644 --- a/programs/mango-v4/src/state/bank.rs +++ b/programs/mango-v4/src/state/bank.rs @@ -731,7 +731,7 @@ impl Bank { }) } - // withdraw the loan origination fee for a borrow that happenend earlier + // withdraw the loan origination fee for a borrow that happened earlier pub fn withdraw_loan_origination_fee( &mut self, position: &mut TokenPosition, @@ -1051,7 +1051,7 @@ impl Bank { ) } - /// calcualtor function that can be used to compute an interest + /// calculator function that can be used to compute an interest /// rate based on the given parameters #[inline(always)] pub fn interest_rate_curve_calculator( diff --git a/ts/client/scripts/maintain-alts.ts b/ts/client/scripts/maintain-alts.ts index 0d3ff088b..6e8b1b1b9 100644 --- a/ts/client/scripts/maintain-alts.ts +++ b/ts/client/scripts/maintain-alts.ts @@ -155,7 +155,7 @@ async function run(): Promise { .map((perpMarket) => [perpMarket.publicKey, perpMarket.oracle]) .flat(), ); - // Well known addressess + // Well known addresses await extendTable( client, group, diff --git a/ts/client/src/accounts/perp.ts b/ts/client/src/accounts/perp.ts index 3c9c0fe5f..a3c8217a0 100644 --- a/ts/client/src/accounts/perp.ts +++ b/ts/client/src/accounts/perp.ts @@ -443,7 +443,7 @@ export class PerpMarket { /** * * Returns instantaneous funding rate for the day. How is it actually applied - funding is - * continously applied on every interaction to a perp position. The rate is further multiplied + * continuously applied on every interaction to a perp position. The rate is further multiplied * by the time elapsed since it was last applied (capped to max. 1hr). * * @param bids From 6c6a75d90ddd56a2900cdf9363b589deaea4e032 Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Mon, 29 Jan 2024 15:07:24 +0100 Subject: [PATCH 12/42] liquidator: batch serum-cancel step (#861) --- bin/liquidator/src/liquidate.rs | 54 ++++-- bin/liquidator/src/main.rs | 1 + lib/client/src/client.rs | 47 ++++-- .../scripts/liqtest/liqtest-create-group.ts | 156 ++++++++++++------ .../liqtest-create-tokens-and-markets.ts | 9 +- .../liqtest/liqtest-make-candidates.ts | 34 +++- .../liqtest/liqtest-settle-and-close-all.ts | 3 + 7 files changed, 218 insertions(+), 86 deletions(-) diff --git a/bin/liquidator/src/liquidate.rs b/bin/liquidator/src/liquidate.rs index b0d280207..fc0b65984 100644 --- a/bin/liquidator/src/liquidate.rs +++ b/bin/liquidator/src/liquidate.rs @@ -4,7 +4,7 @@ use std::time::Duration; use itertools::Itertools; use mango_v4::health::{HealthCache, HealthType}; use mango_v4::state::{MangoAccountValue, PerpMarketIndex, Side, TokenIndex, QUOTE_TOKEN_INDEX}; -use mango_v4_client::{chain_data, MangoClient}; +use mango_v4_client::{chain_data, MangoClient, PreparedInstructions}; use solana_sdk::signature::Signature; use futures::{stream, StreamExt, TryStreamExt}; @@ -19,6 +19,10 @@ pub struct Config { pub min_health_ratio: f64, pub refresh_timeout: Duration, pub compute_limit_for_liq_ix: u32, + + /// If we cram multiple ix into a transaction, don't exceed this level + /// of expected-cu. + pub max_cu_per_transaction: u32, } struct LiquidateHelper<'a> { @@ -46,7 +50,7 @@ impl<'a> LiquidateHelper<'a> { Ok((*orders, *open_orders)) }) .try_collect(); - let serum_force_cancels = serum_oos? + let mut serum_force_cancels = serum_oos? .into_iter() .filter_map(|(orders, open_orders)| { let can_force_cancel = open_orders.native_coin_total > 0 @@ -62,18 +66,42 @@ impl<'a> LiquidateHelper<'a> { if serum_force_cancels.is_empty() { return Ok(None); } - // Cancel all orders on a random serum market - let serum_orders = serum_force_cancels.choose(&mut rand::thread_rng()).unwrap(); - let txsig = self - .client - .serum3_liq_force_cancel_orders( - (self.pubkey, self.liqee), - serum_orders.market_index, - &serum_orders.open_orders, - ) - .await?; + serum_force_cancels.shuffle(&mut rand::thread_rng()); + + let mut ixs = PreparedInstructions::new(); + let mut cancelled_markets = vec![]; + let mut tx_builder = self.client.transaction_builder().await?; + + for force_cancel in serum_force_cancels { + let mut new_ixs = ixs.clone(); + new_ixs.append( + self.client + .serum3_liq_force_cancel_orders_instruction( + (self.pubkey, self.liqee), + force_cancel.market_index, + &force_cancel.open_orders, + ) + .await?, + ); + + let exceeds_cu_limit = new_ixs.cu > self.config.max_cu_per_transaction; + let exceeds_size_limit = { + tx_builder.instructions = new_ixs.clone().to_instructions(); + !tx_builder.transaction_size()?.is_ok() + }; + if exceeds_cu_limit || exceeds_size_limit { + break; + } + + ixs = new_ixs; + cancelled_markets.push(force_cancel.market_index); + } + + tx_builder.instructions = ixs.to_instructions(); + + let txsig = tx_builder.send_and_confirm(&self.client.client).await?; info!( - market_index = serum_orders.market_index, + market_indexes = ?cancelled_markets, %txsig, "Force cancelled serum orders", ); diff --git a/bin/liquidator/src/main.rs b/bin/liquidator/src/main.rs index 205b05e74..2d4e1cc1d 100644 --- a/bin/liquidator/src/main.rs +++ b/bin/liquidator/src/main.rs @@ -336,6 +336,7 @@ async fn main() -> anyhow::Result<()> { let liq_config = liquidate::Config { min_health_ratio: cli.min_health_ratio, compute_limit_for_liq_ix: cli.compute_limit_for_liquidation, + max_cu_per_transaction: 1_000_000, // TODO: config refresh_timeout: Duration::from_secs(30), }; diff --git a/lib/client/src/client.rs b/lib/client/src/client.rs index 2caa5fb19..c65afa6e8 100644 --- a/lib/client/src/client.rs +++ b/lib/client/src/client.rs @@ -931,12 +931,12 @@ impl MangoClient { Ok(orders) } - pub async fn serum3_liq_force_cancel_orders( + pub async fn serum3_liq_force_cancel_orders_instruction( &self, liqee: (&Pubkey, &MangoAccountValue), market_index: Serum3MarketIndex, open_orders: &Pubkey, - ) -> anyhow::Result { + ) -> anyhow::Result { let s3 = self.context.serum3(market_index); let base = self.context.serum3_base_token(market_index); let quote = self.context.serum3_quote_token(market_index); @@ -946,7 +946,7 @@ impl MangoClient { .unwrap(); let limit = 5; - let ixs = PreparedInstructions::from_single( + let ix = PreparedInstructions::from_single( Instruction { program_id: mango_v4::id(), accounts: { @@ -982,6 +982,18 @@ impl MangoClient { self.instruction_cu(health_cu) + self.context.compute_estimates.cu_per_serum3_order_cancel * limit as u32, ); + Ok(ix) + } + + pub async fn serum3_liq_force_cancel_orders( + &self, + liqee: (&Pubkey, &MangoAccountValue), + market_index: Serum3MarketIndex, + open_orders: &Pubkey, + ) -> anyhow::Result { + let ixs = self + .serum3_liq_force_cancel_orders_instruction(liqee, market_index, open_orders) + .await?; self.send_and_confirm_permissionless_tx(ixs.to_instructions()) .await } @@ -1825,32 +1837,35 @@ impl MangoClient { &self, instructions: Vec, ) -> anyhow::Result { - let fee_payer = self.client.fee_payer(); - TransactionBuilder { + let mut tx_builder = TransactionBuilder { instructions, - address_lookup_tables: self.mango_address_lookup_tables().await?, - payer: fee_payer.pubkey(), - signers: vec![self.owner.clone(), fee_payer], - config: self.client.config.transaction_builder_config, - } - .send_and_confirm(&self.client) - .await + ..self.transaction_builder().await? + }; + tx_builder.signers.push(self.owner.clone()); + tx_builder.send_and_confirm(&self.client).await } pub async fn send_and_confirm_permissionless_tx( &self, instructions: Vec, ) -> anyhow::Result { - let fee_payer = self.client.fee_payer(); TransactionBuilder { instructions, + ..self.transaction_builder().await? + } + .send_and_confirm(&self.client) + .await + } + + pub async fn transaction_builder(&self) -> anyhow::Result { + let fee_payer = self.client.fee_payer(); + Ok(TransactionBuilder { + instructions: vec![], address_lookup_tables: self.mango_address_lookup_tables().await?, payer: fee_payer.pubkey(), signers: vec![fee_payer], config: self.client.config.transaction_builder_config, - } - .send_and_confirm(&self.client) - .await + }) } pub async fn simulate( diff --git a/ts/client/scripts/liqtest/liqtest-create-group.ts b/ts/client/scripts/liqtest/liqtest-create-group.ts index 44b23a504..c91de1080 100644 --- a/ts/client/scripts/liqtest/liqtest-create-group.ts +++ b/ts/client/scripts/liqtest/liqtest-create-group.ts @@ -29,6 +29,7 @@ const MAINNET_MINTS = new Map([ ['ETH', MINTS[1]], ['SOL', MINTS[2]], ['MNGO', MINTS[3]], + ['MSOL', MINTS[4]], ]); const STUB_PRICES = new Map([ @@ -36,13 +37,7 @@ const STUB_PRICES = new Map([ ['ETH', 1200.0], // eth and usdc both have 6 decimals ['SOL', 0.015], // sol has 9 decimals, equivalent to $15 per SOL ['MNGO', 0.02], -]); - -// External markets are matched with those in https://github.com/blockworks-foundation/mango-client-v3/blob/main/src/ids.json -// and verified to have best liquidity for pair on https://openserum.io/ -const MAINNET_SERUM3_MARKETS = new Map([ - ['ETH/USDC', SERUM_MARKETS[0]], - ['SOL/USDC', SERUM_MARKETS[1]], + ['MSOL', 0.017], ]); const MIN_VAULT_TO_DEPOSITS_RATIO = 0.2; @@ -90,11 +85,13 @@ async function main(): Promise { for (const [name, mint] of MAINNET_MINTS) { console.log(`Creating stub oracle for ${name}...`); const mintPk = new PublicKey(mint); - try { - const price = STUB_PRICES.get(name)!; - await client.stubOracleCreate(group, mintPk, price); - } catch (error) { - console.log(error); + if ((await client.getStubOracle(group, mintPk)).length == 0) { + try { + const price = STUB_PRICES.get(name)!; + await client.stubOracleCreate(group, mintPk, price); + } catch (error) { + console.log(error); + } } const oracle = (await client.getStubOracle(group, mintPk))[0]; console.log(`...created stub oracle ${oracle.publicKey}`); @@ -114,22 +111,32 @@ async function main(): Promise { maxRate: 1.5, }; + const noFallbackOracle = PublicKey.default; + // register token 0 console.log(`Registering USDC...`); const usdcMint = new PublicKey(MAINNET_MINTS.get('USDC')!); const usdcOracle = oracles.get('USDC'); try { - await client.tokenRegister(group, usdcMint, usdcOracle, 0, 'USDC', { - ...DefaultTokenRegisterParams, - loanOriginationFeeRate: 0, - loanFeeRate: 0.0001, - initAssetWeight: 1, - maintAssetWeight: 1, - initLiabWeight: 1, - maintLiabWeight: 1, - liquidationFee: 0, - netBorrowLimitPerWindowQuote: NET_BORROWS_LIMIT_NATIVE, - }); + await client.tokenRegister( + group, + usdcMint, + usdcOracle, + noFallbackOracle, + 0, + 'USDC', + { + ...DefaultTokenRegisterParams, + loanOriginationFeeRate: 0, + loanFeeRate: 0.0001, + initAssetWeight: 1, + maintAssetWeight: 1, + initLiabWeight: 1, + maintLiabWeight: 1, + liquidationFee: 0, + netBorrowLimitPerWindowQuote: NET_BORROWS_LIMIT_NATIVE, + }, + ); await group.reloadAll(client); } catch (error) { console.log(error); @@ -140,17 +147,25 @@ async function main(): Promise { const ethMint = new PublicKey(MAINNET_MINTS.get('ETH')!); const ethOracle = oracles.get('ETH'); try { - await client.tokenRegister(group, ethMint, ethOracle, 1, 'ETH', { - ...DefaultTokenRegisterParams, - loanOriginationFeeRate: 0, - loanFeeRate: 0.0001, - maintAssetWeight: 0.9, - initAssetWeight: 0.8, - maintLiabWeight: 1.1, - initLiabWeight: 1.2, - liquidationFee: 0.05, - netBorrowLimitPerWindowQuote: NET_BORROWS_LIMIT_NATIVE, - }); + await client.tokenRegister( + group, + ethMint, + ethOracle, + noFallbackOracle, + 1, + 'ETH', + { + ...DefaultTokenRegisterParams, + loanOriginationFeeRate: 0, + loanFeeRate: 0.0001, + maintAssetWeight: 0.9, + initAssetWeight: 0.8, + maintLiabWeight: 1.1, + initLiabWeight: 1.2, + liquidationFee: 0.05, + netBorrowLimitPerWindowQuote: NET_BORROWS_LIMIT_NATIVE, + }, + ); await group.reloadAll(client); } catch (error) { console.log(error); @@ -165,6 +180,7 @@ async function main(): Promise { group, solMint, solOracle, + noFallbackOracle, 2, // tokenIndex 'SOL', { @@ -184,27 +200,72 @@ async function main(): Promise { console.log(error); } + const genericBanks = ['MNGO', 'MSOL']; + let nextTokenIndex = 3; + for (let name of genericBanks) { + console.log(`Registering ${name}...`); + const mint = new PublicKey(MAINNET_MINTS.get(name)!); + const oracle = oracles.get(name); + try { + await client.tokenRegister( + group, + mint, + oracle, + noFallbackOracle, + nextTokenIndex, + name, + { + ...DefaultTokenRegisterParams, + loanOriginationFeeRate: 0, + loanFeeRate: 0.0001, + maintAssetWeight: 0.9, + initAssetWeight: 0.8, + maintLiabWeight: 1.1, + initLiabWeight: 1.2, + liquidationFee: 0.05, + netBorrowLimitPerWindowQuote: NET_BORROWS_LIMIT_NATIVE, + }, + ); + nextTokenIndex += 1; + await group.reloadAll(client); + } catch (error) { + console.log(error); + } + } + // log tokens/banks for (const bank of await group.banksMapByMint.values()) { console.log(`${bank.toString()}`); } - console.log('Registering SOL/USDC serum market...'); - try { - await client.serum3RegisterMarket( - group, - new PublicKey(MAINNET_SERUM3_MARKETS.get('SOL/USDC')!), - group.getFirstBankByMint(new PublicKey(MAINNET_MINTS.get('SOL')!)), - group.getFirstBankByMint(new PublicKey(MAINNET_MINTS.get('USDC')!)), - 1, - 'SOL/USDC', - 0, - ); - } catch (error) { - console.log(error); + let nextSerumMarketIndex = 0; + for (let [name, mint] of MAINNET_MINTS) { + if (name == 'USDC') { + continue; + } + + console.log(`Registering ${name}/USDC serum market...`); + try { + await client.serum3RegisterMarket( + group, + new PublicKey(SERUM_MARKETS[nextSerumMarketIndex]), + group.getFirstBankByMint(new PublicKey(mint)), + group.getFirstBankByMint(usdcMint), + nextSerumMarketIndex, + `${name}/USDC`, + 0, + ); + nextSerumMarketIndex += 1; + } catch (error) { + console.log(error); + } } console.log('Registering MNGO-PERP market...'); + if (!group.banksMapByMint.get(usdcMint.toString())) { + console.log('stopping, no USDC bank'); + return; + } const mngoOracle = oracles.get('MNGO'); try { await client.perpCreateMarket( @@ -237,6 +298,7 @@ async function main(): Promise { -1.0, 2 * 60 * 60, 0.025, + 0.0, ); } catch (error) { console.log(error); diff --git a/ts/client/scripts/liqtest/liqtest-create-tokens-and-markets.ts b/ts/client/scripts/liqtest/liqtest-create-tokens-and-markets.ts index d78e5e436..6f01e7237 100644 --- a/ts/client/scripts/liqtest/liqtest-create-tokens-and-markets.ts +++ b/ts/client/scripts/liqtest/liqtest-create-tokens-and-markets.ts @@ -20,6 +20,9 @@ import { generateSerum3MarketExternalVaultSignerAddress } from '../../src/accoun // Script which creates three mints and two serum3 markets relating them // +const MINT_COUNT = 5; +const SERUM_MARKET_COUNT = 4; + function getVaultOwnerAndNonce( market: PublicKey, programId: PublicKey, @@ -56,7 +59,7 @@ async function main(): Promise { // Make mints const mints = await Promise.all( - Array(4) + Array(MINT_COUNT) .fill(null) .map(() => splToken.createMint(connection, admin, admin.publicKey, null, 6), @@ -78,11 +81,11 @@ async function main(): Promise { // Make serum markets const serumMarkets: PublicKey[] = []; const quoteMint = mints[0]; - for (const baseMint of mints.slice(1, 3)) { + for (const baseMint of mints.slice(1, 1 + SERUM_MARKET_COUNT)) { const feeRateBps = 0.25; // don't think this does anything const quoteDustThreshold = 100; const baseLotSize = 1000; - const quoteLotSize = 1000; + const quoteLotSize = 1; // makes prices be in 1000ths const openbookProgramId = OPENBOOK_PROGRAM_ID.devnet; const market = Keypair.generate(); diff --git a/ts/client/scripts/liqtest/liqtest-make-candidates.ts b/ts/client/scripts/liqtest/liqtest-make-candidates.ts index b64220520..c1d04e436 100644 --- a/ts/client/scripts/liqtest/liqtest-make-candidates.ts +++ b/ts/client/scripts/liqtest/liqtest-make-candidates.ts @@ -31,7 +31,7 @@ const CLUSTER = process.env.CLUSTER || 'mainnet-beta'; // native prices const PRICES = { ETH: 1200.0, - SOL: 0.015, + SOL: 0.015, // not updated for the fact that the new mints we use have 6 decimals! USDC: 1, MNGO: 0.02, }; @@ -100,7 +100,7 @@ async function main() { async function createMangoAccount(name: string): Promise { const accountNum = maxAccountNum + 1; maxAccountNum = maxAccountNum + 1; - await client.createMangoAccount(group, accountNum, name, 4, 4, 4, 4); + await client.createMangoAccount(group, accountNum, name, 5, 4, 4, 4); return (await client.getMangoAccountForOwner( group, admin.publicKey, @@ -202,7 +202,7 @@ async function main() { group, mangoAccount, sellMint, - new BN(100000), + new BN(150000), ); await mangoAccount.reload(client); @@ -217,20 +217,40 @@ async function main() { .build(), ); try { - // At a price of $1/ui-SOL we can buy 0.1 ui-SOL for the 100k native-USDC we have. - // With maint weight of 0.9 we have 10x main-leverage. Buying 12x as much causes liquidation. + // At a price of $0.015/ui-SOL we can buy 10 ui-SOL for the 0.15 USDC (150k native-USDC) we have. + // With maint weight of 0.9 we have 10x main-leverage. Buying 11x as much causes liquidation. await client.serum3PlaceOrder( group, mangoAccount, market.serumMarketExternal, Serum3Side.bid, - 1, - 12 * 0.1, + 0.015, + 11 * 10, Serum3SelfTradeBehavior.abortTransaction, Serum3OrderType.limit, 0, 5, ); + await mangoAccount.reload(client); + + for (let market of group.serum3MarketsMapByMarketIndex.values()) { + if (market.name == 'SOL/USDC') { + continue; + } + await client.serum3PlaceOrder( + group, + mangoAccount, + market.serumMarketExternal, + Serum3Side.bid, + 0.001, + 1, + Serum3SelfTradeBehavior.abortTransaction, + Serum3OrderType.limit, + 0, + 5, + ); + await mangoAccount.reload(client); + } } finally { // restore the weights await client.tokenEdit( diff --git a/ts/client/scripts/liqtest/liqtest-settle-and-close-all.ts b/ts/client/scripts/liqtest/liqtest-settle-and-close-all.ts index 76ddc26f7..406484daa 100644 --- a/ts/client/scripts/liqtest/liqtest-settle-and-close-all.ts +++ b/ts/client/scripts/liqtest/liqtest-settle-and-close-all.ts @@ -57,6 +57,9 @@ async function main() { `closing serum orders on: ${account} for market ${serumMarket.name}`, ); await client.serum3CancelAllOrders(group, account, serumExternal, 10); + try { + await client.serum3ConsumeEvents(group, serumExternal); + } catch (e) {} await client.serum3SettleFunds(group, account, serumExternal); await client.serum3CloseOpenOrders(group, account, serumExternal); } From afc2ff9e80d443e86c09341240965c53d7f7be26 Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Mon, 29 Jan 2024 15:01:10 +0100 Subject: [PATCH 13/42] allocator: Don't allow growth beyond heap memory region --- programs/mango-v4/src/allocator.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/programs/mango-v4/src/allocator.rs b/programs/mango-v4/src/allocator.rs index 9cb72d153..9ed5e94d0 100644 --- a/programs/mango-v4/src/allocator.rs +++ b/programs/mango-v4/src/allocator.rs @@ -2,6 +2,11 @@ use std::alloc::{GlobalAlloc, Layout}; +/// The end of the region where heap space may be reserved for the program. +/// +/// The actual size of the heap is currently not available at runtime. +pub const HEAP_END_ADDRESS: usize = 0x400000000; + #[cfg(not(feature = "no-entrypoint"))] #[global_allocator] pub static ALLOCATOR: BumpAllocator = BumpAllocator {}; @@ -48,6 +53,9 @@ unsafe impl GlobalAlloc for BumpAllocator { let end = begin.checked_add(layout.size()).unwrap(); *pos_ptr = end; + // Ensure huge allocations can't escape the dedicated heap memory region + assert!(end < HEAP_END_ADDRESS); + // Write a byte to trigger heap overflow errors early let end_ptr = end as *mut u8; *end_ptr = 0; From 719aee37ae6871b710ed23d03eef46b2c6815801 Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Mon, 29 Jan 2024 15:05:12 +0100 Subject: [PATCH 14/42] delegate withdraw: require target to have expected owner --- programs/mango-v4/src/instructions/token_withdraw.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/programs/mango-v4/src/instructions/token_withdraw.rs b/programs/mango-v4/src/instructions/token_withdraw.rs index 17f2dce8c..bcbfbc172 100644 --- a/programs/mango-v4/src/instructions/token_withdraw.rs +++ b/programs/mango-v4/src/instructions/token_withdraw.rs @@ -133,6 +133,11 @@ pub fn token_withdraw(ctx: Context, amount: u64, allow_borrow: bo owner_ata, MangoError::DelegateWithdrawOnlyToOwnerAta ); + require_keys_eq!( + ctx.accounts.token_account.owner, + account.fixed.owner, + MangoError::DelegateWithdrawOnlyToOwnerAta + ); // Delegates must close the token position require!( From ae5907ba3a431b95f1af0eb8357371cafbd81df2 Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Thu, 1 Feb 2024 11:23:45 +0100 Subject: [PATCH 15/42] fix perp settle limit materialization (#865) Previously, we tried to keep track of "other" and "trade" realized pnl. An issue occured when a perp base position went to zero: the way we computed the trade pnl included potential non-trade unsettled pnl. That caused follow-up trouble because the value could change sign and reset the settle limit for trade pnl. This change aims to simplify in some ways: - explicitly talk about oneshot-settleable pnl (fees, funding, liquidation) and recurring-settleable pnl (materialization of settle limit derived from the stable value of the base position when reducing the base position) - instead of directly tracking realized settleable amounts (which doesn't really work), just decrease the recurring settleable amount when it exceeds the remaining unsettled pnl - get rid of the directionality to avoid bugs of that kind - stop tracking unsettled-realized trade pnl (it was wrong before, and no client uses it) - we already track position-lifetime realized trade pnl --- mango_v4.json | 34 +- .../src/instructions/perp_consume_events.rs | 43 +- .../perp_liq_base_or_positive_pnl.rs | 6 +- .../perp_liq_negative_pnl_or_bankruptcy.rs | 8 +- .../src/instructions/perp_settle_fees.rs | 2 +- .../src/instructions/perp_settle_pnl.rs | 4 +- programs/mango-v4/src/state/mango_account.rs | 14 +- .../src/state/mango_account_components.rs | 706 ++++++++---------- .../tests/cases/test_liq_perps_bankruptcy.rs | 18 +- .../test_liq_perps_base_and_bankruptcy.rs | 12 +- .../mango-v4/tests/cases/test_perp_settle.rs | 29 +- ts/client/src/accounts/mangoAccount.ts | 55 +- ts/client/src/mango_v4.ts | 68 +- 13 files changed, 454 insertions(+), 545 deletions(-) diff --git a/mango_v4.json b/mango_v4.json index 27495ca7d..b3475028d 100644 --- a/mango_v4.json +++ b/mango_v4.json @@ -9334,36 +9334,44 @@ "type": "f64" }, { - "name": "realizedTradePnlNative", + "name": "deprecatedRealizedTradePnlNative", "docs": [ - "Amount of pnl that was realized by bringing the base position closer to 0.", - "", - "The settlement of this type of pnl is limited by settle_pnl_limit_realized_trade.", - "Settling pnl reduces this value once other_pnl below is exhausted." + "Deprecated field: Amount of pnl that was realized by bringing the base position closer to 0." ], "type": { "defined": "I80F48" } }, { - "name": "realizedOtherPnlNative", + "name": "oneshotSettlePnlAllowance", "docs": [ - "Amount of pnl realized from fees, funding and liquidation.", + "Amount of pnl that can be settled once.", "", - "This type of realized pnl is always settleable.", - "Settling pnl reduces this value first." + "- The value is signed: a negative number means negative pnl can be settled.", + "- A settlement in the right direction will decrease this amount.", + "", + "Typically added for fees, funding and liquidation." ], "type": { "defined": "I80F48" } }, { - "name": "settlePnlLimitRealizedTrade", + "name": "recurringSettlePnlAllowance", "docs": [ - "Settle limit contribution from realized pnl.", + "Amount of pnl that can be settled in each settle window.", "", - "Every time pnl is realized, this is increased by a fraction of the stable", - "value of the realization. It magnitude decreases when realized pnl drops below its value." + "- Unsigned, the settlement can happen in both directions. Value is >= 0.", + "- Previously stored a similar value that was signed, so in migration cases", + "this value can be negative and should be .abs()ed.", + "- If this value exceeds the current stable-upnl, it should be decreased,", + "see apply_recurring_settle_pnl_allowance_constraint()", + "", + "When the base position is reduced, the settle limit contribution from the reduced", + "base position is materialized into this value. When the base position increases,", + "some of the allowance is taken away.", + "", + "This also gets increased when a liquidator takes over pnl." ], "type": "i64" }, diff --git a/programs/mango-v4/src/instructions/perp_consume_events.rs b/programs/mango-v4/src/instructions/perp_consume_events.rs index d029fb43a..c8f6341a7 100644 --- a/programs/mango-v4/src/instructions/perp_consume_events.rs +++ b/programs/mango-v4/src/instructions/perp_consume_events.rs @@ -74,40 +74,37 @@ pub fn perp_consume_events(ctx: Context, limit: usize) -> Res group, event_queue ); - let before_pnl = maker_taker - .perp_position(perp_market_index)? - .realized_trade_pnl_native; - maker_taker.execute_perp_maker( + let maker_realized_pnl = maker_taker.execute_perp_maker( perp_market_index, &mut perp_market, fill, &group, )?; - maker_taker.execute_perp_taker(perp_market_index, &mut perp_market, fill)?; + let taker_realized_pnl = maker_taker.execute_perp_taker( + perp_market_index, + &mut perp_market, + fill, + )?; emit_perp_balances( group_key, fill.maker, maker_taker.perp_position(perp_market_index).unwrap(), &perp_market, ); - let after_pnl = maker_taker - .perp_position(perp_market_index)? - .realized_trade_pnl_native; - let closed_pnl = after_pnl - before_pnl; + let closed_pnl = maker_realized_pnl + taker_realized_pnl; (closed_pnl, closed_pnl) } else { load_mango_account!(maker, fill.maker, mango_account_ais, group, event_queue); load_mango_account!(taker, fill.taker, mango_account_ais, group, event_queue); - let maker_before_pnl = maker - .perp_position(perp_market_index)? - .realized_trade_pnl_native; - let taker_before_pnl = taker - .perp_position(perp_market_index)? - .realized_trade_pnl_native; - - maker.execute_perp_maker(perp_market_index, &mut perp_market, fill, &group)?; - taker.execute_perp_taker(perp_market_index, &mut perp_market, fill)?; + let maker_realized_pnl = maker.execute_perp_maker( + perp_market_index, + &mut perp_market, + fill, + &group, + )?; + let taker_realized_pnl = + taker.execute_perp_taker(perp_market_index, &mut perp_market, fill)?; emit_perp_balances( group_key, fill.maker, @@ -120,16 +117,8 @@ pub fn perp_consume_events(ctx: Context, limit: usize) -> Res taker.perp_position(perp_market_index).unwrap(), &perp_market, ); - let maker_after_pnl = maker - .perp_position(perp_market_index)? - .realized_trade_pnl_native; - let taker_after_pnl = taker - .perp_position(perp_market_index)? - .realized_trade_pnl_native; - let maker_closed_pnl = maker_after_pnl - maker_before_pnl; - let taker_closed_pnl = taker_after_pnl - taker_before_pnl; - (maker_closed_pnl, taker_closed_pnl) + (maker_realized_pnl, taker_realized_pnl) }; emit_stack(FillLogV3 { mango_group: group_key, diff --git a/programs/mango-v4/src/instructions/perp_liq_base_or_positive_pnl.rs b/programs/mango-v4/src/instructions/perp_liq_base_or_positive_pnl.rs index 246ddd9bf..93c867078 100644 --- a/programs/mango-v4/src/instructions/perp_liq_base_or_positive_pnl.rs +++ b/programs/mango-v4/src/instructions/perp_liq_base_or_positive_pnl.rs @@ -598,7 +598,7 @@ pub(crate) fn liquidation_action( let token_transfer = pnl_transfer * spot_gain_per_settled; liqor_perp_position.record_liquidation_pnl_takeover(pnl_transfer, limit_transfer); - liqee_perp_position.record_settle(pnl_transfer); + liqee_perp_position.record_settle(pnl_transfer, &perp_market); // Update the accounts' perp_spot_transfer statistics. let transfer_i64 = token_transfer.round_to_zero().to_num::(); @@ -1027,7 +1027,7 @@ mod tests { init_liqee_base, I80F48::from_num(init_liqee_quote), ); - p.realized_other_pnl_native = p + p.oneshot_settle_pnl_allowance = p .unsettled_pnl(setup.perp_market.data(), I80F48::ONE) .unwrap(); @@ -1072,7 +1072,7 @@ mod tests { // The settle limit taken over matches the quote pos when removing the // quote gains from giving away base lots assert_eq_f!( - I80F48::from_num(liqor_perp.settle_pnl_limit_realized_trade), + I80F48::from_num(liqor_perp.recurring_settle_pnl_allowance), liqor_perp.quote_position_native.to_num::() + liqor_perp.base_position_lots as f64, 1.1 diff --git a/programs/mango-v4/src/instructions/perp_liq_negative_pnl_or_bankruptcy.rs b/programs/mango-v4/src/instructions/perp_liq_negative_pnl_or_bankruptcy.rs index 73afa64e2..49b8416f9 100644 --- a/programs/mango-v4/src/instructions/perp_liq_negative_pnl_or_bankruptcy.rs +++ b/programs/mango-v4/src/instructions/perp_liq_negative_pnl_or_bankruptcy.rs @@ -267,7 +267,7 @@ pub(crate) fn liquidation_action( .max(I80F48::ZERO); if settlement > 0 { liqor_perp_position.record_liquidation_quote_change(-settlement); - liqee_perp_position.record_settle(-settlement); + liqee_perp_position.record_settle(-settlement, &perp_market); // Update the accounts' perp_spot_transfer statistics. let settlement_i64 = settlement.round_to_zero().to_num::(); @@ -380,7 +380,7 @@ pub(crate) fn liquidation_action( // transfer perp quote loss from the liqee to the liqor let liqor_perp_position = liqor.perp_position_mut(perp_market_index)?; - liqee_perp_position.record_settle(-insurance_liab_transfer); + liqee_perp_position.record_settle(-insurance_liab_transfer, &perp_market); liqor_perp_position.record_liquidation_quote_change(-insurance_liab_transfer); msg!( @@ -399,7 +399,7 @@ pub(crate) fn liquidation_action( (perp_market.long_funding, perp_market.short_funding); if insurance_fund_exhausted && remaining_liab > 0 { perp_market.socialize_loss(-remaining_liab)?; - liqee_perp_position.record_settle(-remaining_liab); + liqee_perp_position.record_settle(-remaining_liab, &perp_market); socialized_loss = remaining_liab; msg!("socialized loss: {}", socialized_loss); } @@ -760,7 +760,7 @@ mod tests { { let p = perp_p(&mut setup.liqee); p.quote_position_native = I80F48::from_num(init_perp); - p.settle_pnl_limit_realized_trade = -settle_limit; + p.recurring_settle_pnl_allowance = (settle_limit as i64).abs(); let settle_bank = setup.settle_bank.data(); settle_bank diff --git a/programs/mango-v4/src/instructions/perp_settle_fees.rs b/programs/mango-v4/src/instructions/perp_settle_fees.rs index f268c62b2..d7c31cf82 100644 --- a/programs/mango-v4/src/instructions/perp_settle_fees.rs +++ b/programs/mango-v4/src/instructions/perp_settle_fees.rs @@ -68,7 +68,7 @@ pub fn perp_settle_fees(ctx: Context, max_settle_amount: u64) -> .min(I80F48::from(max_settle_amount)); require!(settlement >= 0, MangoError::SettlementAmountMustBePositive); - perp_position.record_settle(-settlement); // settle the negative pnl on the user perp position + perp_position.record_settle(-settlement, &perp_market); // settle the negative pnl on the user perp position perp_market.fees_accrued -= settlement; emit_perp_balances( diff --git a/programs/mango-v4/src/instructions/perp_settle_pnl.rs b/programs/mango-v4/src/instructions/perp_settle_pnl.rs index 4fba7233a..3e8bc7927 100644 --- a/programs/mango-v4/src/instructions/perp_settle_pnl.rs +++ b/programs/mango-v4/src/instructions/perp_settle_pnl.rs @@ -143,8 +143,8 @@ pub fn perp_settle_pnl(ctx: Context) -> Result<()> { b_max_settle, ); - a_perp_position.record_settle(settlement); - b_perp_position.record_settle(-settlement); + a_perp_position.record_settle(settlement, &perp_market); + b_perp_position.record_settle(-settlement, &perp_market); emit_perp_balances( ctx.accounts.group.key(), ctx.accounts.account_a.key(), diff --git a/programs/mango-v4/src/state/mango_account.rs b/programs/mango-v4/src/state/mango_account.rs index 656e93f72..9a759b250 100644 --- a/programs/mango-v4/src/state/mango_account.rs +++ b/programs/mango-v4/src/state/mango_account.rs @@ -1237,13 +1237,14 @@ impl< Ok(()) } + /// Returns amount of realized trade pnl for the maker pub fn execute_perp_maker( &mut self, perp_market_index: PerpMarketIndex, perp_market: &mut PerpMarket, fill: &FillEvent, group: &Group, - ) -> Result<()> { + ) -> Result { let side = fill.taker_side().invert_side(); let (base_change, quote_change) = fill.base_quote_change(side); let quote = I80F48::from(perp_market.quote_lot_size) * I80F48::from(quote_change); @@ -1257,7 +1258,7 @@ impl< let pa = self.perp_position_mut(perp_market_index)?; pa.settle_funding(perp_market); pa.record_trading_fee(fees); - pa.record_trade(perp_market, base_change, quote); + let realized_pnl = pa.record_trade(perp_market, base_change, quote); pa.maker_volume += quote.abs().to_num::(); @@ -1288,15 +1289,16 @@ impl< } } - Ok(()) + Ok(realized_pnl) } + /// Returns amount of realized trade pnl for the taker pub fn execute_perp_taker( &mut self, perp_market_index: PerpMarketIndex, perp_market: &mut PerpMarket, fill: &FillEvent, - ) -> Result<()> { + ) -> Result { let pa = self.perp_position_mut(perp_market_index)?; pa.settle_funding(perp_market); @@ -1305,11 +1307,11 @@ impl< // fees are assessed at time of trade; no need to assess fees here let quote_change_native = I80F48::from(perp_market.quote_lot_size) * I80F48::from(quote_change); - pa.record_trade(perp_market, base_change, quote_change_native); + let realized_pnl = pa.record_trade(perp_market, base_change, quote_change_native); pa.taker_volume += quote_change_native.abs().to_num::(); - Ok(()) + Ok(realized_pnl) } pub fn execute_perp_out_event( diff --git a/programs/mango-v4/src/state/mango_account_components.rs b/programs/mango-v4/src/state/mango_account_components.rs index 10e8dc50f..06d30efc5 100644 --- a/programs/mango-v4/src/state/mango_account_components.rs +++ b/programs/mango-v4/src/state/mango_account_components.rs @@ -288,23 +288,31 @@ pub struct PerpPosition { /// Reset to 0 when the base position reaches or crosses 0. pub avg_entry_price_per_base_lot: f64, - /// Amount of pnl that was realized by bringing the base position closer to 0. - /// - /// The settlement of this type of pnl is limited by settle_pnl_limit_realized_trade. - /// Settling pnl reduces this value once other_pnl below is exhausted. - pub realized_trade_pnl_native: I80F48, + /// Deprecated field: Amount of pnl that was realized by bringing the base position closer to 0. + pub deprecated_realized_trade_pnl_native: I80F48, - /// Amount of pnl realized from fees, funding and liquidation. + /// Amount of pnl that can be settled once. /// - /// This type of realized pnl is always settleable. - /// Settling pnl reduces this value first. - pub realized_other_pnl_native: I80F48, + /// - The value is signed: a negative number means negative pnl can be settled. + /// - A settlement in the right direction will decrease this amount. + /// + /// Typically added for fees, funding and liquidation. + pub oneshot_settle_pnl_allowance: I80F48, - /// Settle limit contribution from realized pnl. + /// Amount of pnl that can be settled in each settle window. /// - /// Every time pnl is realized, this is increased by a fraction of the stable - /// value of the realization. It magnitude decreases when realized pnl drops below its value. - pub settle_pnl_limit_realized_trade: i64, + /// - Unsigned, the settlement can happen in both directions. Value is >= 0. + /// - Previously stored a similar value that was signed, so in migration cases + /// this value can be negative and should be .abs()ed. + /// - If this value exceeds the current stable-upnl, it should be decreased, + /// see apply_recurring_settle_pnl_allowance_constraint() + /// + /// When the base position is reduced, the settle limit contribution from the reduced + /// base position is materialized into this value. When the base position increases, + /// some of the allowance is taken away. + /// + /// This also gets increased when a liquidator takes over pnl. + pub recurring_settle_pnl_allowance: i64, /// Trade pnl, fees, funding that were added over the current position's lifetime. /// @@ -345,11 +353,11 @@ impl Default for PerpPosition { taker_volume: 0, perp_spot_transfers: 0, avg_entry_price_per_base_lot: 0.0, - realized_trade_pnl_native: I80F48::ZERO, - realized_other_pnl_native: I80F48::ZERO, + deprecated_realized_trade_pnl_native: I80F48::ZERO, + oneshot_settle_pnl_allowance: I80F48::ZERO, settle_pnl_limit_window: 0, settle_pnl_limit_settled_in_current_window_native: 0, - settle_pnl_limit_realized_trade: 0, + recurring_settle_pnl_allowance: 0, realized_pnl_for_position_native: I80F48::ZERO, reserved: [0; 88], } @@ -439,7 +447,7 @@ impl PerpPosition { pub fn settle_funding(&mut self, perp_market: &PerpMarket) { let funding = self.unsettled_funding(perp_market); self.quote_position_native -= funding; - self.realized_other_pnl_native -= funding; + self.oneshot_settle_pnl_allowance -= funding; self.realized_pnl_for_position_native -= funding; if self.base_position_lots.is_positive() { @@ -453,41 +461,47 @@ impl PerpPosition { } /// Updates avg entry price, breakeven price, realized pnl, realized pnl limit + /// + /// Returns realized trade pnl fn update_trade_stats( &mut self, base_change: i64, quote_change_native: I80F48, perp_market: &PerpMarket, - ) { + ) -> I80F48 { if base_change == 0 { - return; + return I80F48::ZERO; } let old_position = self.base_position_lots; let new_position = old_position + base_change; - // amount of lots that were reduced (so going from -5 to 10 lots is a reduction of 5) + // abs amount of lots that were reduced: + // - going from -5 to 10 lots is a reduction of 5 + // - going from 10 to -5 is a reduction of 10 let reduced_lots; + // same for increases + // - going from -5 to 10 lots is an increase of 10 + // - going from 10 to -5 is an increase of 5 + let increased_lots; // amount of pnl that was realized by the reduction (signed) let newly_realized_pnl; if new_position == 0 { - reduced_lots = -old_position; + reduced_lots = old_position.abs(); + increased_lots = 0; + + let avg_entry = I80F48::from_num(self.avg_entry_price_per_base_lot); + newly_realized_pnl = quote_change_native + I80F48::from(base_change) * avg_entry; // clear out display fields that live only while the position lasts self.avg_entry_price_per_base_lot = 0.0; self.quote_running_native = 0; self.realized_pnl_for_position_native = I80F48::ZERO; - - // There can't be unrealized pnl without a base position, so fix the - // realized_trade_pnl to cover everything that isn't realized_other_pnl. - let total_realized_pnl = self.quote_position_native + quote_change_native; - let new_realized_trade_pnl = total_realized_pnl - self.realized_other_pnl_native; - newly_realized_pnl = new_realized_trade_pnl - self.realized_trade_pnl_native; - self.realized_trade_pnl_native = new_realized_trade_pnl; } else if old_position.signum() != new_position.signum() { // If the base position changes sign, we've crossed base_pos == 0 (or old_position == 0) - reduced_lots = -old_position; + reduced_lots = old_position.abs(); + increased_lots = new_position.abs(); let old_position = old_position as f64; let new_position = new_position as f64; let base_change = base_change as f64; @@ -496,7 +510,6 @@ impl PerpPosition { // Award realized pnl based on the old_position size newly_realized_pnl = I80F48::from_num(old_position * (new_avg_entry - old_avg_entry)); - self.realized_trade_pnl_native += newly_realized_pnl; // Set entry and break-even based on the new_position entered self.avg_entry_price_per_base_lot = new_avg_entry; @@ -513,6 +526,7 @@ impl PerpPosition { if is_increasing { // Increasing position: avg entry price updates, no new realized pnl reduced_lots = 0; + increased_lots = base_change.abs(); newly_realized_pnl = I80F48::ZERO; let old_position_abs = old_position.abs() as f64; let new_position_abs = new_position.abs() as f64; @@ -522,128 +536,60 @@ impl PerpPosition { self.avg_entry_price_per_base_lot = new_position_quote_value / new_position_abs; } else { // Decreasing position: pnl is realized, avg entry price does not change - reduced_lots = base_change; + reduced_lots = base_change.abs(); + increased_lots = 0; let avg_entry = I80F48::from_num(self.avg_entry_price_per_base_lot); newly_realized_pnl = quote_change_native + I80F48::from(base_change) * avg_entry; - self.realized_trade_pnl_native += newly_realized_pnl; self.realized_pnl_for_position_native += newly_realized_pnl; } } - // Bump the realized trade pnl settle limit for a fraction of the stable price value, - // allowing gradual settlement of very high-pnl trades. - let realized_stable_value = I80F48::from(reduced_lots.abs() * perp_market.base_lot_size) - * perp_market.stable_price(); - let stable_value_fraction = - I80F48::from_num(perp_market.settle_pnl_limit_factor) * realized_stable_value; - self.increase_realized_trade_pnl_settle_limit(newly_realized_pnl, stable_value_fraction); + let net_base_increase = increased_lots - reduced_lots; + self.recurring_settle_pnl_allowance = self.recurring_settle_pnl_allowance.abs(); + self.recurring_settle_pnl_allowance -= + (I80F48::from(net_base_increase * perp_market.base_lot_size) + * perp_market.stable_price() + * I80F48::from_num(perp_market.settle_pnl_limit_factor)) + .clamp_to_i64(); + self.recurring_settle_pnl_allowance = self.recurring_settle_pnl_allowance.max(0); + + newly_realized_pnl } - fn increase_realized_trade_pnl_settle_limit( - &mut self, - newly_realized_pnl: I80F48, - limit: I80F48, - ) { - // When realized limit has a different sign from realized pnl, reset it completely - if (self.settle_pnl_limit_realized_trade > 0 && self.realized_trade_pnl_native <= 0) - || (self.settle_pnl_limit_realized_trade < 0 && self.realized_trade_pnl_native >= 0) - { - self.settle_pnl_limit_realized_trade = 0; - } + /// Returns the change in recurring settle allowance + fn apply_recurring_settle_pnl_allowance_constraint(&mut self, perp_market: &PerpMarket) -> i64 { + // deprecation/migration + self.recurring_settle_pnl_allowance = self.recurring_settle_pnl_allowance.abs(); + self.deprecated_realized_trade_pnl_native = I80F48::ZERO; - // Whenever realized pnl increases in magnitude, also increase realized pnl settle limit - // magnitude. - if newly_realized_pnl.signum() == self.realized_trade_pnl_native.signum() { - // The realized pnl settle limit change is restricted to actually realized pnl: - // buying and then selling some base lots at the same price shouldn't affect - // the settle limit. - let limit_change = if newly_realized_pnl > 0 { - newly_realized_pnl.min(limit).ceil().clamp_to_i64() - } else { - newly_realized_pnl.max(-limit).floor().clamp_to_i64() - }; - self.settle_pnl_limit_realized_trade += limit_change; - } + let before = self.recurring_settle_pnl_allowance; - // Ensure the realized limit doesn't exceed the realized pnl - self.apply_realized_trade_pnl_settle_limit_constraint(newly_realized_pnl); - } + // The recurring allowance is always >= 0 and <= stable-upnl + let upnl = self + .unsettled_pnl(perp_market, perp_market.stable_price()) + .unwrap(); + let upnl_abs = upnl.abs().ceil().to_num::(); + self.recurring_settle_pnl_allowance = + self.recurring_settle_pnl_allowance.max(0).min(upnl_abs); - /// The abs(realized pnl settle limit) should be roughly < abs(realized pnl). - /// - /// It's not always true, since realized_pnl can change with fees and funding - /// without updating the realized pnl settle limit. And rounding also breaks it. - /// - /// This function applies that constraint and deals with bookkeeping. - fn apply_realized_trade_pnl_settle_limit_constraint( - &mut self, - realized_trade_pnl_change: I80F48, - ) { - let new_limit = if self.realized_trade_pnl_native > 0 { - self.settle_pnl_limit_realized_trade - .min(self.realized_trade_pnl_native.ceil().clamp_to_i64()) - .max(0) - } else { - self.settle_pnl_limit_realized_trade - .max(self.realized_trade_pnl_native.floor().clamp_to_i64()) - .min(0) - }; - let limit_change = new_limit - self.settle_pnl_limit_realized_trade; - self.settle_pnl_limit_realized_trade = new_limit; - - // If we reduce the budget for realized pnl settling we also need to decrease the - // used-up settle amount to keep the freely settleable amount the same. - // - // Example: Settling the last remaining 50 realized pnl adds 50 to settled and brings the - // realized pnl settle budget to 0 above. That means we reduced the budget _and_ used - // up a part of it: it was double-counted. Instead bring the budget to 0 and don't increase - // settled. - // - // Example: The same thing can happen with the opposite sign. Say you have - // -50 realized pnl - // -80 pnl overall - // +-30 unrealized pnl settle limit - // -40 realized pnl settle limit - // 0 settle limit used - // -70 available settle limit - // Settling -60 would result in - // 0 realized pnl - // -20 pnl overall - // +-30 unrealized pnl settle limit - // 0 realized pnl settle limit - // -60 settle limit used - // 0 available settle limit - // Which would mean no more unrealized pnl could be settled, when -10 more should be settleable! - // This function notices the realized pnl limit_change was 40 and adjusts the settle limit: - // +-30 unrealized pnl settle limit - // 0 realized pnl settle limit - // -20 settle limit used - // -10 available settle limit - - // Sometimes realized_pnl gets reduced by non-settles such as funding or fees. - // To avoid overcorrecting, the adjustment is limited to the realized_pnl change - // passed into this function. - let realized_pnl_change = realized_trade_pnl_change.round_to_zero().clamp_to_i64(); - let used_change = if limit_change >= 0 { - limit_change.min(realized_pnl_change).max(0) - } else { - limit_change.max(realized_pnl_change).min(0) - }; - - self.settle_pnl_limit_settled_in_current_window_native += used_change; + self.recurring_settle_pnl_allowance - before } /// Change the base and quote positions as the result of a trade + /// + /// Returns realized trade pnl pub fn record_trade( &mut self, perp_market: &mut PerpMarket, base_change: i64, quote_change_native: I80F48, - ) { + ) -> I80F48 { assert_eq!(perp_market.perp_market_index, self.market_index); - self.update_trade_stats(base_change, quote_change_native, perp_market); + let realized_pnl = self.update_trade_stats(base_change, quote_change_native, perp_market); self.change_base_position(perp_market, base_change); self.change_quote_position(quote_change_native); + self.apply_recurring_settle_pnl_allowance_constraint(perp_market); + realized_pnl } fn change_quote_position(&mut self, quote_change_native: I80F48) { @@ -709,13 +655,11 @@ impl PerpPosition { /// Returns the (min_pnl, max_pnl) range of quote-native pnl that can be settled this window. /// - /// It contains contributions from three factors: - /// - a fraction of the base position stable value, which gives settlement limit - /// equally in both directions - /// - the stored realized trade settle limit, which adds an extra settlement allowance - /// in a single direction - /// - the stored realized other settle limit, which adds an extra settlement allowance - /// in a single direction + /// 1. a fraction of the base position stable value, which gives settlement limit + /// equally in both directions + /// 2. the stored recurring settle allowance, which is mostly allowance from 1. that was + /// materialized when the position was reduced (see recurring_settle_pnl_allowance) + /// 3. once-only settlement allowance in a single direction (see oneshot_settle_pnl_allowance) pub fn settle_limit(&self, market: &PerpMarket) -> (i64, i64) { assert_eq!(self.market_index, market.perp_market_index); if market.settle_pnl_limit_factor < 0.0 { @@ -726,21 +670,16 @@ impl PerpPosition { let position_value = (market.stable_price() * base_native).abs().to_num::(); let unrealized = (market.settle_pnl_limit_factor as f64 * position_value).clamp_to_i64(); - let mut min_pnl = -unrealized; - let mut max_pnl = unrealized; + let mut max_pnl = unrealized + // abs() because of potential migration + + self.recurring_settle_pnl_allowance.abs(); + let mut min_pnl = -max_pnl; - let realized_trade = self.settle_pnl_limit_realized_trade; - if realized_trade >= 0 { - max_pnl = max_pnl.saturating_add(realized_trade); + let oneshot = self.oneshot_settle_pnl_allowance; + if oneshot >= 0 { + max_pnl = max_pnl.saturating_add(oneshot.ceil().clamp_to_i64()); } else { - min_pnl = min_pnl.saturating_add(realized_trade); - }; - - let realized_other = self.realized_other_pnl_native; - if realized_other >= 0 { - max_pnl = max_pnl.saturating_add(realized_other.ceil().clamp_to_i64()); - } else { - min_pnl = min_pnl.saturating_add(realized_other.floor().clamp_to_i64()); + min_pnl = min_pnl.saturating_add(oneshot.floor().clamp_to_i64()); }; // the min/max here is just for safety @@ -784,63 +723,64 @@ impl PerpPosition { /// Update the perp position for pnl settlement /// /// If `pnl` is positive, then that is settled away, deducting from the quote position. - pub fn record_settle(&mut self, settled_pnl: I80F48) { + pub fn record_settle(&mut self, settled_pnl: I80F48, perp_market: &PerpMarket) { self.change_quote_position(-settled_pnl); - // Settlement reduces realized_other_pnl first. - // Reduction only happens if settled_pnl has the same sign as realized_other_pnl. - let other_reduction = if settled_pnl > 0 { + // Settlement reduces oneshot_settle_pnl_allowance if available. + // Reduction only happens if settled_pnl has the same sign as oneshot_settle_pnl_allowance. + let oneshot_reduction = if settled_pnl > 0 { settled_pnl - .min(self.realized_other_pnl_native) + .min(self.oneshot_settle_pnl_allowance) .max(I80F48::ZERO) } else { settled_pnl - .max(self.realized_other_pnl_native) + .max(self.oneshot_settle_pnl_allowance) .min(I80F48::ZERO) }; - self.realized_other_pnl_native -= other_reduction; - let trade_and_unrealized_settlement = settled_pnl - other_reduction; + self.oneshot_settle_pnl_allowance -= oneshot_reduction; - // Then reduces realized_trade_pnl, similar to other_pnl above. - let trade_reduction = if trade_and_unrealized_settlement > 0 { - trade_and_unrealized_settlement - .min(self.realized_trade_pnl_native) - .max(I80F48::ZERO) - } else { - trade_and_unrealized_settlement - .max(self.realized_trade_pnl_native) - .min(I80F48::ZERO) - }; - self.realized_trade_pnl_native -= trade_reduction; - - // Consume settle limit budget: We don't track consumption of realized_other_pnl - // because settling it directly reduces its budget as well. - let settled_pnl_i64 = trade_and_unrealized_settlement + // Consume settle limit budget: + // We don't track consumption of oneshot_settle_pnl_allowance because settling already + // reduces the available budget for subsequent settlesas well. + let mut used_settle_limit = (settled_pnl - oneshot_reduction) .round_to_zero() .clamp_to_i64(); - self.settle_pnl_limit_settled_in_current_window_native += settled_pnl_i64; - self.apply_realized_trade_pnl_settle_limit_constraint(-trade_reduction) + // Similarly, if the recurring budget gets reduced (because stable-upnl is lower than it), + // don't also increase settle_pnl_limit_settled_in_current_window_native. + // Example: Settle 500 on a 1000 upnl, 1000 recurring limit account: + // -> 500 upnl and 500 recurring limit, if we also had 500 settled_in_current_window + // then no more settlement would be allowed + let recurring_allowance_change = + self.apply_recurring_settle_pnl_allowance_constraint(perp_market); + if recurring_allowance_change < 0 { + if used_settle_limit > 0 { + used_settle_limit = (used_settle_limit + recurring_allowance_change).max(0); + } else { + used_settle_limit = (used_settle_limit - recurring_allowance_change).min(0); + } + } + + self.settle_pnl_limit_settled_in_current_window_native += used_settle_limit; } /// Update perp position for a maker/taker fee payment pub fn record_trading_fee(&mut self, fee: I80F48) { self.change_quote_position(-fee); - self.realized_other_pnl_native -= fee; + self.oneshot_settle_pnl_allowance -= fee; self.realized_pnl_for_position_native -= fee; } /// Adds immediately-settleable realized pnl when a liqor takes over pnl during liquidation pub fn record_liquidation_quote_change(&mut self, change: I80F48) { self.change_quote_position(change); - self.realized_other_pnl_native += change; + self.oneshot_settle_pnl_allowance += change; } /// Adds to the quote position and adds a recurring ("realized trade") settle limit pub fn record_liquidation_pnl_takeover(&mut self, change: I80F48, recurring_limit: I80F48) { self.change_quote_position(change); - self.realized_trade_pnl_native += change; - self.increase_realized_trade_pnl_settle_limit(change, recurring_limit); + self.recurring_settle_pnl_allowance += recurring_limit.abs().ceil().to_num::(); } } @@ -956,13 +896,15 @@ mod tests { let mut market = test_perp_market(10.0); let mut pos = create_perp_position(&market, 0, 0); // Go long 10 @ 10 - pos.record_trade(&mut market, 10, I80F48::from(-100)); + let realized = pos.record_trade(&mut market, 10, I80F48::from(-100)); assert_eq!(pos.quote_running_native, -100); assert_eq!(pos.avg_entry_price(&market), 10.0); assert_eq!(pos.break_even_price(&market), 10.0); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(0)); - assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(0)); - assert_eq!(pos.settle_pnl_limit_realized_trade, 0); + assert_eq!(pos.realized_pnl_for_position_native, realized); + assert_eq!(realized, I80F48::ZERO); + assert_eq!(pos.oneshot_settle_pnl_allowance, I80F48::ZERO); + assert_eq!(pos.recurring_settle_pnl_allowance, 0); + assert_eq!(pos.deprecated_realized_trade_pnl_native, I80F48::from(0)); } #[test] @@ -970,13 +912,15 @@ mod tests { let mut market = test_perp_market(10.0); let mut pos = create_perp_position(&market, 0, 0); // Go short 10 @ 10 - pos.record_trade(&mut market, -10, I80F48::from(100)); + let realized = pos.record_trade(&mut market, -10, I80F48::from(100)); assert_eq!(pos.quote_running_native, 100); assert_eq!(pos.avg_entry_price(&market), 10.0); assert_eq!(pos.break_even_price(&market), 10.0); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(0)); - assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(0)); - assert_eq!(pos.settle_pnl_limit_realized_trade, 0); + assert_eq!(pos.realized_pnl_for_position_native, realized); + assert_eq!(realized, I80F48::ZERO); + assert_eq!(pos.oneshot_settle_pnl_allowance, I80F48::ZERO); + assert_eq!(pos.recurring_settle_pnl_allowance, 0); + assert_eq!(pos.deprecated_realized_trade_pnl_native, I80F48::from(0)); } #[test] @@ -984,13 +928,15 @@ mod tests { let mut market = test_perp_market(10.0); let mut pos = create_perp_position(&market, 10, 10); // Go long 10 @ 30 - pos.record_trade(&mut market, 10, I80F48::from(-300)); + let realized = pos.record_trade(&mut market, 10, I80F48::from(-300)); assert_eq!(pos.quote_running_native, -400); assert_eq!(pos.avg_entry_price(&market), 20.0); assert_eq!(pos.break_even_price(&market), 20.0); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(0)); - assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(0)); - assert_eq!(pos.settle_pnl_limit_realized_trade, 0); + assert_eq!(pos.realized_pnl_for_position_native, realized); + assert_eq!(realized, I80F48::ZERO); + assert_eq!(pos.oneshot_settle_pnl_allowance, I80F48::ZERO); + assert_eq!(pos.recurring_settle_pnl_allowance, 0); + assert_eq!(pos.deprecated_realized_trade_pnl_native, I80F48::from(0)); } #[test] @@ -998,13 +944,15 @@ mod tests { let mut market = test_perp_market(10.0); let mut pos = create_perp_position(&market, -10, 10); // Go short 10 @ 30 - pos.record_trade(&mut market, -10, I80F48::from(300)); + let realized = pos.record_trade(&mut market, -10, I80F48::from(300)); assert_eq!(pos.quote_running_native, 400); assert_eq!(pos.avg_entry_price(&market), 20.0); assert_eq!(pos.break_even_price(&market), 20.0); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(0)); - assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(0)); - assert_eq!(pos.settle_pnl_limit_realized_trade, 0); + assert_eq!(pos.realized_pnl_for_position_native, realized); + assert_eq!(realized, I80F48::ZERO); + assert_eq!(pos.oneshot_settle_pnl_allowance, I80F48::ZERO); + assert_eq!(pos.recurring_settle_pnl_allowance, 0); + assert_eq!(pos.deprecated_realized_trade_pnl_native, I80F48::from(0)); } #[test] @@ -1012,13 +960,15 @@ mod tests { let mut market = test_perp_market(10.0); let mut pos = create_perp_position(&market, -10, 10); // Go long 5 @ 50 - pos.record_trade(&mut market, 5, I80F48::from(-250)); + let realized = pos.record_trade(&mut market, 5, I80F48::from(-250)); assert_eq!(pos.quote_running_native, -150); assert_eq!(pos.avg_entry_price(&market), 10.0); // Entry price remains the same when decreasing assert_eq!(pos.break_even_price(&market), -30.0); // The short can't break even anymore - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(-200)); - assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(-200)); - assert_eq!(pos.settle_pnl_limit_realized_trade, -5 * 10 / 5 - 1); + assert_eq!(pos.realized_pnl_for_position_native, realized); + assert_eq!(realized, I80F48::from(-200)); + assert_eq!(pos.oneshot_settle_pnl_allowance, I80F48::ZERO); + assert_eq!(pos.recurring_settle_pnl_allowance, 11); // 5 * 10 * 0.2 rounded up + assert_eq!(pos.deprecated_realized_trade_pnl_native, I80F48::ZERO); } #[test] @@ -1026,13 +976,15 @@ mod tests { let mut market = test_perp_market(10.0); let mut pos = create_perp_position(&market, 10, 10); // Go short 5 @ 50 - pos.record_trade(&mut market, -5, I80F48::from(250)); + let realized = pos.record_trade(&mut market, -5, I80F48::from(250)); assert_eq!(pos.quote_running_native, 150); assert_eq!(pos.avg_entry_price(&market), 10.0); // Entry price remains the same when decreasing assert_eq!(pos.break_even_price(&market), -30.0); // Already broke even - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(200)); - assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(200)); - assert_eq!(pos.settle_pnl_limit_realized_trade, 5 * 10 / 5 + 1); + assert_eq!(pos.realized_pnl_for_position_native, realized); + assert_eq!(realized, I80F48::from(200)); + assert_eq!(pos.oneshot_settle_pnl_allowance, I80F48::ZERO); + assert_eq!(pos.recurring_settle_pnl_allowance, 11); // 5 * 10 * 0.2 rounded up + assert_eq!(pos.deprecated_realized_trade_pnl_native, I80F48::ZERO); } #[test] @@ -1040,13 +992,15 @@ mod tests { let mut market = test_perp_market(10.0); let mut pos = create_perp_position(&market, 10, 10); // Go short 10 @ 25 - pos.record_trade(&mut market, -10, I80F48::from(250)); + let realized = pos.record_trade(&mut market, -10, I80F48::from(250)); assert_eq!(pos.quote_running_native, 0); assert_eq!(pos.avg_entry_price(&market), 0.0); // Entry price zero when no position assert_eq!(pos.break_even_price(&market), 0.0); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(150)); assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(0)); - assert_eq!(pos.settle_pnl_limit_realized_trade, 10 * 10 / 5 + 1); + assert_eq!(realized, I80F48::from(150)); + assert_eq!(pos.oneshot_settle_pnl_allowance, I80F48::ZERO); + assert_eq!(pos.recurring_settle_pnl_allowance, 21); // 10 * 10 * 0.2 rounded up + assert_eq!(pos.deprecated_realized_trade_pnl_native, I80F48::ZERO); } #[test] @@ -1054,13 +1008,15 @@ mod tests { let mut market = test_perp_market(10.0); let mut pos = create_perp_position(&market, -10, 10); // Go long 10 @ 25 - pos.record_trade(&mut market, 10, I80F48::from(-250)); + let realized = pos.record_trade(&mut market, 10, I80F48::from(-250)); assert_eq!(pos.quote_running_native, 0); assert_eq!(pos.avg_entry_price(&market), 0.0); // Entry price zero when no position assert_eq!(pos.break_even_price(&market), 0.0); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(-150)); assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(0)); - assert_eq!(pos.settle_pnl_limit_realized_trade, -10 * 10 / 5 - 1); + assert_eq!(realized, I80F48::from(-150)); + assert_eq!(pos.oneshot_settle_pnl_allowance, I80F48::ZERO); + assert_eq!(pos.recurring_settle_pnl_allowance, 21); // 10 * 10 * 0.2 rounded up + assert_eq!(pos.deprecated_realized_trade_pnl_native, I80F48::ZERO); } #[test] @@ -1068,13 +1024,15 @@ mod tests { let mut market = test_perp_market(10.0); let mut pos = create_perp_position(&market, 10, 10); // Go short 15 @ 20 - pos.record_trade(&mut market, -15, I80F48::from(300)); + let realized = pos.record_trade(&mut market, -15, I80F48::from(300)); assert_eq!(pos.quote_running_native, 100); assert_eq!(pos.avg_entry_price(&market), 20.0); assert_eq!(pos.break_even_price(&market), 20.0); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(100)); - assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(0)); - assert_eq!(pos.settle_pnl_limit_realized_trade, 10 * 10 / 5 + 1); + assert_eq!(pos.realized_pnl_for_position_native, I80F48::ZERO); // new position + assert_eq!(realized, I80F48::from(100)); + assert_eq!(pos.oneshot_settle_pnl_allowance, I80F48::ZERO); + assert_eq!(pos.recurring_settle_pnl_allowance, 11); // 5 * 10 * 0.2 rounded up + assert_eq!(pos.deprecated_realized_trade_pnl_native, I80F48::ZERO); } #[test] @@ -1082,13 +1040,15 @@ mod tests { let mut market = test_perp_market(10.0); let mut pos = create_perp_position(&market, -10, 10); // Go long 15 @ 20 - pos.record_trade(&mut market, 15, I80F48::from(-300)); + let realized = pos.record_trade(&mut market, 15, I80F48::from(-300)); assert_eq!(pos.quote_running_native, -100); assert_eq!(pos.avg_entry_price(&market), 20.0); assert_eq!(pos.break_even_price(&market), 20.0); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(-100)); - assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(0)); - assert_eq!(pos.settle_pnl_limit_realized_trade, -10 * 10 / 5 - 1); + assert_eq!(pos.realized_pnl_for_position_native, I80F48::ZERO); // new position + assert_eq!(realized, I80F48::from(-100)); + assert_eq!(pos.oneshot_settle_pnl_allowance, I80F48::ZERO); + assert_eq!(pos.recurring_settle_pnl_allowance, 11); // 5 * 10 * 0.2 rounded up + assert_eq!(pos.deprecated_realized_trade_pnl_native, I80F48::ZERO); } #[test] @@ -1096,15 +1056,21 @@ mod tests { let mut market = test_perp_market(10.0); let mut pos = create_perp_position(&market, 0, 0); // Buy 11 @ 10,000 - pos.record_trade(&mut market, 11, I80F48::from(-11 * 10_000)); + let realized_buy = pos.record_trade(&mut market, 11, I80F48::from(-11 * 10_000)); // Sell 1 @ 12,000 - pos.record_trade(&mut market, -1, I80F48::from(12_000)); + let realized_sell = pos.record_trade(&mut market, -1, I80F48::from(12_000)); assert_eq!(pos.quote_running_native, -98_000); assert_eq!(pos.base_position_lots, 10); assert_eq!(pos.break_even_price(&market), 9_800.0); // We made 2k on the trade, so we can sell our contract up to a loss of 200 each - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(2_000)); - assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(2_000)); - assert_eq!(pos.settle_pnl_limit_realized_trade, 1 * 10 / 5 + 1); + assert_eq!( + pos.realized_pnl_for_position_native, + realized_buy + realized_sell + ); + assert_eq!(realized_buy, I80F48::ZERO); + assert_eq!(realized_sell, I80F48::from(2_000)); + assert_eq!(pos.oneshot_settle_pnl_allowance, I80F48::ZERO); + assert_eq!(pos.recurring_settle_pnl_allowance, 3); // 1 * 10 * 0.2 rounded up + assert_eq!(pos.deprecated_realized_trade_pnl_native, I80F48::ZERO); } #[test] @@ -1114,84 +1080,91 @@ mod tests { let mut pos = create_perp_position(&market, 0, 0); // Buy 110 @ 10,000 - pos.record_trade(&mut market, 11, I80F48::from(-11 * 10 * 10_000)); + let realized_buy = pos.record_trade(&mut market, 11, I80F48::from(-11 * 10 * 10_000)); // Sell 10 @ 12,000 - pos.record_trade(&mut market, -1, I80F48::from(1 * 10 * 12_000)); + let realized_sell = pos.record_trade(&mut market, -1, I80F48::from(1 * 10 * 12_000)); assert_eq!(pos.quote_running_native, -980_000); assert_eq!(pos.base_position_lots, 10); assert_eq!(pos.avg_entry_price_per_base_lot, 100_000.0); assert_eq!(pos.avg_entry_price(&market), 10_000.0); assert_eq!(pos.break_even_price(&market), 9_800.0); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(20_000)); - assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(20_000)); + + assert_eq!( + pos.realized_pnl_for_position_native, + realized_buy + realized_sell + ); + assert_eq!(realized_buy, I80F48::ZERO); + assert_eq!(realized_sell, I80F48::from(20_000)); + assert_eq!(pos.oneshot_settle_pnl_allowance, I80F48::ZERO); + assert_eq!(pos.recurring_settle_pnl_allowance, 21); // 10 * 10 * 0.2 rounded up + assert_eq!(pos.deprecated_realized_trade_pnl_native, I80F48::ZERO); } #[test] fn test_perp_realized_settle_limit_no_reduction() { - let mut market = test_perp_market(10.0); + let mut market = test_perp_market(10000.0); let mut pos = create_perp_position(&market, 0, 0); // Buy 11 @ 10,000 pos.record_trade(&mut market, 11, I80F48::from(-11 * 10_000)); // Sell 1 @ 11,000 pos.record_trade(&mut market, -1, I80F48::from(11_000)); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(1_000)); - assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(1_000)); - assert_eq!(pos.settle_pnl_limit_realized_trade, 1 * 10 / 5 + 1); + assert_eq!(pos.recurring_settle_pnl_allowance, 1000); // 1 * 10000 * 0.2 rounded up, limited by upnl! - // Sell 1 @ 11,000 -- increases limit - pos.record_trade(&mut market, -1, I80F48::from(11_000)); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(2_000)); - assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(2_000)); - assert_eq!(pos.settle_pnl_limit_realized_trade, 2 * (10 / 5 + 1)); + // Sell 1 @ 9,500 -- actually decreases because upnl goes down + pos.record_trade(&mut market, -1, I80F48::from(9_500)); + assert_eq!(pos.recurring_settle_pnl_allowance, 500); - // Sell 1 @ 9,000 -- a loss, but doesn't flip realized_trade_pnl_native sign, no change to limit - pos.record_trade(&mut market, -1, I80F48::from(9_000)); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(1_000)); - assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(1_000)); - assert_eq!(pos.settle_pnl_limit_realized_trade, 2 * (10 / 5 + 1)); + // Sell 2 @ 20,000 each -- not limited this time + pos.record_trade(&mut market, -2, I80F48::from(40_000)); + assert_eq!(pos.recurring_settle_pnl_allowance, 4501); - // Sell 1 @ 8,000 -- flips sign, changes pnl limit + // Buy 1 @ 9,000 -- decreases allowance + pos.record_trade(&mut market, 1, I80F48::from(-9_000)); + assert_eq!(pos.recurring_settle_pnl_allowance, 2501); + + // Sell 1 @ 8,000 -- increases limit + market.stable_price_model.stable_price = 8000.0; pos.record_trade(&mut market, -1, I80F48::from(8_000)); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(-1_000)); - assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(-1_000)); - assert_eq!(pos.settle_pnl_limit_realized_trade, -(1 * 10 / 5 + 1)); + assert_eq!(pos.recurring_settle_pnl_allowance, 4102); + + assert_eq!(pos.deprecated_realized_trade_pnl_native, I80F48::ZERO); } #[test] fn test_perp_trade_without_realized_pnl() { - let mut market = test_perp_market(10.0); + let mut market = test_perp_market(10_000.0); let mut pos = create_perp_position(&market, 0, 0); // Buy 11 @ 10,000 pos.record_trade(&mut market, 11, I80F48::from(-11 * 10_000)); // Sell 1 @ 10,000 - pos.record_trade(&mut market, -1, I80F48::from(10_000)); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(0)); + let realized = pos.record_trade(&mut market, -1, I80F48::from(10_000)); + assert_eq!(realized, I80F48::ZERO); assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(0)); - assert_eq!(pos.settle_pnl_limit_realized_trade, 0); + assert_eq!(pos.recurring_settle_pnl_allowance, 0); // Sell 10 @ 10,000 - pos.record_trade(&mut market, -10, I80F48::from(10 * 10_000)); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(0)); + let realized = pos.record_trade(&mut market, -10, I80F48::from(10 * 10_000)); + assert_eq!(realized, I80F48::ZERO); assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(0)); - assert_eq!(pos.settle_pnl_limit_realized_trade, 0); + assert_eq!(pos.recurring_settle_pnl_allowance, 0); assert_eq!(pos.base_position_lots, 0); assert_eq!(pos.quote_position_native, I80F48::ZERO); } #[test] - fn test_perp_realized_pnl_trade_other_separation() { - let mut market = test_perp_market(10.0); + fn test_perp_oneshot_settle_allowance() { + let mut market = test_perp_market(10_000.0); let mut pos = create_perp_position(&market, 0, 0); pos.record_trading_fee(I80F48::from(-70)); - assert_eq!(pos.realized_other_pnl_native, I80F48::from(70)); + assert_eq!(pos.oneshot_settle_pnl_allowance, I80F48::from(70)); pos.record_liquidation_quote_change(I80F48::from(30)); - assert_eq!(pos.realized_other_pnl_native, I80F48::from(100)); + assert_eq!(pos.oneshot_settle_pnl_allowance, I80F48::from(100)); // Buy 1 @ 10,000 pos.record_trade(&mut market, 1, I80F48::from(-1 * 10_000)); @@ -1199,10 +1172,16 @@ mod tests { // Sell 1 @ 11,000 pos.record_trade(&mut market, -1, I80F48::from(11_000)); - assert_eq!(pos.realized_other_pnl_native, I80F48::from(100)); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(1_000)); - assert_eq!(pos.realized_pnl_for_position_native, I80F48::from(0)); - assert_eq!(pos.settle_pnl_limit_realized_trade, 1 * 10 / 5 + 1); + assert_eq!(pos.oneshot_settle_pnl_allowance, I80F48::from(100)); + assert_eq!(pos.recurring_settle_pnl_allowance, 1100); // limited by upnl + + pos.record_settle(I80F48::from(50), &market); + assert_eq!(pos.oneshot_settle_pnl_allowance, I80F48::from(50)); + assert_eq!(pos.recurring_settle_pnl_allowance, 1050); + + pos.record_settle(I80F48::from(100), &market); + assert_eq!(pos.oneshot_settle_pnl_allowance, I80F48::from(0)); + assert_eq!(pos.recurring_settle_pnl_allowance, 950); } #[test] @@ -1217,20 +1196,19 @@ mod tests { pos.record_trade(&mut market, 2, I80F48::from(-2 * 2)); assert!((pos.avg_entry_price(&market) - 1.66666).abs() < 0.001); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(0)); // Sell 2 @ 4 - pos.record_trade(&mut market, -2, I80F48::from(2 * 4)); + let realized1 = pos.record_trade(&mut market, -2, I80F48::from(2 * 4)); assert!((pos.avg_entry_price(&market) - 1.66666).abs() < 0.001); - assert!((pos.realized_trade_pnl_native.to_num::() - 4.6666).abs() < 0.01); + assert!((realized1.to_num::() - 4.6666).abs() < 0.01); // Sell 1 @ 2 - pos.record_trade(&mut market, -1, I80F48::from(2)); + let realized2 = pos.record_trade(&mut market, -1, I80F48::from(2)); assert_eq!(pos.avg_entry_price(&market), 0.0); assert!((pos.quote_position_native.to_num::() - 5.1).abs() < 0.001); - assert!((pos.realized_trade_pnl_native.to_num::() - 5.1).abs() < 0.01); + assert!((realized2.to_num::() - 0.3333).abs() < 0.01); } #[test] @@ -1288,94 +1266,67 @@ mod tests { } #[test] - fn test_perp_realized_pnl_consumption() { + fn test_perp_settle_limit_allowance_consumption() { let market = test_perp_market(10.0); let mut pos = create_perp_position(&market, 0, 0); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(0)); - pos.settle_pnl_limit_realized_trade = 1000; - pos.realized_trade_pnl_native = I80F48::from(1500); - pos.record_settle(I80F48::from(10)); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(1490)); - assert_eq!(pos.settle_pnl_limit_realized_trade, 1000); + // setup some upnl so the recurring allowance isn't reduced immediately + pos.quote_position_native = I80F48::from(1100); + + pos.recurring_settle_pnl_allowance = 1000; + pos.record_settle(I80F48::from(10), &market); + assert_eq!(pos.recurring_settle_pnl_allowance, 1000); assert_eq!(pos.settle_pnl_limit_settled_in_current_window_native, 10); - pos.record_settle(I80F48::from(-2)); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(1490)); - assert_eq!(pos.settle_pnl_limit_realized_trade, 1000); + pos.record_settle(I80F48::from(-2), &market); + assert_eq!(pos.recurring_settle_pnl_allowance, 1000); assert_eq!(pos.settle_pnl_limit_settled_in_current_window_native, 8); - pos.record_settle(I80F48::from(1100)); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(390)); - assert_eq!(pos.settle_pnl_limit_realized_trade, 390); + pos.record_settle(I80F48::from(492), &market); + assert_eq!(pos.recurring_settle_pnl_allowance, 600); assert_eq!( pos.settle_pnl_limit_settled_in_current_window_native, - 8 + 1100 - (1000 - 390) + 8 + 492 - 400 ); - pos.settle_pnl_limit_realized_trade = 4; pos.settle_pnl_limit_settled_in_current_window_native = 0; - pos.realized_trade_pnl_native = I80F48::from(5); + pos.recurring_settle_pnl_allowance = 0; + pos.oneshot_settle_pnl_allowance = I80F48::from(4); assert_eq!(pos.available_settle_limit(&market), (0, 4)); - pos.record_settle(I80F48::from(-20)); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(5)); - assert_eq!(pos.settle_pnl_limit_realized_trade, 4); + pos.record_settle(I80F48::from(-20), &market); assert_eq!(pos.settle_pnl_limit_settled_in_current_window_native, -20); + assert_eq!(pos.oneshot_settle_pnl_allowance, I80F48::from(4)); assert_eq!(pos.available_settle_limit(&market), (0, 24)); - pos.record_settle(I80F48::from(2)); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(3)); - assert_eq!(pos.settle_pnl_limit_realized_trade, 3); - assert_eq!(pos.settle_pnl_limit_settled_in_current_window_native, -19); + pos.record_settle(I80F48::from(2), &market); + assert_eq!(pos.settle_pnl_limit_settled_in_current_window_native, -20); + assert_eq!(pos.oneshot_settle_pnl_allowance, I80F48::from(2)); assert_eq!(pos.available_settle_limit(&market), (0, 22)); - pos.record_settle(I80F48::from(10)); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(0)); - assert_eq!(pos.settle_pnl_limit_realized_trade, 0); - assert_eq!(pos.settle_pnl_limit_settled_in_current_window_native, -12); - assert_eq!(pos.available_settle_limit(&market), (0, 12)); + pos.record_settle(I80F48::from(4), &market); + assert_eq!(pos.settle_pnl_limit_settled_in_current_window_native, -18); + assert_eq!(pos.oneshot_settle_pnl_allowance, I80F48::from(0)); + assert_eq!(pos.available_settle_limit(&market), (0, 18)); - pos.realized_trade_pnl_native = I80F48::from(-5); - pos.settle_pnl_limit_realized_trade = -4; pos.settle_pnl_limit_settled_in_current_window_native = 0; - pos.record_settle(I80F48::from(20)); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(-5)); - assert_eq!(pos.settle_pnl_limit_realized_trade, -4); + pos.recurring_settle_pnl_allowance = 0; + pos.oneshot_settle_pnl_allowance = I80F48::from(-4); + assert_eq!(pos.available_settle_limit(&market), (-4, 0)); + pos.record_settle(I80F48::from(20), &market); assert_eq!(pos.settle_pnl_limit_settled_in_current_window_native, 20); + assert_eq!(pos.oneshot_settle_pnl_allowance, I80F48::from(-4)); + assert_eq!(pos.available_settle_limit(&market), (-24, 0)); - pos.record_settle(I80F48::from(-2)); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(-3)); - assert_eq!(pos.settle_pnl_limit_realized_trade, -3); - assert_eq!(pos.settle_pnl_limit_settled_in_current_window_native, 19); + pos.record_settle(I80F48::from(-2), &market); + assert_eq!(pos.settle_pnl_limit_settled_in_current_window_native, 20); + assert_eq!(pos.oneshot_settle_pnl_allowance, I80F48::from(-2)); + assert_eq!(pos.available_settle_limit(&market), (-22, 0)); - pos.record_settle(I80F48::from(-10)); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(0)); - assert_eq!(pos.settle_pnl_limit_realized_trade, 0); - assert_eq!(pos.settle_pnl_limit_settled_in_current_window_native, 12); - - pos.realized_other_pnl_native = I80F48::from(10); - pos.realized_trade_pnl_native = I80F48::from(25); - pos.settle_pnl_limit_realized_trade = 20; - pos.record_settle(I80F48::from(1)); - assert_eq!(pos.realized_other_pnl_native, I80F48::from(9)); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(25)); - assert_eq!(pos.settle_pnl_limit_realized_trade, 20); - assert_eq!(pos.settle_pnl_limit_settled_in_current_window_native, 12); - - pos.record_settle(I80F48::from(10)); - assert_eq!(pos.realized_other_pnl_native, I80F48::from(0)); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(24)); - assert_eq!(pos.settle_pnl_limit_realized_trade, 20); - assert_eq!(pos.settle_pnl_limit_settled_in_current_window_native, 13); - - pos.realized_other_pnl_native = I80F48::from(-10); - pos.realized_trade_pnl_native = I80F48::from(-25); - pos.settle_pnl_limit_realized_trade = -20; - pos.record_settle(I80F48::from(-1)); - assert_eq!(pos.realized_other_pnl_native, I80F48::from(-9)); - assert_eq!(pos.realized_trade_pnl_native, I80F48::from(-25)); - assert_eq!(pos.settle_pnl_limit_realized_trade, -20); + pos.record_settle(I80F48::from(-4), &market); + assert_eq!(pos.settle_pnl_limit_settled_in_current_window_native, 18); + assert_eq!(pos.oneshot_settle_pnl_allowance, I80F48::from(0)); + assert_eq!(pos.available_settle_limit(&market), (-18, 0)); } #[test] @@ -1411,94 +1362,45 @@ mod tests { let mut market = test_perp_market(0.5); let mut pos = create_perp_position(&market, 100, 1); - pos.realized_trade_pnl_native = I80F48::from(60); // no effect let limited_pnl = |pos: &PerpPosition, market: &PerpMarket, pnl: i64| { pos.apply_pnl_settle_limit(market, I80F48::from(pnl)) .to_num::() }; - pos.settle_pnl_limit_realized_trade = 5; - assert_eq!(pos.available_settle_limit(&market), (-10, 15)); // 0.2 factor * 0.5 stable price * 100 lots + 5 realized + assert_eq!(pos.available_settle_limit(&market), (-10, 10)); // 0.2 factor * 0.5 stable price * 100 lots + assert_eq!(limited_pnl(&pos, &market, 100), 10.0); + assert_eq!(limited_pnl(&pos, &market, -100), -10.0); + + pos.oneshot_settle_pnl_allowance = I80F48::from_num(-5); + assert_eq!(pos.available_settle_limit(&market), (-15, 10)); + assert_eq!(limited_pnl(&pos, &market, 100), 10.0); + assert_eq!(limited_pnl(&pos, &market, -100), -15.0); + + pos.oneshot_settle_pnl_allowance = I80F48::from_num(5); + assert_eq!(pos.available_settle_limit(&market), (-10, 15)); assert_eq!(limited_pnl(&pos, &market, 100), 15.0); assert_eq!(limited_pnl(&pos, &market, -100), -10.0); - pos.settle_pnl_limit_settled_in_current_window_native = 2; - assert_eq!(pos.available_settle_limit(&market), (-12, 13)); - assert_eq!(limited_pnl(&pos, &market, 100), 13.0); - assert_eq!(limited_pnl(&pos, &market, -100), -12.0); + pos.recurring_settle_pnl_allowance = 11; + assert_eq!(pos.available_settle_limit(&market), (-21, 26)); + assert_eq!(limited_pnl(&pos, &market, 100), 26.0); + assert_eq!(limited_pnl(&pos, &market, -100), -21.0); - pos.settle_pnl_limit_settled_in_current_window_native = 16; - assert_eq!(pos.available_settle_limit(&market), (-26, 0)); + pos.settle_pnl_limit_settled_in_current_window_native = 17; + assert_eq!(pos.available_settle_limit(&market), (-38, 9)); - pos.settle_pnl_limit_settled_in_current_window_native = -16; - assert_eq!(pos.available_settle_limit(&market), (0, 31)); + pos.settle_pnl_limit_settled_in_current_window_native = 27; + assert_eq!(pos.available_settle_limit(&market), (-48, 0)); - pos.settle_pnl_limit_realized_trade = 0; - pos.settle_pnl_limit_settled_in_current_window_native = 2; - assert_eq!(pos.available_settle_limit(&market), (-12, 8)); + pos.settle_pnl_limit_settled_in_current_window_native = -17; + assert_eq!(pos.available_settle_limit(&market), (-4, 43)); - pos.settle_pnl_limit_settled_in_current_window_native = -2; - assert_eq!(pos.available_settle_limit(&market), (-8, 12)); + pos.settle_pnl_limit_settled_in_current_window_native = -27; + assert_eq!(pos.available_settle_limit(&market), (0, 53)); + pos.settle_pnl_limit_settled_in_current_window_native = 0; market.stable_price_model.stable_price = 1.0; - assert_eq!(pos.available_settle_limit(&market), (-18, 22)); - - pos.settle_pnl_limit_realized_trade = 1000; - pos.settle_pnl_limit_settled_in_current_window_native = 2; - assert_eq!(pos.available_settle_limit(&market), (-22, 1018)); - - pos.realized_other_pnl_native = I80F48::from(5); - assert_eq!(pos.available_settle_limit(&market), (-22, 1023)); - - pos.realized_other_pnl_native = I80F48::from(-5); - assert_eq!(pos.available_settle_limit(&market), (-27, 1018)); - } - - #[test] - fn test_perp_reduced_realized_pnl_settle_limit() { - let market = test_perp_market(0.5); - let mut pos = create_perp_position(&market, 100, 1); - - let cases = vec![ - // No change if realized > limit - (0, (100, 50, 70, -200), (50, 70)), - // No change if realized > limit - (1, (100, 50, 70, 200), (50, 70)), - // No change if abs(realized) > abs(limit) - (2, (-100, -50, 70, -200), (-50, 70)), - // No change if abs(realized) > abs(limit) - (3, (-100, -50, 70, 200), (-50, 70)), - // reduction limited by realized change - (4, (40, 50, 70, -5), (40, 65)), - // reduction max - (5, (40, 50, 70, -15), (40, 60)), - // reduction, with realized change wrong direction - (6, (40, 50, 70, 15), (40, 70)), - // reduction limited by realized change - (7, (-40, -50, -70, 5), (-40, -65)), - // reduction max - (8, (-40, -50, -70, 15), (-40, -60)), - // reduction, with realized change wrong direction - (9, (-40, -50, -70, -15), (-40, -70)), - // reduction when used amount is opposite sign - (10, (-40, -50, 70, -15), (-40, 70)), - // reduction when used amount is opposite sign - (11, (-40, -50, 70, 15), (-40, 80)), - ]; - - for (i, (realized, realized_limit, used, change), (expected_limit, expected_used)) in cases - { - println!("test case {i}"); - pos.realized_trade_pnl_native = I80F48::from(realized); - pos.settle_pnl_limit_realized_trade = realized_limit; - pos.settle_pnl_limit_settled_in_current_window_native = used; - pos.apply_realized_trade_pnl_settle_limit_constraint(I80F48::from(change)); - assert_eq!(pos.settle_pnl_limit_realized_trade, expected_limit); - assert_eq!( - pos.settle_pnl_limit_settled_in_current_window_native, - expected_used - ); - } + assert_eq!(pos.available_settle_limit(&market), (-31, 36)); } } diff --git a/programs/mango-v4/tests/cases/test_liq_perps_bankruptcy.rs b/programs/mango-v4/tests/cases/test_liq_perps_bankruptcy.rs index d89980821..e3bb11f08 100644 --- a/programs/mango-v4/tests/cases/test_liq_perps_bankruptcy.rs +++ b/programs/mango-v4/tests/cases/test_liq_perps_bankruptcy.rs @@ -112,8 +112,8 @@ async fn test_liq_perps_bankruptcy() -> Result<(), TransportError> { taker_fee: 0.0, group_insurance_fund: true, // adjust this factur such that we get the desired settle limit in the end - settle_pnl_limit_factor: (settle_limit as f32 + 0.1).min(0.0) - / (-1.0 * 100.0 * adj_price) as f32, + settle_pnl_limit_factor: (settle_limit as f32 - 0.1).max(0.0) + / (1.0 * 100.0 * adj_price) as f32, settle_pnl_limit_window_size_ts: 24 * 60 * 60, ..PerpCreateMarketInstruction::with_new_book_and_queue(&solana, base_token).await }, @@ -227,7 +227,7 @@ async fn test_liq_perps_bankruptcy() -> Result<(), TransportError> { let account_data = solana.get_account::(account).await; assert_eq!(account_data.perps[0].quote_position_native(), pnl); assert_eq!( - account_data.perps[0].settle_pnl_limit_realized_trade, + account_data.perps[0].recurring_settle_pnl_allowance, settle_limit ); assert_eq!( @@ -277,7 +277,7 @@ async fn test_liq_perps_bankruptcy() -> Result<(), TransportError> { }; { - let (perp_market, account, liqor) = setup_perp(-28, -50, -10).await; + let (perp_market, account, liqor) = setup_perp(-28, -50, 10).await; let liqor_quote_before = account_position(solana, liqor, quote_token.bank).await; send_tx( @@ -310,7 +310,7 @@ async fn test_liq_perps_bankruptcy() -> Result<(), TransportError> { } { - let (perp_market, account, liqor) = setup_perp(-28, -50, -10).await; + let (perp_market, account, liqor) = setup_perp(-28, -50, 10).await; fund_insurance(2).await; let liqor_quote_before = account_position(solana, liqor, quote_token.bank).await; @@ -348,7 +348,7 @@ async fn test_liq_perps_bankruptcy() -> Result<(), TransportError> { } { - let (perp_market, account, liqor) = setup_perp(-28, -50, -10).await; + let (perp_market, account, liqor) = setup_perp(-28, -50, 10).await; fund_insurance(5).await; send_tx( @@ -371,7 +371,7 @@ async fn test_liq_perps_bankruptcy() -> Result<(), TransportError> { // no insurance { - let (perp_market, account, liqor) = setup_perp(-28, -50, -10).await; + let (perp_market, account, liqor) = setup_perp(-28, -50, 10).await; send_tx( solana, @@ -390,7 +390,7 @@ async fn test_liq_perps_bankruptcy() -> Result<(), TransportError> { // no settlement: no settle health { - let (perp_market, account, liqor) = setup_perp(-200, -50, -10).await; + let (perp_market, account, liqor) = setup_perp(-200, -50, 10).await; fund_insurance(5).await; send_tx( @@ -430,7 +430,7 @@ async fn test_liq_perps_bankruptcy() -> Result<(), TransportError> { // no socialized loss: fully covered by insurance fund { - let (perp_market, account, liqor) = setup_perp(-40, -50, -5).await; + let (perp_market, account, liqor) = setup_perp(-40, -50, 5).await; fund_insurance(42).await; send_tx( diff --git a/programs/mango-v4/tests/cases/test_liq_perps_base_and_bankruptcy.rs b/programs/mango-v4/tests/cases/test_liq_perps_base_and_bankruptcy.rs index 9381e9249..2a0c4ee8e 100644 --- a/programs/mango-v4/tests/cases/test_liq_perps_base_and_bankruptcy.rs +++ b/programs/mango-v4/tests/cases/test_liq_perps_base_and_bankruptcy.rs @@ -230,12 +230,12 @@ async fn test_liq_perps_base_and_bankruptcy() -> Result<(), TransportError> { 0.1 )); assert!(assert_equal( - liqee_data.perps[0].realized_trade_pnl_native, + liqee_data.perps[0].realized_pnl_for_position_native, liqee_amount - 1000.0, 0.1 )); // stable price is 1.0, so 0.2 * 1000 - assert_eq!(liqee_data.perps[0].settle_pnl_limit_realized_trade, -201); + assert_eq!(liqee_data.perps[0].recurring_settle_pnl_allowance, 201); assert!(assert_equal( perp_market_after.fees_accrued - perp_market_before.fees_accrued, liqor_amount - liqee_amount, @@ -521,7 +521,7 @@ async fn test_liq_perps_base_and_bankruptcy() -> Result<(), TransportError> { send_tx( solana, TokenWithdrawInstruction { - amount: liqee_quote_deposits_before as u64 - 100, + amount: liqee_quote_deposits_before as u64 - 200, allow_borrow: false, account: account_1, owner, @@ -572,9 +572,9 @@ async fn test_liq_perps_base_and_bankruptcy() -> Result<(), TransportError> { 0.1 )); assert!(assert_equal( - liqor_data.tokens[0].native(&settle_bank), - liqor_before.tokens[0].native(&settle_bank).to_num::() - - liqee_settle_limit_before as f64 * 100.0, // 100 is base lot size + liqor_data.tokens[1].native(&settle_bank), + liqor_before.tokens[1].native(&settle_bank).to_num::() + - liqee_settle_limit_before as f64, 0.1 )); diff --git a/programs/mango-v4/tests/cases/test_perp_settle.rs b/programs/mango-v4/tests/cases/test_perp_settle.rs index 06aaa6c06..cc3fbb956 100644 --- a/programs/mango-v4/tests/cases/test_perp_settle.rs +++ b/programs/mango-v4/tests/cases/test_perp_settle.rs @@ -1100,14 +1100,6 @@ async fn test_perp_pnl_settle_limit() -> Result<(), TransportError> { let mango_account_0 = solana.get_account::(account_0).await; let mango_account_1 = solana.get_account::(account_1).await; - assert_eq!( - mango_account_0.perps[0].realized_trade_pnl_native, - I80F48::from(200_000 - 80_000) - ); - assert_eq!( - mango_account_1.perps[0].realized_trade_pnl_native, - I80F48::from(-200_000 + 80_000) - ); // neither account has any settle limit left (check for 1 because of the ceil()ing) assert_eq!( mango_account_0.perps[0].available_settle_limit(&market).1, @@ -1119,7 +1111,7 @@ async fn test_perp_pnl_settle_limit() -> Result<(), TransportError> { ); // check that realized pnl settle limit was set up correctly assert_eq!( - mango_account_0.perps[0].settle_pnl_limit_realized_trade, + mango_account_0.perps[0].recurring_settle_pnl_allowance, (0.8 * 1.0 * 100.0 * 1000.0) as i64 + 1 ); // +1 just for rounding @@ -1152,7 +1144,7 @@ async fn test_perp_pnl_settle_limit() -> Result<(), TransportError> { // This time account 0's realized pnl settle limit kicks in. // let account_1_quote_before = mango_account_1.perps[0].quote_position_native(); - let account_0_realized_limit = mango_account_0.perps[0].settle_pnl_limit_realized_trade; + let account_0_realized_limit = mango_account_0.perps[0].recurring_settle_pnl_allowance; send_tx( solana, @@ -1186,12 +1178,13 @@ async fn test_perp_pnl_settle_limit() -> Result<(), TransportError> { mango_account_1.perps[0].quote_position_native() - account_1_quote_before, I80F48::from(account_0_realized_limit) ); - // account0's limit gets reduced to the realized pnl amount left over + // account0's limit gets reduced to the pnl amount left over + let perp_market_data = solana.get_account::(perp_market).await; assert_eq!( - mango_account_0.perps[0].settle_pnl_limit_realized_trade, + mango_account_0.perps[0].recurring_settle_pnl_allowance, mango_account_0.perps[0] - .realized_trade_pnl_native - .to_num::() + .unsettled_pnl(&perp_market_data, I80F48::from_num(1.0)) + .unwrap() ); // can't settle again @@ -1213,7 +1206,7 @@ async fn test_perp_pnl_settle_limit() -> Result<(), TransportError> { // let account_1_quote_before = mango_account_1.perps[0].quote_position_native(); - let account_0_realized_limit = mango_account_0.perps[0].settle_pnl_limit_realized_trade; + let account_0_realized_limit = mango_account_0.perps[0].recurring_settle_pnl_allowance; send_tx( solana, @@ -1248,13 +1241,13 @@ async fn test_perp_pnl_settle_limit() -> Result<(), TransportError> { I80F48::from(account_0_realized_limit) ); // account0's limit gets reduced to the realized pnl amount left over - assert_eq!(mango_account_0.perps[0].settle_pnl_limit_realized_trade, 0); + assert_eq!(mango_account_0.perps[0].recurring_settle_pnl_allowance, 0); assert_eq!( - mango_account_0.perps[0].realized_trade_pnl_native, + mango_account_0.perps[0].realized_pnl_for_position_native, I80F48::from(0) ); assert_eq!( - mango_account_1.perps[0].realized_trade_pnl_native, + mango_account_1.perps[0].realized_pnl_for_position_native, I80F48::from(0) ); diff --git a/ts/client/src/accounts/mangoAccount.ts b/ts/client/src/accounts/mangoAccount.ts index b26f84ffd..656268ff6 100644 --- a/ts/client/src/accounts/mangoAccount.ts +++ b/ts/client/src/accounts/mangoAccount.ts @@ -1325,9 +1325,9 @@ export class PerpPosition { dto.takerVolume, dto.perpSpotTransfers, dto.avgEntryPricePerBaseLot, - I80F48.from(dto.realizedTradePnlNative), - I80F48.from(dto.realizedOtherPnlNative), - dto.settlePnlLimitRealizedTrade, + I80F48.from(dto.deprecatedRealizedTradePnlNative), + I80F48.from(dto.oneshotSettlePnlAllowance), + dto.recurringSettlePnlAllowance, I80F48.from(dto.realizedPnlForPositionNative), ); } @@ -1380,9 +1380,9 @@ export class PerpPosition { public takerVolume: BN, public perpSpotTransfers: BN, public avgEntryPricePerBaseLot: number, - public realizedTradePnlNative: I80F48, - public realizedOtherPnlNative: I80F48, - public settlePnlLimitRealizedTrade: BN, + public deprecatedRealizedTradePnlNative: I80F48, + public oneshotSettlePnlAllowance: I80F48, + public recurringSettlePnlAllowance: BN, public realizedPnlForPositionNative: I80F48, ) {} @@ -1636,28 +1636,25 @@ export class PerpPosition { .mul(baseNative) .toNumber(); const unrealized = new BN(perpMarket.settlePnlLimitFactor * positionValue); + + let maxPnl = unrealized.add(this.recurringSettlePnlAllowance.abs()); + let minPnl = maxPnl.neg(); + + const oneshot = this.oneshotSettlePnlAllowance; + if (!oneshot.isNeg()) { + maxPnl = maxPnl.add(new BN(oneshot.ceil().toNumber())); + } else { + minPnl = minPnl.add(new BN(oneshot.floor().toNumber())); + } + const used = new BN( this.settlePnlLimitSettledInCurrentWindowNative.toNumber(), ); - let minPnl = unrealized.neg().sub(used); - let maxPnl = unrealized.sub(used); + const availableMin = BN.min(minPnl.sub(used), new BN(0)); + const availableMax = BN.max(maxPnl.sub(used), new BN(0)); - const realizedTrade = this.settlePnlLimitRealizedTrade; - if (realizedTrade.gte(new BN(0))) { - maxPnl = maxPnl.add(realizedTrade); - } else { - minPnl = minPnl.add(realizedTrade); - } - - const realizedOther = new BN(this.realizedOtherPnlNative.toNumber()); - if (realizedOther.gte(new BN(0))) { - maxPnl = maxPnl.add(realizedOther); - } else { - minPnl = minPnl.add(realizedOther); - } - - return [BN.min(minPnl, new BN(0)), BN.max(maxPnl, new BN(0))]; + return [availableMin, availableMax]; } public applyPnlSettleLimit(pnl: I80F48, perpMarket: PerpMarket): I80F48 { @@ -1782,8 +1779,10 @@ export class PerpPosition { this.getNotionalValueUi(perpMarket!).toString() + ', cumulative pnl over position lifetime ui - ' + this.cumulativePnlOverPositionLifetimeUi(perpMarket!).toString() + - ', realized other pnl native ui - ' + - toUiDecimalsForQuote(this.realizedOtherPnlNative) + + ', oneshot settleable native ui - ' + + toUiDecimalsForQuote(this.oneshotSettlePnlAllowance) + + ', recurring settleable native ui - ' + + toUiDecimalsForQuote(this.recurringSettlePnlAllowance) + ', cumulative long funding ui - ' + toUiDecimalsForQuote(this.cumulativeLongFunding) + ', cumulative short funding ui - ' + @@ -1812,9 +1811,9 @@ export class PerpPositionDto { public takerVolume: BN, public perpSpotTransfers: BN, public avgEntryPricePerBaseLot: number, - public realizedTradePnlNative: I80F48Dto, - public realizedOtherPnlNative: I80F48Dto, - public settlePnlLimitRealizedTrade: BN, + public deprecatedRealizedTradePnlNative: I80F48Dto, + public oneshotSettlePnlAllowance: I80F48Dto, + public recurringSettlePnlAllowance: BN, public realizedPnlForPositionNative: I80F48Dto, ) {} } diff --git a/ts/client/src/mango_v4.ts b/ts/client/src/mango_v4.ts index 61243c5ca..463d43468 100644 --- a/ts/client/src/mango_v4.ts +++ b/ts/client/src/mango_v4.ts @@ -9334,36 +9334,44 @@ export type MangoV4 = { "type": "f64" }, { - "name": "realizedTradePnlNative", + "name": "deprecatedRealizedTradePnlNative", "docs": [ - "Amount of pnl that was realized by bringing the base position closer to 0.", - "", - "The settlement of this type of pnl is limited by settle_pnl_limit_realized_trade.", - "Settling pnl reduces this value once other_pnl below is exhausted." + "Deprecated field: Amount of pnl that was realized by bringing the base position closer to 0." ], "type": { "defined": "I80F48" } }, { - "name": "realizedOtherPnlNative", + "name": "oneshotSettlePnlAllowance", "docs": [ - "Amount of pnl realized from fees, funding and liquidation.", + "Amount of pnl that can be settled once.", "", - "This type of realized pnl is always settleable.", - "Settling pnl reduces this value first." + "- The value is signed: a negative number means negative pnl can be settled.", + "- A settlement in the right direction will decrease this amount.", + "", + "Typically added for fees, funding and liquidation." ], "type": { "defined": "I80F48" } }, { - "name": "settlePnlLimitRealizedTrade", + "name": "recurringSettlePnlAllowance", "docs": [ - "Settle limit contribution from realized pnl.", + "Amount of pnl that can be settled in each settle window.", "", - "Every time pnl is realized, this is increased by a fraction of the stable", - "value of the realization. It magnitude decreases when realized pnl drops below its value." + "- Unsigned, the settlement can happen in both directions. Value is >= 0.", + "- Previously stored a similar value that was signed, so in migration cases", + "this value can be negative and should be .abs()ed.", + "- If this value exceeds the current stable-upnl, it should be decreased,", + "see apply_recurring_settle_pnl_allowance_constraint()", + "", + "When the base position is reduced, the settle limit contribution from the reduced", + "base position is materialized into this value. When the base position increases,", + "some of the allowance is taken away.", + "", + "This also gets increased when a liquidator takes over pnl." ], "type": "i64" }, @@ -23360,36 +23368,44 @@ export const IDL: MangoV4 = { "type": "f64" }, { - "name": "realizedTradePnlNative", + "name": "deprecatedRealizedTradePnlNative", "docs": [ - "Amount of pnl that was realized by bringing the base position closer to 0.", - "", - "The settlement of this type of pnl is limited by settle_pnl_limit_realized_trade.", - "Settling pnl reduces this value once other_pnl below is exhausted." + "Deprecated field: Amount of pnl that was realized by bringing the base position closer to 0." ], "type": { "defined": "I80F48" } }, { - "name": "realizedOtherPnlNative", + "name": "oneshotSettlePnlAllowance", "docs": [ - "Amount of pnl realized from fees, funding and liquidation.", + "Amount of pnl that can be settled once.", "", - "This type of realized pnl is always settleable.", - "Settling pnl reduces this value first." + "- The value is signed: a negative number means negative pnl can be settled.", + "- A settlement in the right direction will decrease this amount.", + "", + "Typically added for fees, funding and liquidation." ], "type": { "defined": "I80F48" } }, { - "name": "settlePnlLimitRealizedTrade", + "name": "recurringSettlePnlAllowance", "docs": [ - "Settle limit contribution from realized pnl.", + "Amount of pnl that can be settled in each settle window.", "", - "Every time pnl is realized, this is increased by a fraction of the stable", - "value of the realization. It magnitude decreases when realized pnl drops below its value." + "- Unsigned, the settlement can happen in both directions. Value is >= 0.", + "- Previously stored a similar value that was signed, so in migration cases", + "this value can be negative and should be .abs()ed.", + "- If this value exceeds the current stable-upnl, it should be decreased,", + "see apply_recurring_settle_pnl_allowance_constraint()", + "", + "When the base position is reduced, the settle limit contribution from the reduced", + "base position is materialized into this value. When the base position increases,", + "some of the allowance is taken away.", + "", + "This also gets increased when a liquidator takes over pnl." ], "type": "i64" }, From a4cddf312953321c7101d9876616f9880c156466 Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Mon, 5 Feb 2024 13:06:06 +0100 Subject: [PATCH 16/42] deposit limit: always allow obv1-selling deposits (#869) Previously serum3_place_order would fail when deposit limits were exhausted on the payer token side. Now the failure only happens when payer tokens need to be borrowed. --- .../src/instructions/serum3_place_order.rs | 21 ++++++--- programs/mango-v4/tests/cases/test_serum.rs | 44 ++++++++++++++++++- 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/programs/mango-v4/src/instructions/serum3_place_order.rs b/programs/mango-v4/src/instructions/serum3_place_order.rs index a894e8e50..725c72dd6 100644 --- a/programs/mango-v4/src/instructions/serum3_place_order.rs +++ b/programs/mango-v4/src/instructions/serum3_place_order.rs @@ -316,18 +316,13 @@ pub fn serum3_place_order( )? }; - // Deposit limit check: Placing an order can increase deposit limit use on both - // the payer and receiver bank. Imagine placing a bid for 500 base @ 0.5: it would - // use up 1000 quote and 500 base because either could be deposit on cancel/fill. - // This is why this must happen after update_bank_potential_tokens() and any withdraws. + // Deposit limit check, receiver side: + // Placing an order can always increase the receiver bank deposits on fill. { let receiver_bank = receiver_bank_ai.load::()?; receiver_bank .check_deposit_and_oo_limit() .with_context(|| std::format!("on {}", receiver_bank.name()))?; - payer_bank - .check_deposit_and_oo_limit() - .with_context(|| std::format!("on {}", payer_bank.name()))?; } // Payer bank safety checks like reduce-only, net borrows, vault-to-deposits ratio @@ -343,6 +338,18 @@ pub fn serum3_place_order( ); payer_bank.enforce_max_utilization_on_borrow()?; payer_bank.check_net_borrows(payer_bank_oracle)?; + + // Deposit limit check, payer side: + // The payer bank deposits could increase when cancelling the order later: + // Imagine the account borrowing payer tokens to place the order, repaying the borrows + // and then cancelling the order to create a deposit. + // + // However, if the account only decreases its deposits to place an order it can't + // worsen the situation and should always go through, even if payer deposit limits are + // already exceeded. + payer_bank + .check_deposit_and_oo_limit() + .with_context(|| std::format!("on {}", payer_bank.name()))?; } else { payer_bank.enforce_borrows_lte_deposits()?; } diff --git a/programs/mango-v4/tests/cases/test_serum.rs b/programs/mango-v4/tests/cases/test_serum.rs index 07bae9cda..20bf59fa3 100644 --- a/programs/mango-v4/tests/cases/test_serum.rs +++ b/programs/mango-v4/tests/cases/test_serum.rs @@ -1762,6 +1762,7 @@ async fn test_serum_deposit_limits() -> Result<(), TransportError> { // let deposit_amount = 5000; // for 10k tokens over both order_placers let CommonSetup { + serum_market_cookie, group_with_tokens, mut order_placer, quote_token, @@ -1879,11 +1880,15 @@ async fn test_serum_deposit_limits() -> Result<(), TransportError> { let remaining_quote = { || async { let b: Bank = solana2.get_account(quote_bank).await; - b.remaining_deposits_until_limit().round().to_num::() + b.remaining_deposits_until_limit().round().to_num::() } }; order_placer.cancel_all().await; + context + .serum + .consume_spot_events(&serum_market_cookie, &[order_placer.open_orders]) + .await; // // TEST: even when placing all quote tokens into a bid, they still count @@ -1907,6 +1912,43 @@ async fn test_serum_deposit_limits() -> Result<(), TransportError> { assert_mango_error(&r, MangoError::BankDepositLimit.into(), "dep limit".into()); order_placer.try_ask(5.0, 399).await.unwrap(); // not 400 due to rounding + // reset + order_placer.cancel_all().await; + context + .serum + .consume_spot_events(&serum_market_cookie, &[order_placer.open_orders]) + .await; + order_placer.settle().await; + + // + // TEST: can place a bid even if quote deposit limit is exhausted + // + send_tx( + solana, + TokenEdit { + group: group_with_tokens.group, + admin: group_with_tokens.admin, + mint: quote_token.mint.pubkey, + fallback_oracle: Pubkey::default(), + options: mango_v4::instruction::TokenEdit { + deposit_limit_opt: Some(1), + ..token_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); + assert!(remaining_quote().await < 0); + assert_eq!( + account_position(solana, order_placer.account, quote_token.bank).await, + 5000 + ); + // borrowing might lead to a deposit increase later + let r = order_placer.try_bid(1.0, 5001, false).await; + assert_mango_error(&r, MangoError::BankDepositLimit.into(), "dep limit".into()); + // but just selling deposits is fine + order_placer.try_bid(1.0, 4999, false).await.unwrap(); + Ok(()) } From 9f4cb0f7760f7ba50624d47d678ea9837e1cc659 Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Wed, 7 Feb 2024 11:42:01 +0100 Subject: [PATCH 17/42] Settler: Fix bad health accounts in tcs_start ix (#871) --- lib/client/src/client.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/lib/client/src/client.rs b/lib/client/src/client.rs index c65afa6e8..a3a5d0df7 100644 --- a/lib/client/src/client.rs +++ b/lib/client/src/client.rs @@ -1610,19 +1610,13 @@ impl MangoClient { account: (&Pubkey, &MangoAccountValue), token_conditional_swap_id: u64, ) -> anyhow::Result { - let mango_account = &self.mango_account().await?; let (tcs_index, tcs) = account .1 .token_conditional_swap_by_id(token_conditional_swap_id)?; let affected_tokens = vec![tcs.buy_token_index, tcs.sell_token_index]; let (health_remaining_ams, health_cu) = self - .derive_health_check_remaining_account_metas( - mango_account, - vec![], - affected_tokens, - vec![], - ) + .derive_health_check_remaining_account_metas(account.1, vec![], affected_tokens, vec![]) .await .unwrap(); From 712a2e3bd6d28212fab0e572e8499a13024d7bc8 Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Wed, 7 Feb 2024 12:50:35 +0100 Subject: [PATCH 18/42] Settler: Keep going on error (#873) in particular: don't panic when a health cache can't be constructed --- bin/settler/src/main.rs | 8 ++++-- bin/settler/src/settle.rs | 51 +++++++++++++++++++++++------------- bin/settler/src/tcs_start.rs | 24 +++++++++++++---- 3 files changed, 58 insertions(+), 25 deletions(-) diff --git a/bin/settler/src/main.rs b/bin/settler/src/main.rs index 4c6e06ade..31391a81b 100644 --- a/bin/settler/src/main.rs +++ b/bin/settler/src/main.rs @@ -308,7 +308,9 @@ async fn main() -> anyhow::Result<()> { account_addresses = state.mango_accounts.iter().cloned().collect(); } - settlement.settle(account_addresses).await.unwrap(); + if let Err(err) = settlement.settle(account_addresses).await { + warn!("settle error: {err:?}"); + } } } }); @@ -329,7 +331,9 @@ async fn main() -> anyhow::Result<()> { account_addresses = state.mango_accounts.iter().cloned().collect(); } - tcs_start.run_pass(account_addresses).await.unwrap(); + if let Err(err) = tcs_start.run_pass(account_addresses).await { + warn!("tcs-start error: {err:?}"); + } } } }); diff --git a/bin/settler/src/settle.rs b/bin/settler/src/settle.rs index 3534091d7..9d66f9214 100644 --- a/bin/settler/src/settle.rs +++ b/bin/settler/src/settle.rs @@ -96,7 +96,13 @@ impl SettlementState { let mut all_negative_settleable = HashMap::>::new(); for account_key in accounts.iter() { - let mut account = account_fetcher.fetch_mango_account(account_key)?; + let mut account = match account_fetcher.fetch_mango_account(account_key) { + Ok(acc) => acc, + Err(e) => { + info!("could not fetch account, skipping {account_key}: {e:?}"); + continue; + } + }; if account.fixed.group != mango_client.group() { continue; } @@ -111,10 +117,10 @@ impl SettlementState { continue; } - let health_cache = mango_client - .health_cache(&account) - .await - .context("creating health cache")?; + let health_cache = match mango_client.health_cache(&account).await { + Ok(hc) => hc, + Err(_) => continue, // Skip for stale/unconfident oracles + }; let liq_end_health = health_cache.health(HealthType::LiquidationEnd); for perp_market_index in perp_indexes { @@ -123,14 +129,19 @@ impl SettlementState { Some(v) => v, None => continue, // skip accounts with perp positions where we couldn't get the price and market }; - let perp_max_settle = - health_cache.perp_max_settle(perp_market.settle_token_index)?; + let perp_max_settle = health_cache + .perp_max_settle(perp_market.settle_token_index) + .expect("perp_max_settle always succeeds when the token index is valid"); - let perp_position = account.perp_position_mut(perp_market_index).unwrap(); + let perp_position = account + .perp_position_mut(perp_market_index) + .expect("index comes from active_perp_positions()"); perp_position.settle_funding(perp_market); perp_position.update_settle_limit(perp_market, now_ts); - let unsettled = perp_position.unsettled_pnl(perp_market, *perp_price)?; + let unsettled = perp_position + .unsettled_pnl(perp_market, *perp_price) + .expect("unsettled_pnl always succeeds with the right perp market"); let limited = perp_position.apply_pnl_settle_limit(perp_market, unsettled); let settleable = if limited >= 0 { limited @@ -157,7 +168,7 @@ impl SettlementState { liq_end_health, maint_health, ) - .unwrap(); + .expect("always ok"); // Assume that settle_fee_flat is near the tx fee, and if we can't possibly // make up for the tx fee even with multiple settle ix in one tx, skip. @@ -181,7 +192,9 @@ impl SettlementState { let address_lookup_tables = mango_client.mango_address_lookup_tables().await?; for (perp_market_index, mut positive_settleable) in all_positive_settleable { - let (perp_market, _, _) = perp_market_info.get(&perp_market_index).unwrap(); + let (perp_market, _, _) = perp_market_info + .get(&perp_market_index) + .expect("perp market must exist"); let negative_settleable = match all_negative_settleable.get_mut(&perp_market_index) { None => continue, Some(v) => v, @@ -286,14 +299,16 @@ impl<'a> SettleBatchProcessor<'a> { let send_result = self.mango_client.client.send_transaction(&tx).await; - if let Err(err) = send_result { - info!("error while sending settle batch: {}", err); - return Ok(None); + match send_result { + Ok(txsig) => { + info!("sent settle tx: {txsig}"); + Ok(Some(txsig)) + } + Err(err) => { + info!("error while sending settle batch: {}", err); + Ok(None) + } } - - let txsig = send_result.unwrap(); - info!("sent settle tx: {txsig}"); - Ok(Some(txsig)) } async fn add_and_maybe_send( diff --git a/bin/settler/src/tcs_start.rs b/bin/settler/src/tcs_start.rs index b10a2ac12..9f8e37011 100644 --- a/bin/settler/src/tcs_start.rs +++ b/bin/settler/src/tcs_start.rs @@ -57,7 +57,13 @@ impl State { let mut startable = vec![]; for account_key in accounts.iter() { - let account = account_fetcher.fetch_mango_account(account_key).unwrap(); + let account = match account_fetcher.fetch_mango_account(account_key) { + Ok(acc) => acc, + Err(e) => { + info!("could not fetch account, skipping {account_key}: {e:?}"); + continue; + } + }; if account.fixed.group != mango_client.group() { continue; } @@ -96,6 +102,11 @@ impl State { let mut ix_targets = vec![]; let mut liqor_account = mango_client.mango_account().await?; for (pubkey, tcs_id, incentive_token_index) in startable_chunk { + // can only batch until all token positions are full + if let Err(_) = liqor_account.ensure_token_position(*incentive_token_index) { + break; + } + let ixs = match self.make_start_ix(pubkey, *tcs_id).await { Ok(v) => v, Err(e) => { @@ -109,7 +120,6 @@ impl State { }; instructions.append(ixs); ix_targets.push((*pubkey, *tcs_id)); - liqor_account.ensure_token_position(*incentive_token_index)?; } // Clear newly created token positions, so the liqor account is mostly empty @@ -120,9 +130,13 @@ impl State { .collect_vec(); for token_index in new_token_pos_indices { let mint = mango_client.context.token(token_index).mint; - let ix = mango_client + let ix = match mango_client .token_withdraw_instructions(&liqor_account, mint, u64::MAX, false) - .await?; + .await + { + Ok(ix) => ix, + Err(_) => continue, + }; instructions.append(ix) } @@ -165,7 +179,7 @@ impl State { pubkey: &Pubkey, tcs_id: u64, ) -> anyhow::Result { - let account = self.account_fetcher.fetch_mango_account(pubkey).unwrap(); + let account = self.account_fetcher.fetch_mango_account(pubkey)?; self.mango_client .token_conditional_swap_start_instruction((pubkey, &account), tcs_id) .await From d9a9c7d66483dd67578a8b8b8587e8d12f069537 Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Wed, 7 Feb 2024 12:52:01 +0100 Subject: [PATCH 19/42] liquidation: Add flag to disable asset liquidation (#867) This can be used to remove the oracle dependency for tokens with zero maint asset weight that are not borrowable. --- bin/liquidator/src/liquidate.rs | 4 +- mango_v4.json | 25 +++++++- programs/mango-v4/src/error.rs | 2 + programs/mango-v4/src/health/cache.rs | 12 +++- programs/mango-v4/src/health/client.rs | 1 + .../mango-v4/src/instructions/token_edit.rs | 11 ++++ .../src/instructions/token_liq_with_token.rs | 4 ++ .../src/instructions/token_register.rs | 2 + .../instructions/token_register_trustless.rs | 1 + programs/mango-v4/src/lib.rs | 4 ++ programs/mango-v4/src/state/bank.rs | 17 +++++- .../mango-v4/tests/cases/test_liq_tokens.rs | 60 +++++++++++++++++++ .../tests/program_test/mango_client.rs | 2 + ts/client/src/accounts/bank.ts | 3 + ts/client/src/client.ts | 2 + ts/client/src/clientIxParamBuilder.ts | 4 ++ ts/client/src/mango_v4.ts | 50 +++++++++++++++- 17 files changed, 194 insertions(+), 10 deletions(-) diff --git a/bin/liquidator/src/liquidate.rs b/bin/liquidator/src/liquidate.rs index fc0b65984..e03d594c3 100644 --- a/bin/liquidator/src/liquidate.rs +++ b/bin/liquidator/src/liquidate.rs @@ -370,11 +370,11 @@ impl<'a> LiquidateHelper<'a> { .filter_map(|(ti, effective)| { // check constraints for liquidatable assets, see also has_possible_spot_liquidations() let tokens = ti.balance_spot.min(effective.spot_and_perp); - let is_valid_asset = tokens >= 1; + let is_valid_asset = tokens >= 1 && ti.allow_asset_liquidation; let quote_value = tokens * ti.prices.oracle; // prefer to liquidate tokens with asset weight that have >$1 liquidatable let is_preferred = - ti.init_asset_weight > 0 && quote_value > I80F48::from(1_000_000); + ti.maint_asset_weight > 0 && quote_value > I80F48::from(1_000_000); is_valid_asset.then_some((ti.token_index, is_preferred, quote_value)) }) .collect_vec(); diff --git a/mango_v4.json b/mango_v4.json index b3475028d..35363b714 100644 --- a/mango_v4.json +++ b/mango_v4.json @@ -631,6 +631,10 @@ { "name": "platformLiquidationFee", "type": "f32" + }, + { + "name": "disableAssetLiquidation", + "type": "bool" } ] }, @@ -1041,6 +1045,12 @@ "type": { "option": "f32" } + }, + { + "name": "disableAssetLiquidationOpt", + "type": { + "option": "bool" + } } ] }, @@ -7373,12 +7383,20 @@ "name": "forceClose", "type": "u8" }, + { + "name": "disableAssetLiquidation", + "docs": [ + "If set to 1, deposits cannot be liquidated when an account is liquidatable.", + "That means bankrupt accounts may still have assets of this type deposited." + ], + "type": "u8" + }, { "name": "padding", "type": { "array": [ "u8", - 6 + 5 ] } }, @@ -14028,6 +14046,11 @@ "code": 6068, "name": "MissingFeedForCLMMOracle", "msg": "Pyth USDC/USD or SOL/USD feed not found (required by CLMM oracle)" + }, + { + "code": 6069, + "name": "TokenAssetLiquidationDisabled", + "msg": "the asset does not allow liquidation" } ] } \ No newline at end of file diff --git a/programs/mango-v4/src/error.rs b/programs/mango-v4/src/error.rs index ed4702bfd..1859d26aa 100644 --- a/programs/mango-v4/src/error.rs +++ b/programs/mango-v4/src/error.rs @@ -143,6 +143,8 @@ pub enum MangoError { InvalidFeedForCLMMOracle, #[msg("Pyth USDC/USD or SOL/USD feed not found (required by CLMM oracle)")] MissingFeedForCLMMOracle, + #[msg("the asset does not allow liquidation")] + TokenAssetLiquidationDisabled, } impl MangoError { diff --git a/programs/mango-v4/src/health/cache.rs b/programs/mango-v4/src/health/cache.rs index 8d29215e3..4c9fa7c3e 100644 --- a/programs/mango-v4/src/health/cache.rs +++ b/programs/mango-v4/src/health/cache.rs @@ -175,6 +175,8 @@ pub struct TokenInfo { /// Includes TokenPosition and free Serum3OpenOrders balances. /// Does not include perp upnl or Serum3 reserved amounts. pub balance_spot: I80F48, + + pub allow_asset_liquidation: bool, } /// Temporary value used during health computations @@ -907,6 +909,7 @@ impl HealthCache { } /// Liquidatable spot assets mean: actual token deposits and also a positive effective token balance + /// and is available for asset liquidation pub fn has_liq_spot_assets(&self) -> bool { let health_token_balances = self.effective_token_balances(HealthType::LiquidationEnd); self.token_infos @@ -914,11 +917,11 @@ impl HealthCache { .zip(health_token_balances.iter()) .any(|(ti, b)| { // need 1 native token to use token_liq_with_token - ti.balance_spot >= 1 && b.spot_and_perp >= 1 + ti.balance_spot >= 1 && b.spot_and_perp >= 1 && ti.allow_asset_liquidation }) } - /// Liquidatable spot borrows mean: actual toen borrows plus a negative effective token balance + /// Liquidatable spot borrows mean: actual token borrows plus a negative effective token balance pub fn has_liq_spot_borrows(&self) -> bool { let health_token_balances = self.effective_token_balances(HealthType::LiquidationEnd); self.token_infos @@ -932,7 +935,9 @@ impl HealthCache { let health_token_balances = self.effective_token_balances(HealthType::LiquidationEnd); let all_iter = || self.token_infos.iter().zip(health_token_balances.iter()); all_iter().any(|(ti, b)| ti.balance_spot < 0 && b.spot_and_perp < 0) - && all_iter().any(|(ti, b)| ti.balance_spot >= 1 && b.spot_and_perp >= 1) + && all_iter().any(|(ti, b)| { + ti.balance_spot >= 1 && b.spot_and_perp >= 1 && ti.allow_asset_liquidation + }) } pub fn has_serum3_open_orders_funds(&self) -> bool { @@ -1286,6 +1291,7 @@ fn new_health_cache_impl( init_scaled_liab_weight: bank.scaled_init_liab_weight(liab_price), prices, balance_spot: native, + allow_asset_liquidation: bank.allows_asset_liquidation(), }); } diff --git a/programs/mango-v4/src/health/client.rs b/programs/mango-v4/src/health/client.rs index 0f0bcad5f..c7bad1009 100644 --- a/programs/mango-v4/src/health/client.rs +++ b/programs/mango-v4/src/health/client.rs @@ -682,6 +682,7 @@ mod tests { init_scaled_liab_weight: I80F48::from_num(1.0 + x), prices: Prices::new_single_price(I80F48::from_num(price)), balance_spot: I80F48::ZERO, + allow_asset_liquidation: true, } } diff --git a/programs/mango-v4/src/instructions/token_edit.rs b/programs/mango-v4/src/instructions/token_edit.rs index e44e6c2ee..e98d65014 100644 --- a/programs/mango-v4/src/instructions/token_edit.rs +++ b/programs/mango-v4/src/instructions/token_edit.rs @@ -53,6 +53,7 @@ pub fn token_edit( deposit_limit_opt: Option, zero_util_rate: Option, platform_liquidation_fee: Option, + disable_asset_liquidation_opt: Option, ) -> Result<()> { let group = ctx.accounts.group.load()?; @@ -484,6 +485,16 @@ pub fn token_edit( bank.platform_liquidation_fee = I80F48::from_num(platform_liquidation_fee); require_group_admin = true; } + + if let Some(disable_asset_liquidation) = disable_asset_liquidation_opt { + msg!( + "Asset liquidation disabled old {:?}, new {:?}", + bank.disable_asset_liquidation, + disable_asset_liquidation + ); + bank.disable_asset_liquidation = u8::from(disable_asset_liquidation); + require_group_admin = true; + } } // account constraint #1 diff --git a/programs/mango-v4/src/instructions/token_liq_with_token.rs b/programs/mango-v4/src/instructions/token_liq_with_token.rs index c40a3a3c7..c064e2216 100644 --- a/programs/mango-v4/src/instructions/token_liq_with_token.rs +++ b/programs/mango-v4/src/instructions/token_liq_with_token.rs @@ -112,6 +112,10 @@ pub(crate) fn liquidation_action( liqee.token_position_and_raw_index(asset_token_index)?; let liqee_asset_native = liqee_asset_position.native(asset_bank); require_gt!(liqee_asset_native, 0); + require!( + asset_bank.allows_asset_liquidation(), + MangoError::TokenAssetLiquidationDisabled + ); let (liqee_liab_position, liqee_liab_raw_index) = liqee.token_position_and_raw_index(liab_token_index)?; diff --git a/programs/mango-v4/src/instructions/token_register.rs b/programs/mango-v4/src/instructions/token_register.rs index e9b6f5b2f..d1a82adb8 100644 --- a/programs/mango-v4/src/instructions/token_register.rs +++ b/programs/mango-v4/src/instructions/token_register.rs @@ -44,6 +44,7 @@ pub fn token_register( deposit_limit: u64, zero_util_rate: f32, platform_liquidation_fee: f32, + disable_asset_liquidation: bool, ) -> Result<()> { // Require token 0 to be in the insurance token if token_index == INSURANCE_TOKEN_INDEX { @@ -109,6 +110,7 @@ pub fn token_register( deposit_weight_scale_start_quote, reduce_only, force_close: 0, + disable_asset_liquidation: u8::from(disable_asset_liquidation), padding: Default::default(), fees_withdrawn: 0, token_conditional_swap_taker_fee_rate, diff --git a/programs/mango-v4/src/instructions/token_register_trustless.rs b/programs/mango-v4/src/instructions/token_register_trustless.rs index fa2f9a3f1..54c5fdc78 100644 --- a/programs/mango-v4/src/instructions/token_register_trustless.rs +++ b/programs/mango-v4/src/instructions/token_register_trustless.rs @@ -90,6 +90,7 @@ pub fn token_register_trustless( deposit_weight_scale_start_quote: 5_000_000_000.0, // $5k reduce_only: 2, // deposit-only force_close: 0, + disable_asset_liquidation: 1, padding: Default::default(), fees_withdrawn: 0, token_conditional_swap_taker_fee_rate: 0.0, diff --git a/programs/mango-v4/src/lib.rs b/programs/mango-v4/src/lib.rs index bc5deb976..e3ef47a95 100644 --- a/programs/mango-v4/src/lib.rs +++ b/programs/mango-v4/src/lib.rs @@ -157,6 +157,7 @@ pub mod mango_v4 { deposit_limit: u64, zero_util_rate: f32, platform_liquidation_fee: f32, + disable_asset_liquidation: bool, ) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::token_register( @@ -190,6 +191,7 @@ pub mod mango_v4 { deposit_limit, zero_util_rate, platform_liquidation_fee, + disable_asset_liquidation, )?; Ok(()) } @@ -245,6 +247,7 @@ pub mod mango_v4 { deposit_limit_opt: Option, zero_util_rate_opt: Option, platform_liquidation_fee_opt: Option, + disable_asset_liquidation_opt: Option, ) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::token_edit( @@ -287,6 +290,7 @@ pub mod mango_v4 { deposit_limit_opt, zero_util_rate_opt, platform_liquidation_fee_opt, + disable_asset_liquidation_opt, )?; Ok(()) } diff --git a/programs/mango-v4/src/state/bank.rs b/programs/mango-v4/src/state/bank.rs index 7a9fbbde7..cafd731cb 100644 --- a/programs/mango-v4/src/state/bank.rs +++ b/programs/mango-v4/src/state/bank.rs @@ -158,8 +158,12 @@ pub struct Bank { pub reduce_only: u8, pub force_close: u8, + /// If set to 1, deposits cannot be liquidated when an account is liquidatable. + /// That means bankrupt accounts may still have assets of this type deposited. + pub disable_asset_liquidation: u8, + #[derivative(Debug = "ignore")] - pub padding: [u8; 6], + pub padding: [u8; 5], // Do separate bookkeping for how many tokens were withdrawn // This ensures that collected_fees_native is strictly increasing for stats gathering purposes @@ -346,7 +350,8 @@ impl Bank { deposit_weight_scale_start_quote: existing_bank.deposit_weight_scale_start_quote, reduce_only: existing_bank.reduce_only, force_close: existing_bank.force_close, - padding: [0; 6], + disable_asset_liquidation: existing_bank.disable_asset_liquidation, + padding: [0; 5], token_conditional_swap_taker_fee_rate: existing_bank .token_conditional_swap_taker_fee_rate, token_conditional_swap_maker_fee_rate: existing_bank @@ -396,6 +401,10 @@ impl Bank { require_gte!(self.maint_weight_shift_liab_target, 0.0); require_gte!(self.zero_util_rate, I80F48::ZERO); require_gte!(self.platform_liquidation_fee, 0.0); + if !self.allows_asset_liquidation() { + require!(self.are_borrows_reduce_only(), MangoError::SomeError); + require_eq!(self.maint_asset_weight, I80F48::ZERO); + } Ok(()) } @@ -417,6 +426,10 @@ impl Bank { self.force_close == 1 } + pub fn allows_asset_liquidation(&self) -> bool { + self.disable_asset_liquidation == 0 + } + #[inline(always)] pub fn native_borrows(&self) -> I80F48 { self.borrow_index * self.indexed_borrows diff --git a/programs/mango-v4/tests/cases/test_liq_tokens.rs b/programs/mango-v4/tests/cases/test_liq_tokens.rs index 56faf7cca..db049681d 100644 --- a/programs/mango-v4/tests/cases/test_liq_tokens.rs +++ b/programs/mango-v4/tests/cases/test_liq_tokens.rs @@ -324,6 +324,66 @@ async fn test_liq_tokens_with_token() -> Result<(), TransportError> { // set_bank_stub_oracle_price(solana, group, borrow_token1, admin, 2.0).await; + // + // TEST: can't liquidate if token has no asset weight + // + + send_tx( + solana, + TokenEdit { + group, + admin, + mint: collateral_token2.mint.pubkey, + fallback_oracle: Pubkey::default(), + options: mango_v4::instruction::TokenEdit { + maint_asset_weight_opt: Some(0.0), + init_asset_weight_opt: Some(0.0), + disable_asset_liquidation_opt: Some(true), + reduce_only_opt: Some(1), + ..token_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); + let res = send_tx( + solana, + TokenLiqWithTokenInstruction { + liqee: account, + liqor: vault_account, + liqor_owner: owner, + asset_token_index: collateral_token2.index, + liab_token_index: borrow_token2.index, + asset_bank_index: 0, + liab_bank_index: 0, + max_liab_transfer: I80F48::from_num(10000.0), + }, + ) + .await; + assert_mango_error( + &res, + MangoError::TokenAssetLiquidationDisabled.into(), + "liquidation disabled".to_string(), + ); + send_tx( + solana, + TokenEdit { + group, + admin, + mint: collateral_token2.mint.pubkey, + fallback_oracle: Pubkey::default(), + options: mango_v4::instruction::TokenEdit { + maint_asset_weight_opt: Some(0.8), + init_asset_weight_opt: Some(0.6), + disable_asset_liquidation_opt: Some(false), + reduce_only_opt: Some(0), + ..token_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); + // // TEST: liquidate borrow2 against too little collateral2 // diff --git a/programs/mango-v4/tests/program_test/mango_client.rs b/programs/mango-v4/tests/program_test/mango_client.rs index e0b5bcd13..e3a3c23cd 100644 --- a/programs/mango-v4/tests/program_test/mango_client.rs +++ b/programs/mango-v4/tests/program_test/mango_client.rs @@ -1077,6 +1077,7 @@ impl ClientInstruction for TokenRegisterInstruction { deposit_limit: 0, zero_util_rate: 0.0, platform_liquidation_fee: self.platform_liquidation_fee, + disable_asset_liquidation: false, }; let bank = Pubkey::find_program_address( @@ -1324,6 +1325,7 @@ pub fn token_edit_instruction_default() -> mango_v4::instruction::TokenEdit { deposit_limit_opt: None, zero_util_rate_opt: None, platform_liquidation_fee_opt: None, + disable_asset_liquidation_opt: None, } } diff --git a/ts/client/src/accounts/bank.ts b/ts/client/src/accounts/bank.ts index 4aead0732..9f4408c09 100644 --- a/ts/client/src/accounts/bank.ts +++ b/ts/client/src/accounts/bank.ts @@ -130,6 +130,7 @@ export class Bank implements BankForHealth { depositWeightScaleStartQuote: number; reduceOnly: number; forceClose: number; + disableAssetLiquidation: number; feesWithdrawn: BN; tokenConditionalSwapTakerFeeRate: number; tokenConditionalSwapMakerFeeRate: number; @@ -211,6 +212,7 @@ export class Bank implements BankForHealth { obj.zeroUtilRate, obj.platformLiquidationFee, obj.collectedLiquidationFees, + obj.disableAssetLiquidation == 0, ); } @@ -276,6 +278,7 @@ export class Bank implements BankForHealth { zeroUtilRate: I80F48Dto, platformLiquidationFee: I80F48Dto, collectedLiquidationFees: I80F48Dto, + public allowAssetLiquidation: boolean, ) { this.name = utf8.decode(new Uint8Array(name)).split('\x00')[0]; this.oracleConfig = { diff --git a/ts/client/src/client.ts b/ts/client/src/client.ts index 212b48650..24067689a 100644 --- a/ts/client/src/client.ts +++ b/ts/client/src/client.ts @@ -461,6 +461,7 @@ export class MangoClient { params.depositLimit, params.zeroUtilRate, params.platformLiquidationFee, + params.disableAssetLiquidation, ) .accounts({ group: group.publicKey, @@ -548,6 +549,7 @@ export class MangoClient { params.depositLimit, params.zeroUtilRate, params.platformLiquidationFee, + params.disableAssetLiquidation, ) .accounts({ group: group.publicKey, diff --git a/ts/client/src/clientIxParamBuilder.ts b/ts/client/src/clientIxParamBuilder.ts index 7a9255ede..20b83fe33 100644 --- a/ts/client/src/clientIxParamBuilder.ts +++ b/ts/client/src/clientIxParamBuilder.ts @@ -30,6 +30,7 @@ export interface TokenRegisterParams { depositLimit: BN; zeroUtilRate: number; platformLiquidationFee: number; + disableAssetLiquidation: boolean; } export const DefaultTokenRegisterParams: TokenRegisterParams = { @@ -70,6 +71,7 @@ export const DefaultTokenRegisterParams: TokenRegisterParams = { depositLimit: new BN(0), zeroUtilRate: 0.0, platformLiquidationFee: 0.0, + disableAssetLiquidation: false, }; export interface TokenEditParams { @@ -111,6 +113,7 @@ export interface TokenEditParams { depositLimit: BN | null; zeroUtilRate: number | null; platformLiquidationFee: number | null; + disableAssetLiquidation: boolean | null; } export const NullTokenEditParams: TokenEditParams = { @@ -152,6 +155,7 @@ export const NullTokenEditParams: TokenEditParams = { depositLimit: null, zeroUtilRate: null, platformLiquidationFee: null, + disableAssetLiquidation: null, }; export interface PerpEditParams { diff --git a/ts/client/src/mango_v4.ts b/ts/client/src/mango_v4.ts index 463d43468..ee363dabb 100644 --- a/ts/client/src/mango_v4.ts +++ b/ts/client/src/mango_v4.ts @@ -631,6 +631,10 @@ export type MangoV4 = { { "name": "platformLiquidationFee", "type": "f32" + }, + { + "name": "disableAssetLiquidation", + "type": "bool" } ] }, @@ -1041,6 +1045,12 @@ export type MangoV4 = { "type": { "option": "f32" } + }, + { + "name": "disableAssetLiquidationOpt", + "type": { + "option": "bool" + } } ] }, @@ -7373,12 +7383,20 @@ export type MangoV4 = { "name": "forceClose", "type": "u8" }, + { + "name": "disableAssetLiquidation", + "docs": [ + "If set to 1, deposits cannot be liquidated when an account is liquidatable.", + "That means bankrupt accounts may still have assets of this type deposited." + ], + "type": "u8" + }, { "name": "padding", "type": { "array": [ "u8", - 6 + 5 ] } }, @@ -14028,6 +14046,11 @@ export type MangoV4 = { "code": 6068, "name": "MissingFeedForCLMMOracle", "msg": "Pyth USDC/USD or SOL/USD feed not found (required by CLMM oracle)" + }, + { + "code": 6069, + "name": "TokenAssetLiquidationDisabled", + "msg": "the asset does not allow liquidation" } ] }; @@ -14665,6 +14688,10 @@ export const IDL: MangoV4 = { { "name": "platformLiquidationFee", "type": "f32" + }, + { + "name": "disableAssetLiquidation", + "type": "bool" } ] }, @@ -15075,6 +15102,12 @@ export const IDL: MangoV4 = { "type": { "option": "f32" } + }, + { + "name": "disableAssetLiquidationOpt", + "type": { + "option": "bool" + } } ] }, @@ -21407,12 +21440,20 @@ export const IDL: MangoV4 = { "name": "forceClose", "type": "u8" }, + { + "name": "disableAssetLiquidation", + "docs": [ + "If set to 1, deposits cannot be liquidated when an account is liquidatable.", + "That means bankrupt accounts may still have assets of this type deposited." + ], + "type": "u8" + }, { "name": "padding", "type": { "array": [ "u8", - 6 + 5 ] } }, @@ -28062,6 +28103,11 @@ export const IDL: MangoV4 = { "code": 6068, "name": "MissingFeedForCLMMOracle", "msg": "Pyth USDC/USD or SOL/USD feed not found (required by CLMM oracle)" + }, + { + "code": 6069, + "name": "TokenAssetLiquidationDisabled", + "msg": "the asset does not allow liquidation" } ] }; From ea91d9d353debc75aa599ea6dc3c08813776f9d0 Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Wed, 7 Feb 2024 12:52:32 +0100 Subject: [PATCH 20/42] rust client: optionally derive prio fees from feed (#866) This allows connecting to a lite-rpc feed to receive block priority updates and computing recently good priority fee values based on that. --- Cargo.lock | 2 + bin/cli/src/main.rs | 11 +- bin/keeper/src/crank.rs | 3 + bin/keeper/src/main.rs | 31 ++-- bin/keeper/src/taker.rs | 8 +- bin/liquidator/src/main.rs | 44 +++-- bin/liquidator/src/trigger_tcs.rs | 6 +- bin/settler/src/main.rs | 28 ++-- bin/settler/src/settle.rs | 2 +- lib/client/Cargo.toml | 2 + lib/client/src/client.rs | 36 ++++- lib/client/src/jupiter/v4.rs | 7 +- lib/client/src/jupiter/v6.rs | 7 +- lib/client/src/lib.rs | 2 + lib/client/src/priority_fees.rs | 240 ++++++++++++++++++++++++++++ lib/client/src/priority_fees_cli.rs | 80 ++++++++++ 16 files changed, 456 insertions(+), 53 deletions(-) create mode 100644 lib/client/src/priority_fees.rs create mode 100644 lib/client/src/priority_fees_cli.rs diff --git a/Cargo.lock b/Cargo.lock index b4a48109e..26b1068d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3445,6 +3445,7 @@ dependencies = [ "atty", "base64 0.13.1", "bincode", + "clap 3.2.25", "derive_builder", "fixed 1.11.0 (git+https://github.com/blockworks-foundation/fixed.git?branch=v1.11.0-borsh0_10-mango)", "futures 0.3.28", @@ -3469,6 +3470,7 @@ dependencies = [ "thiserror", "tokio", "tokio-stream", + "tokio-tungstenite 0.17.2", "tracing", "tracing-subscriber", ] diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index a27e0245c..90b3a74f6 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -137,10 +137,13 @@ impl Rpc { .cluster(anchor_client::Cluster::from_str(&self.url)?) .commitment(solana_sdk::commitment_config::CommitmentConfig::confirmed()) .fee_payer(Some(Arc::new(fee_payer))) - .transaction_builder_config(TransactionBuilderConfig { - prioritization_micro_lamports: Some(5), - compute_budget_per_instruction: Some(250_000), - }) + .transaction_builder_config( + TransactionBuilderConfig::builder() + .prioritization_micro_lamports(Some(5)) + .compute_budget_per_instruction(Some(250_000)) + .build() + .unwrap(), + ) .build() .unwrap()) } diff --git a/bin/keeper/src/crank.rs b/bin/keeper/src/crank.rs index 90569d765..8082f41a8 100644 --- a/bin/keeper/src/crank.rs +++ b/bin/keeper/src/crank.rs @@ -12,6 +12,7 @@ use solana_sdk::{ instruction::{AccountMeta, Instruction}, pubkey::Pubkey, }; +use tokio::task::JoinHandle; use tracing::*; use warp::Filter; @@ -80,6 +81,7 @@ pub async fn runner( interval_consume_events: u64, interval_update_funding: u64, interval_check_for_changes_and_abort: u64, + extra_jobs: Vec>, ) -> Result<(), anyhow::Error> { let handles1 = mango_client .context @@ -144,6 +146,7 @@ pub async fn runner( ), serve_metrics(), debugging_handle, + futures::future::join_all(extra_jobs), ); Ok(()) diff --git a/bin/keeper/src/main.rs b/bin/keeper/src/main.rs index 02f374f19..dad08649e 100644 --- a/bin/keeper/src/main.rs +++ b/bin/keeper/src/main.rs @@ -8,7 +8,8 @@ use anchor_client::Cluster; use clap::{Parser, Subcommand}; use mango_v4_client::{ - keypair_from_cli, Client, FallbackOracleConfig, MangoClient, TransactionBuilderConfig, + keypair_from_cli, priority_fees_cli, Client, FallbackOracleConfig, MangoClient, + TransactionBuilderConfig, }; use solana_sdk::commitment_config::CommitmentConfig; use solana_sdk::pubkey::Pubkey; @@ -63,9 +64,12 @@ struct Cli { #[clap(long, env, default_value_t = 10)] timeout: u64, - /// prioritize each transaction with this many microlamports/cu - #[clap(long, env, default_value = "0")] - prioritization_micro_lamports: u64, + #[clap(flatten)] + prioritization_fee_cli: priority_fees_cli::PriorityFeeArgs, + + /// url to the lite-rpc websocket, optional + #[clap(long, env, default_value = "")] + lite_rpc_url: String, } #[derive(Subcommand, Debug, Clone)] @@ -87,6 +91,10 @@ async fn main() -> Result<(), anyhow::Error> { }; let cli = Cli::parse_from(args); + let (prio_provider, prio_jobs) = cli + .prioritization_fee_cli + .make_prio_provider(cli.lite_rpc_url.clone())?; + let owner = Arc::new(keypair_from_cli(&cli.owner)); let rpc_url = cli.rpc_url; @@ -105,11 +113,13 @@ async fn main() -> Result<(), anyhow::Error> { .commitment(commitment) .fee_payer(Some(owner.clone())) .timeout(Duration::from_secs(cli.timeout)) - .transaction_builder_config(TransactionBuilderConfig { - prioritization_micro_lamports: (cli.prioritization_micro_lamports > 0) - .then_some(cli.prioritization_micro_lamports), - compute_budget_per_instruction: None, - }) + .transaction_builder_config( + TransactionBuilderConfig::builder() + .priority_fee_provider(prio_provider) + .compute_budget_per_instruction(None) + .build() + .unwrap(), + ) .fallback_oracle_config(FallbackOracleConfig::Never) .build() .unwrap(), @@ -143,12 +153,13 @@ async fn main() -> Result<(), anyhow::Error> { cli.interval_consume_events, cli.interval_update_funding, cli.interval_check_new_listings_and_abort, + prio_jobs, ) .await } Command::Taker { .. } => { let client = mango_client.clone(); - taker::runner(client, debugging_handle).await + taker::runner(client, debugging_handle, prio_jobs).await } } } diff --git a/bin/keeper/src/taker.rs b/bin/keeper/src/taker.rs index 90a56d0bb..70f024df0 100644 --- a/bin/keeper/src/taker.rs +++ b/bin/keeper/src/taker.rs @@ -10,13 +10,15 @@ use mango_v4::{ accounts_ix::{Serum3OrderType, Serum3SelfTradeBehavior, Serum3Side}, state::TokenIndex, }; +use tokio::task::JoinHandle; use tracing::*; use crate::MangoClient; pub async fn runner( mango_client: Arc, - _debugging_handle: impl Future, + debugging_handle: impl Future, + extra_jobs: Vec>, ) -> Result<(), anyhow::Error> { ensure_deposit(&mango_client).await?; ensure_oo(&mango_client).await?; @@ -53,7 +55,9 @@ pub async fn runner( futures::join!( futures::future::join_all(handles1), - futures::future::join_all(handles2) + futures::future::join_all(handles2), + debugging_handle, + futures::future::join_all(extra_jobs), ); Ok(()) diff --git a/bin/liquidator/src/main.rs b/bin/liquidator/src/main.rs index 2d4e1cc1d..ed091e55f 100644 --- a/bin/liquidator/src/main.rs +++ b/bin/liquidator/src/main.rs @@ -7,6 +7,7 @@ use anchor_client::Cluster; use anyhow::Context; use clap::Parser; use mango_v4::state::{PerpMarketIndex, TokenIndex}; +use mango_v4_client::priority_fees_cli; use mango_v4_client::AsyncChannelSendUnlessFull; use mango_v4_client::{ account_update_stream, chain_data, error_tracking::ErrorTracking, jupiter, keypair_from_cli, @@ -148,9 +149,12 @@ struct Cli { #[clap(long, env, value_enum, default_value = "swap-sell-into-buy")] tcs_mode: TcsMode, - /// prioritize each transaction with this many microlamports/cu - #[clap(long, env, default_value = "0")] - prioritization_micro_lamports: u64, + #[clap(flatten)] + prioritization_fee_cli: priority_fees_cli::PriorityFeeArgs, + + /// url to the lite-rpc websocket, optional + #[clap(long, env, default_value = "")] + lite_rpc_url: String, /// compute limit requested for liquidation instructions #[clap(long, env, default_value = "250000")] @@ -189,20 +193,31 @@ pub fn encode_address(addr: &Pubkey) -> String { async fn main() -> anyhow::Result<()> { mango_v4_client::tracing_subscriber_init(); - let args = if let Ok(cli_dotenv) = CliDotenv::try_parse() { + let args: Vec = if let Ok(cli_dotenv) = CliDotenv::try_parse() { dotenv::from_path(cli_dotenv.dotenv)?; - cli_dotenv.remaining_args + std::env::args_os() + .take(1) + .chain(cli_dotenv.remaining_args.into_iter()) + .collect() } else { dotenv::dotenv().ok(); std::env::args_os().collect() }; let cli = Cli::parse_from(args); - let liqor_owner = Arc::new(keypair_from_cli(&cli.liqor_owner)); + // + // Priority fee setup + // + let (prio_provider, prio_jobs) = cli + .prioritization_fee_cli + .make_prio_provider(cli.lite_rpc_url.clone())?; + // + // Client setup + // + let liqor_owner = Arc::new(keypair_from_cli(&cli.liqor_owner)); let rpc_url = cli.rpc_url; let ws_url = rpc_url.replace("https", "wss"); - let rpc_timeout = Duration::from_secs(10); let cluster = Cluster::Custom(rpc_url.clone(), ws_url.clone()); let commitment = CommitmentConfig::processed(); @@ -214,12 +229,14 @@ async fn main() -> anyhow::Result<()> { .jupiter_v4_url(cli.jupiter_v4_url) .jupiter_v6_url(cli.jupiter_v6_url) .jupiter_token(cli.jupiter_token) - .transaction_builder_config(TransactionBuilderConfig { - prioritization_micro_lamports: (cli.prioritization_micro_lamports > 0) - .then_some(cli.prioritization_micro_lamports), - // Liquidation and tcs triggers set their own budgets, this is a default for other tx - compute_budget_per_instruction: Some(250_000), - }) + .transaction_builder_config( + TransactionBuilderConfig::builder() + .priority_fee_provider(prio_provider) + // Liquidation and tcs triggers set their own budgets, this is a default for other tx + .compute_budget_per_instruction(Some(250_000)) + .build() + .unwrap(), + ) .override_send_transaction_urls(cli.override_send_transaction_url) .build() .unwrap(); @@ -584,6 +601,7 @@ async fn main() -> anyhow::Result<()> { check_changes_for_abort_job, ] .into_iter() + .chain(prio_jobs.into_iter()) .collect(); jobs.next().await; diff --git a/bin/liquidator/src/trigger_tcs.rs b/bin/liquidator/src/trigger_tcs.rs index c8b914698..7e3f00203 100644 --- a/bin/liquidator/src/trigger_tcs.rs +++ b/bin/liquidator/src/trigger_tcs.rs @@ -14,7 +14,7 @@ use mango_v4::{ use mango_v4_client::{chain_data, jupiter, MangoClient, TransactionBuilder}; use anyhow::Context as AnyhowContext; -use solana_sdk::{signature::Signature, signer::Signer}; +use solana_sdk::signature::Signature; use tracing::*; use {fixed::types::I80F48, solana_sdk::pubkey::Pubkey}; @@ -1168,10 +1168,8 @@ impl Context { let fee_payer = self.mango_client.client.fee_payer(); TransactionBuilder { instructions: vec![compute_ix], - address_lookup_tables: vec![], - payer: fee_payer.pubkey(), signers: vec![self.mango_client.owner.clone(), fee_payer], - config: self.mango_client.client.config().transaction_builder_config, + ..self.mango_client.transaction_builder().await? } }; diff --git a/bin/settler/src/main.rs b/bin/settler/src/main.rs index 31391a81b..57f408a21 100644 --- a/bin/settler/src/main.rs +++ b/bin/settler/src/main.rs @@ -6,8 +6,8 @@ use anchor_client::Cluster; use clap::Parser; use mango_v4::state::{PerpMarketIndex, TokenIndex}; use mango_v4_client::{ - account_update_stream, chain_data, keypair_from_cli, snapshot_source, websocket_source, Client, - MangoClient, MangoGroupContext, TransactionBuilderConfig, + account_update_stream, chain_data, keypair_from_cli, priority_fees_cli, snapshot_source, + websocket_source, Client, MangoClient, MangoGroupContext, TransactionBuilderConfig, }; use tracing::*; @@ -61,9 +61,12 @@ struct Cli { #[clap(long, env, default_value = "100")] get_multiple_accounts_count: usize, - /// prioritize each transaction with this many microlamports/cu - #[clap(long, env, default_value = "0")] - prioritization_micro_lamports: u64, + #[clap(flatten)] + prioritization_fee_cli: priority_fees_cli::PriorityFeeArgs, + + /// url to the lite-rpc websocket, optional + #[clap(long, env, default_value = "")] + lite_rpc_url: String, /// compute budget for each instruction #[clap(long, env, default_value = "250000")] @@ -87,6 +90,10 @@ async fn main() -> anyhow::Result<()> { }; let cli = Cli::parse_from(args); + let (prio_provider, prio_jobs) = cli + .prioritization_fee_cli + .make_prio_provider(cli.lite_rpc_url.clone())?; + let settler_owner = Arc::new(keypair_from_cli(&cli.settler_owner)); let rpc_url = cli.rpc_url; @@ -100,11 +107,11 @@ async fn main() -> anyhow::Result<()> { commitment, settler_owner.clone(), Some(rpc_timeout), - TransactionBuilderConfig { - prioritization_micro_lamports: (cli.prioritization_micro_lamports > 0) - .then_some(cli.prioritization_micro_lamports), - compute_budget_per_instruction: Some(cli.compute_budget_per_instruction), - }, + TransactionBuilderConfig::builder() + .compute_budget_per_instruction(Some(cli.compute_budget_per_instruction)) + .priority_fee_provider(prio_provider) + .build() + .unwrap(), ); // The representation of current on-chain account data @@ -352,6 +359,7 @@ async fn main() -> anyhow::Result<()> { check_changes_for_abort_job, ] .into_iter() + .chain(prio_jobs.into_iter()) .collect(); jobs.next().await; diff --git a/bin/settler/src/settle.rs b/bin/settler/src/settle.rs index 9d66f9214..edd84c700 100644 --- a/bin/settler/src/settle.rs +++ b/bin/settler/src/settle.rs @@ -284,7 +284,7 @@ impl<'a> SettleBatchProcessor<'a> { address_lookup_tables: self.address_lookup_tables.clone(), payer: fee_payer.pubkey(), signers: vec![fee_payer], - config: client.config().transaction_builder_config, + config: client.config().transaction_builder_config.clone(), } .transaction_with_blockhash(self.blockhash) } diff --git a/lib/client/Cargo.toml b/lib/client/Cargo.toml index b986fd0bf..cc1c29a9d 100644 --- a/lib/client/Cargo.toml +++ b/lib/client/Cargo.toml @@ -15,6 +15,7 @@ async-channel = "1.6" async-once-cell = { version = "0.4.2", features = ["unpin"] } async-trait = "0.1.52" atty = "0.2" +clap = { version = "3.1.8", features = ["derive", "env"] } derive_builder = "0.12.0" fixed = { workspace = true, features = ["serde", "borsh"] } futures = "0.3.25" @@ -38,6 +39,7 @@ thiserror = "1.0.31" reqwest = "0.11.17" tokio = { version = "1", features = ["full"] } tokio-stream = { version = "0.1.9"} +tokio-tungstenite = "0.17.0" serde = "1.0.141" serde_json = "1.0.82" base64 = "0.13.0" diff --git a/lib/client/src/client.rs b/lib/client/src/client.rs index a3a5d0df7..b1584d197 100644 --- a/lib/client/src/client.rs +++ b/lib/client/src/client.rs @@ -29,6 +29,7 @@ use crate::confirm_transaction::{wait_for_transaction_confirmation, RpcConfirmTr use crate::context::MangoGroupContext; use crate::gpa::{fetch_anchor_account, fetch_mango_accounts}; use crate::health_cache; +use crate::priority_fees::{FixedPriorityFeeProvider, PriorityFeeProvider}; use crate::util::PreparedInstructions; use crate::{jupiter, util}; use solana_address_lookup_table_program::state::AddressLookupTable; @@ -53,7 +54,7 @@ use solana_sdk::{commitment_config::CommitmentConfig, pubkey::Pubkey, signer::Si pub const MAX_ACCOUNTS_PER_TRANSACTION: usize = 64; // very close to anchor_client::Client, which unfortunately has no accessors or Clone -#[derive(Clone, Debug, Builder)] +#[derive(Clone, Builder)] #[builder(name = "ClientBuilder", build_fn(name = "build_config"))] pub struct ClientConfig { /// RPC url @@ -376,7 +377,7 @@ impl MangoClient { address_lookup_tables: vec![], payer: payer.pubkey(), signers: vec![owner, payer], - config: client.config.transaction_builder_config, + config: client.config.transaction_builder_config.clone(), } .send_and_confirm(&client) .await?; @@ -1858,7 +1859,7 @@ impl MangoClient { address_lookup_tables: self.mango_address_lookup_tables().await?, payer: fee_payer.pubkey(), signers: vec![fee_payer], - config: self.client.config.transaction_builder_config, + config: self.client.config.transaction_builder_config.clone(), }) } @@ -1872,7 +1873,7 @@ impl MangoClient { address_lookup_tables: vec![], payer: fee_payer.pubkey(), signers: vec![fee_payer], - config: self.client.config.transaction_builder_config, + config: self.client.config.transaction_builder_config.clone(), } .simulate(&self.client) .await @@ -1951,14 +1952,30 @@ impl Default for FallbackOracleConfig { } } -#[derive(Copy, Clone, Debug, Default)] +#[derive(Clone, Default, Builder)] pub struct TransactionBuilderConfig { /// adds a SetComputeUnitPrice instruction in front if none exists - pub prioritization_micro_lamports: Option, + pub priority_fee_provider: Option>, /// adds a SetComputeUnitBudget instruction if none exists pub compute_budget_per_instruction: Option, } +impl TransactionBuilderConfig { + pub fn builder() -> TransactionBuilderConfigBuilder { + TransactionBuilderConfigBuilder::default() + } +} + +impl TransactionBuilderConfigBuilder { + pub fn prioritization_micro_lamports(&mut self, cu: Option) -> &mut Self { + self.priority_fee_provider( + cu.map(|cu| { + Arc::new(FixedPriorityFeeProvider::new(cu)) as Arc + }), + ) + } +} + pub struct TransactionBuilder { pub instructions: Vec, pub address_lookup_tables: Vec, @@ -2012,7 +2029,12 @@ impl TransactionBuilder { ); } - let cu_prio = self.config.prioritization_micro_lamports.unwrap_or(0); + let cu_prio = self + .config + .priority_fee_provider + .as_ref() + .map(|provider| provider.compute_unit_fee_microlamports()) + .unwrap_or(0); if !has_compute_unit_price && cu_prio > 0 { ixs.insert(0, ComputeBudgetInstruction::set_compute_unit_price(cu_prio)); } diff --git a/lib/client/src/jupiter/v4.rs b/lib/client/src/jupiter/v4.rs index 85bbb6eea..2c6dfb271 100644 --- a/lib/client/src/jupiter/v4.rs +++ b/lib/client/src/jupiter/v4.rs @@ -338,7 +338,12 @@ impl<'a> JupiterV4<'a> { address_lookup_tables, payer, signers: vec![self.mango_client.owner.clone()], - config: self.mango_client.client.config().transaction_builder_config, + config: self + .mango_client + .client + .config() + .transaction_builder_config + .clone(), }) } diff --git a/lib/client/src/jupiter/v6.rs b/lib/client/src/jupiter/v6.rs index 1d79371d9..6c73fc741 100644 --- a/lib/client/src/jupiter/v6.rs +++ b/lib/client/src/jupiter/v6.rs @@ -388,7 +388,12 @@ impl<'a> JupiterV6<'a> { address_lookup_tables, payer, signers: vec![self.mango_client.owner.clone()], - config: self.mango_client.client.config().transaction_builder_config, + config: self + .mango_client + .client + .config() + .transaction_builder_config + .clone(), }) } diff --git a/lib/client/src/lib.rs b/lib/client/src/lib.rs index a584630fa..882a931f6 100644 --- a/lib/client/src/lib.rs +++ b/lib/client/src/lib.rs @@ -15,6 +15,8 @@ pub mod gpa; pub mod health_cache; pub mod jupiter; pub mod perp_pnl; +pub mod priority_fees; +pub mod priority_fees_cli; pub mod snapshot_source; mod util; pub mod websocket_source; diff --git a/lib/client/src/priority_fees.rs b/lib/client/src/priority_fees.rs new file mode 100644 index 000000000..179fc0361 --- /dev/null +++ b/lib/client/src/priority_fees.rs @@ -0,0 +1,240 @@ +use futures::{SinkExt, StreamExt}; +use jsonrpc_core::{MethodCall, Notification, Params, Version}; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; +use tokio::sync::broadcast; +use tokio::task::JoinHandle; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::protocol::Message; +use tracing::*; + +pub trait PriorityFeeProvider: Sync + Send { + fn compute_unit_fee_microlamports(&self) -> u64; +} + +pub struct FixedPriorityFeeProvider { + pub compute_unit_fee_microlamports: u64, +} + +impl FixedPriorityFeeProvider { + pub fn new(fee_microlamports: u64) -> Self { + Self { + compute_unit_fee_microlamports: fee_microlamports, + } + } +} + +impl PriorityFeeProvider for FixedPriorityFeeProvider { + fn compute_unit_fee_microlamports(&self) -> u64 { + self.compute_unit_fee_microlamports + } +} + +#[derive(Builder)] +pub struct EmaPriorityFeeProviderConfig { + pub percentile: u8, + + #[builder(default = "0.2")] + pub alpha: f64, + + pub fallback_prio: u64, + + #[builder(default = "Duration::from_secs(15)")] + pub max_age: Duration, +} + +impl EmaPriorityFeeProviderConfig { + pub fn builder() -> EmaPriorityFeeProviderConfigBuilder { + EmaPriorityFeeProviderConfigBuilder::default() + } +} + +#[derive(Default)] +struct CuPercentileEmaPriorityFeeProviderData { + ema: f64, + last_update: Option, +} + +pub struct CuPercentileEmaPriorityFeeProvider { + data: RwLock, + config: EmaPriorityFeeProviderConfig, +} + +impl PriorityFeeProvider for CuPercentileEmaPriorityFeeProvider { + fn compute_unit_fee_microlamports(&self) -> u64 { + let data = self.data.read().unwrap(); + if let Some(last_update) = data.last_update { + if Instant::now().duration_since(last_update) > self.config.max_age { + return self.config.fallback_prio; + } + } else { + return self.config.fallback_prio; + } + data.ema as u64 + } +} + +impl CuPercentileEmaPriorityFeeProvider { + pub fn run( + config: EmaPriorityFeeProviderConfig, + sender: &broadcast::Sender, + ) -> (Arc, JoinHandle<()>) { + let this = Arc::new(Self { + data: Default::default(), + config, + }); + let handle = tokio::spawn({ + let this_c = this.clone(); + let rx = sender.subscribe(); + async move { Self::run_update_job(this_c, rx).await } + }); + (this, handle) + } + + async fn run_update_job(provider: Arc, mut rx: broadcast::Receiver) { + let config = &provider.config; + loop { + let block_prios = rx.recv().await.unwrap(); + let prio = match block_prios.by_cu_percentile.get(&config.percentile) { + Some(v) => *v as f64, + None => { + error!("percentile not available: {}", config.percentile); + continue; + } + }; + + let mut data = provider.data.write().unwrap(); + data.ema = data.ema * (1.0 - config.alpha) + config.alpha * prio; + data.last_update = Some(Instant::now()); + } + } +} + +#[derive(Clone, Default, Debug)] +pub struct BlockPrioFees { + pub slot: u64, + // prio fee percentile in percent -> prio fee + pub percentile: HashMap, + // cu percentile in percent -> median prio fee of the group + pub by_cu_percentile: HashMap, +} + +#[derive(serde::Deserialize)] +struct BlockPrioritizationFeesNotificationContext { + slot: u64, +} + +#[derive(serde::Deserialize)] +struct BlockPrioritizationFeesNotificationValue { + by_tx: Vec, + by_tx_percentiles: Vec, + by_cu: Vec, + by_cu_percentiles: Vec, +} + +#[derive(serde::Deserialize)] +struct BlockPrioritizationFeesNotificationParams { + context: BlockPrioritizationFeesNotificationContext, + value: BlockPrioritizationFeesNotificationValue, +} + +fn as_block_prioritization_fees_notification( + notification_str: &str, +) -> anyhow::Result> { + let notification: Notification = match serde_json::from_str(¬ification_str) { + Ok(v) => v, + Err(_) => return Ok(None), // not a notification at all + }; + if notification.method != "blockPrioritizationFeesNotification" { + return Ok(None); + } + let map = match notification.params { + Params::Map(m) => m, + _ => anyhow::bail!("unexpected params, expected map"), + }; + let result = map + .get("result") + .ok_or(anyhow::anyhow!("missing params.result"))? + .clone(); + + let mut data = BlockPrioFees::default(); + let v: BlockPrioritizationFeesNotificationParams = serde_json::from_value(result)?; + data.slot = v.context.slot; + for (percentile, prio) in v.value.by_tx_percentiles.iter().zip(v.value.by_tx.iter()) { + let int_perc: u8 = ((percentile * 100.0) as u64).try_into()?; + data.percentile.insert(int_perc, *prio); + } + for (percentile, prio) in v.value.by_cu_percentiles.iter().zip(v.value.by_cu.iter()) { + let int_perc: u8 = ((percentile * 100.0) as u64).try_into()?; + data.by_cu_percentile.insert(int_perc, *prio); + } + + Ok(Some(data)) +} + +async fn connect_and_broadcast( + url: &str, + sender: &broadcast::Sender, +) -> anyhow::Result<()> { + let (ws_stream, _) = connect_async(url).await?; + let (mut write, mut read) = ws_stream.split(); + + // Create a JSON-RPC request + let call = MethodCall { + jsonrpc: Some(Version::V2), + method: "blockPrioritizationFeesSubscribe".to_string(), + params: Params::None, + id: jsonrpc_core::Id::Num(1), + }; + + let request = serde_json::to_string(&call).unwrap(); + write.send(Message::Text(request)).await?; + + loop { + let timeout = tokio::time::sleep(Duration::from_secs(20)); + tokio::select! { + message = read.next() => { + match message { + Some(Ok(Message::Text(text))) => { + if let Some(block_prio) = as_block_prioritization_fees_notification(&text)? { + // Failure might just mean there is no receiver right now + let _ = sender.send(block_prio); + } + } + Some(Ok(Message::Ping(..))) => {} + Some(Ok(Message::Pong(..))) => {} + Some(Ok(msg @ _)) => { + anyhow::bail!("received a non-text message: {:?}", msg); + }, + Some(Err(e)) => { + anyhow::bail!("error receiving message: {}", e); + } + None => { + anyhow::bail!("websocket stream closed"); + } + } + }, + _ = timeout => { + anyhow::bail!("timeout"); + } + } + } +} + +async fn connect_and_broadcast_loop(url: &str, sender: broadcast::Sender) { + loop { + if let Err(err) = connect_and_broadcast(url, &sender).await { + info!("recent block prio feed error, restarting: {err:?}"); + } + } +} + +pub fn run_broadcast_from_websocket_feed( + url: String, +) -> (broadcast::Sender, JoinHandle<()>) { + let (sender, _) = broadcast::channel(10); + let sender_c = sender.clone(); + let handle = tokio::spawn(async move { connect_and_broadcast_loop(&url, sender_c).await }); + (sender, handle) +} diff --git a/lib/client/src/priority_fees_cli.rs b/lib/client/src/priority_fees_cli.rs new file mode 100644 index 000000000..c2f44bc34 --- /dev/null +++ b/lib/client/src/priority_fees_cli.rs @@ -0,0 +1,80 @@ +use std::sync::Arc; +use tokio::task::JoinHandle; +use tracing::*; + +use crate::priority_fees::*; + +#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] +enum PriorityFeeStyleArg { + None, + Fixed, + LiteRpcCuPercentileEma, +} + +#[derive(clap::Args, Debug, Clone)] +pub struct PriorityFeeArgs { + /// choose prio fee style + #[clap(long, env, value_enum, default_value = "none")] + prioritization_style: PriorityFeeStyleArg, + + /// prioritize each transaction with this many microlamports/cu + /// + /// for dynamic prio styles, this is the fallback value + #[clap(long, env, default_value = "0")] + prioritization_micro_lamports: u64, + + #[clap(long, env, default_value = "50")] + prioritization_ema_percentile: u8, + + #[clap(long, env, default_value = "0.2")] + prioritization_ema_alpha: f64, +} + +impl PriorityFeeArgs { + pub fn make_prio_provider( + &self, + lite_rpc_url: String, + ) -> anyhow::Result<(Option>, Vec>)> { + let prio_style; + if self.prioritization_micro_lamports > 0 + && self.prioritization_style == PriorityFeeStyleArg::None + { + info!("forcing prioritization-style to fixed, since prioritization-micro-lamports was set"); + prio_style = PriorityFeeStyleArg::Fixed; + } else { + prio_style = self.prioritization_style; + } + + Ok(match prio_style { + PriorityFeeStyleArg::None => (None, vec![]), + PriorityFeeStyleArg::Fixed => ( + Some(Arc::new(FixedPriorityFeeProvider::new( + self.prioritization_micro_lamports, + ))), + vec![], + ), + PriorityFeeStyleArg::LiteRpcCuPercentileEma => { + if lite_rpc_url.is_empty() { + anyhow::bail!("cannot use recent-cu-percentile-ema prioritization style without a lite-rpc url"); + } + let (block_prio_broadcaster, block_prio_job) = + run_broadcast_from_websocket_feed(lite_rpc_url); + let (prio_fee_provider, prio_fee_provider_job) = + CuPercentileEmaPriorityFeeProvider::run( + EmaPriorityFeeProviderConfig::builder() + .percentile(75) + .fallback_prio(self.prioritization_micro_lamports) + .alpha(self.prioritization_ema_alpha) + .percentile(self.prioritization_ema_percentile) + .build() + .unwrap(), + &block_prio_broadcaster, + ); + ( + Some(prio_fee_provider), + vec![block_prio_job, prio_fee_provider_job], + ) + } + }) + } +} From b5d49381edd9e2e885c46e04bb249721a6fbe6f8 Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Fri, 9 Feb 2024 09:09:42 +0100 Subject: [PATCH 21/42] rust clippy fix --- bin/settler/src/settle.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/settler/src/settle.rs b/bin/settler/src/settle.rs index edd84c700..13e154868 100644 --- a/bin/settler/src/settle.rs +++ b/bin/settler/src/settle.rs @@ -13,7 +13,7 @@ use solana_sdk::signature::Signature; use solana_sdk::signer::Signer; use solana_sdk::transaction::VersionedTransaction; use tracing::*; -use {anyhow::Context, fixed::types::I80F48, solana_sdk::pubkey::Pubkey}; +use {fixed::types::I80F48, solana_sdk::pubkey::Pubkey}; pub struct Config { /// Amount of time to wait before reusing a positive-pnl account From 08a5ee8f538967322b49d8e95f9853c856c06b97 Mon Sep 17 00:00:00 2001 From: Serge Farny Date: Fri, 9 Feb 2024 11:19:52 +0100 Subject: [PATCH 22/42] rust client: Perp - Auto close account when needed (#875) * perp: auto close perp market account when needing to open a new one with no slot available * rust_client: do not send health accounts when deactivating a perp position (not needed on program side) * rust_client: add perp place order command --- bin/cli/src/main.rs | 85 +++++++++++ lib/client/src/client.rs | 142 +++++++++++++------ programs/mango-v4/src/state/mango_account.rs | 44 ++++++ 3 files changed, 228 insertions(+), 43 deletions(-) diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index 90b3a74f6..70c4e95d5 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -1,10 +1,13 @@ +use clap::clap_derive::ArgEnum; use clap::{Args, Parser, Subcommand}; +use mango_v4::state::{PlaceOrderType, SelfTradeBehavior, Side}; use mango_v4_client::{ keypair_from_cli, pubkey_from_cli, Client, MangoClient, TransactionBuilderConfig, }; use solana_sdk::pubkey::Pubkey; use std::str::FromStr; use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; mod save_snapshot; mod test_oracles; @@ -88,6 +91,41 @@ struct JupiterSwap { rpc: Rpc, } +#[derive(ArgEnum, Clone, Debug)] +#[repr(u8)] +pub enum CliSide { + Bid = 0, + Ask = 1, +} + +#[derive(Args, Debug, Clone)] +struct PerpPlaceOrder { + #[clap(long)] + account: String, + + /// also pays for everything + #[clap(short, long)] + owner: String, + + #[clap(long)] + market_name: String, + + #[clap(long, value_enum)] + side: CliSide, + + #[clap(short, long)] + price: f64, + + #[clap(long)] + quantity: f64, + + #[clap(long)] + expiry: u64, + + #[clap(flatten)] + rpc: Rpc, +} + #[derive(Subcommand, Debug, Clone)] enum Command { CreateAccount(CreateAccount), @@ -128,6 +166,7 @@ enum Command { #[clap(short, long)] output: String, }, + PerpPlaceOrder(PerpPlaceOrder), } impl Rpc { @@ -248,6 +287,52 @@ async fn main() -> Result<(), anyhow::Error> { let client = rpc.client(None)?; save_snapshot::save_snapshot(mango_group, client, output).await? } + Command::PerpPlaceOrder(cmd) => { + let client = cmd.rpc.client(Some(&cmd.owner))?; + let account = pubkey_from_cli(&cmd.account); + let owner = Arc::new(keypair_from_cli(&cmd.owner)); + let client = MangoClient::new_for_existing_account(client, account, owner).await?; + let market = client + .context + .perp_markets + .iter() + .find(|p| p.1.name == cmd.market_name) + .unwrap() + .1; + + fn native(x: f64, b: u32) -> i64 { + (x * (10_i64.pow(b)) as f64) as i64 + } + + let price_lots = native(cmd.price, 6) * market.base_lot_size + / (market.quote_lot_size * 10_i64.pow(market.base_decimals.into())); + let max_base_lots = + native(cmd.quantity, market.base_decimals.into()) / market.base_lot_size; + + let txsig = client + .perp_place_order( + market.perp_market_index, + match cmd.side { + CliSide::Bid => Side::Bid, + CliSide::Ask => Side::Ask, + }, + price_lots, + max_base_lots, + i64::max_value(), + SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(), + PlaceOrderType::Limit, + false, + if cmd.expiry > 0 { + SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() + cmd.expiry + } else { + 0 + }, + 10, + SelfTradeBehavior::AbortTransaction, + ) + .await?; + println!("{}", txsig); + } }; Ok(()) diff --git a/lib/client/src/client.rs b/lib/client/src/client.rs index b1584d197..c3795cc01 100644 --- a/lib/client/src/client.rs +++ b/lib/client/src/client.rs @@ -45,6 +45,7 @@ use solana_sdk::signer::keypair; use solana_sdk::transaction::TransactionError; use anyhow::Context; +use mango_v4::error::{IsAnchorErrorWithCode, MangoError}; use solana_sdk::account::ReadableAccount; use solana_sdk::instruction::{AccountMeta, Instruction}; use solana_sdk::signature::{Keypair, Signature}; @@ -1058,51 +1059,64 @@ impl MangoClient { limit: u8, self_trade_behavior: SelfTradeBehavior, ) -> anyhow::Result { + let mut ixs = PreparedInstructions::new(); + let perp = self.context.perp(market_index); + let mut account = account.clone(); + + let close_perp_ixs_opt = self + .replace_perp_market_if_needed(&account, market_index) + .await?; + + if let Some((close_perp_ixs, modified_account)) = close_perp_ixs_opt { + account = modified_account; + ixs.append(close_perp_ixs); + } + let (health_remaining_metas, health_cu) = self .derive_health_check_remaining_account_metas( - account, + &account, vec![], vec![], vec![market_index], ) .await?; - let ixs = PreparedInstructions::from_single( - Instruction { - program_id: mango_v4::id(), - accounts: { - let mut ams = anchor_lang::ToAccountMetas::to_account_metas( - &mango_v4::accounts::PerpPlaceOrder { - group: self.group(), - account: self.mango_account_address, - owner: self.owner(), - perp_market: perp.address, - bids: perp.bids, - asks: perp.asks, - event_queue: perp.event_queue, - oracle: perp.oracle, - }, - None, - ); - ams.extend(health_remaining_metas.into_iter()); - ams - }, - data: anchor_lang::InstructionData::data( - &mango_v4::instruction::PerpPlaceOrderV2 { - side, - price_lots, - max_base_lots, - max_quote_lots, - client_order_id, - order_type, - reduce_only, - expiry_timestamp, - limit, - self_trade_behavior, + let ix = Instruction { + program_id: mango_v4::id(), + accounts: { + let mut ams = anchor_lang::ToAccountMetas::to_account_metas( + &mango_v4::accounts::PerpPlaceOrder { + group: self.group(), + account: self.mango_account_address, + owner: self.owner(), + perp_market: perp.address, + bids: perp.bids, + asks: perp.asks, + event_queue: perp.event_queue, + oracle: perp.oracle, }, - ), + None, + ); + ams.extend(health_remaining_metas.into_iter()); + ams }, + data: anchor_lang::InstructionData::data(&mango_v4::instruction::PerpPlaceOrderV2 { + side, + price_lots, + max_base_lots, + max_quote_lots, + client_order_id, + order_type, + reduce_only, + expiry_timestamp, + limit, + self_trade_behavior, + }), + }; + + ixs.push( + ix, self.instruction_cu(health_cu) + self.context.compute_estimates.cu_per_perp_order_match * limit as u32, ); @@ -1110,6 +1124,44 @@ impl MangoClient { Ok(ixs) } + async fn replace_perp_market_if_needed( + &self, + account: &MangoAccountValue, + perk_market_index: PerpMarketIndex, + ) -> anyhow::Result> { + let context = &self.context; + let settle_token_index = context.perp(perk_market_index).settle_token_index; + + let mut account = account.clone(); + let enforce_position_result = + account.ensure_perp_position(perk_market_index, settle_token_index); + + if !enforce_position_result + .is_anchor_error_with_code(MangoError::NoFreePerpPositionIndex.error_code()) + { + return Ok(None); + } + + let perp_position_to_close_opt = account.find_first_active_unused_perp_position(); + match perp_position_to_close_opt { + Some(perp_position_to_close) => { + let close_ix = self + .perp_deactivate_position_instruction(perp_position_to_close.market_index) + .await?; + + let previous_market = context.perp(perp_position_to_close.market_index); + account.deactivate_perp_position( + perp_position_to_close.market_index, + previous_market.settle_token_index, + )?; + account.ensure_perp_position(perk_market_index, settle_token_index)?; + + Ok(Some((close_ix, account))) + } + None => anyhow::bail!("No perp market slot available"), + } + } + #[allow(clippy::too_many_arguments)] pub async fn perp_place_order( &self, @@ -1182,18 +1234,23 @@ impl MangoClient { &self, market_index: PerpMarketIndex, ) -> anyhow::Result { - let perp = self.context.perp(market_index); - let mango_account = &self.mango_account().await?; - - let (health_check_metas, health_cu) = self - .derive_health_check_remaining_account_metas(mango_account, vec![], vec![], vec![]) + let ixs = self + .perp_deactivate_position_instruction(market_index) .await?; + self.send_and_confirm_owner_tx(ixs.to_instructions()).await + } + + async fn perp_deactivate_position_instruction( + &self, + market_index: PerpMarketIndex, + ) -> anyhow::Result { + let perp = self.context.perp(market_index); let ixs = PreparedInstructions::from_single( Instruction { program_id: mango_v4::id(), accounts: { - let mut ams = anchor_lang::ToAccountMetas::to_account_metas( + let ams = anchor_lang::ToAccountMetas::to_account_metas( &mango_v4::accounts::PerpDeactivatePosition { group: self.group(), account: self.mango_account_address, @@ -1202,16 +1259,15 @@ impl MangoClient { }, None, ); - ams.extend(health_check_metas.into_iter()); ams }, data: anchor_lang::InstructionData::data( &mango_v4::instruction::PerpDeactivatePosition {}, ), }, - self.instruction_cu(health_cu), + self.context.compute_estimates.cu_per_mango_instruction, ); - self.send_and_confirm_owner_tx(ixs.to_instructions()).await + Ok(ixs) } pub async fn perp_settle_pnl_instruction( diff --git a/programs/mango-v4/src/state/mango_account.rs b/programs/mango-v4/src/state/mango_account.rs index 9a759b250..ac8c215ef 100644 --- a/programs/mango-v4/src/state/mango_account.rs +++ b/programs/mango-v4/src/state/mango_account.rs @@ -1155,6 +1155,7 @@ impl< } } + // Only used in unit tests pub fn deactivate_perp_position( &mut self, perp_market_index: PerpMarketIndex, @@ -1196,6 +1197,19 @@ impl< Ok(()) } + pub fn find_first_active_unused_perp_position(&self) -> Option<&PerpPosition> { + let first_unused_position_opt = self.all_perp_positions().find(|p| { + p.is_active() + && p.base_position_lots == 0 + && p.quote_position_native == 0 + && p.bids_base_lots == 0 + && p.asks_base_lots == 0 + && p.taker_base_lots == 0 + && p.taker_quote_lots == 0 + }); + first_unused_position_opt + } + pub fn add_perp_order( &mut self, perp_market_index: PerpMarketIndex, @@ -2808,4 +2822,34 @@ mod tests { Ok(()) } + + #[test] + fn test_perp_auto_close_first_unused() { + let mut account = make_test_account(); + + // Fill all perp slots + assert_eq!(account.header.perp_count, 4); + account.ensure_perp_position(1, 0).unwrap(); + account.ensure_perp_position(2, 0).unwrap(); + account.ensure_perp_position(3, 0).unwrap(); + account.ensure_perp_position(4, 0).unwrap(); + assert_eq!(account.active_perp_positions().count(), 4); + + // Force usage of some perp slot (leaves 3 unused) + account.perp_position_mut(1).unwrap().taker_base_lots = 10; + account.perp_position_mut(2).unwrap().base_position_lots = 10; + account.perp_position_mut(4).unwrap().quote_position_native = I80F48::from_num(10); + assert!(account.perp_position(3).ok().is_some()); + + // Should not succeed anymore + { + let e = account.ensure_perp_position(5, 0); + assert!(e.is_anchor_error_with_code(MangoError::NoFreePerpPositionIndex.error_code())); + } + + // Act + let to_be_closed_account_opt = account.find_first_active_unused_perp_position(); + + assert_eq!(to_be_closed_account_opt.unwrap().market_index, 3) + } } From 007cf0da6ee8874106d264fa883d77a13936feb4 Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Fri, 9 Feb 2024 11:31:37 +0100 Subject: [PATCH 23/42] liquidator: make tcs max amount configurable (#874) --- bin/liquidator/src/main.rs | 15 ++++++++++++--- bin/liquidator/src/token_swap_info.rs | 3 +++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/bin/liquidator/src/main.rs b/bin/liquidator/src/main.rs index ed091e55f..f489fabe8 100644 --- a/bin/liquidator/src/main.rs +++ b/bin/liquidator/src/main.rs @@ -149,6 +149,10 @@ struct Cli { #[clap(long, env, value_enum, default_value = "swap-sell-into-buy")] tcs_mode: TcsMode, + /// largest tcs amount to trigger in one transaction, in dollar + #[clap(long, env, default_value = "1000.0")] + tcs_max_trigger_amount: f64, + #[clap(flatten)] prioritization_fee_cli: priority_fees_cli::PriorityFeeArgs, @@ -180,6 +184,11 @@ struct Cli { #[clap(long, env, default_value = "")] jupiter_token: String, + /// size of the swap to quote via jupiter to get slippage info, in dollar + /// should be larger than tcs_max_trigger_amount + #[clap(long, env, default_value = "1000.0")] + jupiter_swap_info_amount: f64, + /// report liquidator's existence and pubkey #[clap(long, env, value_enum, default_value = "true")] telemetry: BoolArg, @@ -340,8 +349,8 @@ async fn main() -> anyhow::Result<()> { }; let token_swap_info_config = token_swap_info::Config { - quote_index: 0, // USDC - quote_amount: 1_000_000_000, // TODO: config, $1000, should be >= tcs_config.max_trigger_quote_amount + quote_index: 0, // USDC + quote_amount: (cli.jupiter_swap_info_amount * 1e6) as u64, jupiter_version: cli.jupiter_version.into(), }; @@ -360,7 +369,7 @@ async fn main() -> anyhow::Result<()> { let tcs_config = trigger_tcs::Config { min_health_ratio: cli.min_health_ratio, - max_trigger_quote_amount: 1_000_000_000, // TODO: config, $1000 + max_trigger_quote_amount: (cli.tcs_max_trigger_amount * 1e6) as u64, compute_limit_for_trigger: cli.compute_limit_for_tcs, profit_fraction: cli.tcs_profit_fraction, collateral_token_index: 0, // USDC diff --git a/bin/liquidator/src/token_swap_info.rs b/bin/liquidator/src/token_swap_info.rs index e15fea2fd..8e4e018ad 100644 --- a/bin/liquidator/src/token_swap_info.rs +++ b/bin/liquidator/src/token_swap_info.rs @@ -11,7 +11,10 @@ use mango_v4_client::MangoClient; pub struct Config { pub quote_index: TokenIndex, + + /// Size in quote_index-token native tokens to quote. pub quote_amount: u64, + pub jupiter_version: jupiter::Version, } From ae833621ad820584ac7dc442ae1d39e0ee20c052 Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Mon, 12 Feb 2024 15:32:43 +0100 Subject: [PATCH 24/42] ci/releasing: Include git sha and refname in release builds (#876) --- .github/workflows/ci-verifiable-build.yml | 2 +- RELEASING.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-verifiable-build.yml b/.github/workflows/ci-verifiable-build.yml index 901a97a37..f82e43845 100644 --- a/.github/workflows/ci-verifiable-build.yml +++ b/.github/workflows/ci-verifiable-build.yml @@ -23,7 +23,7 @@ jobs: - name: Verifiable Build run: | - anchor build --verifiable --docker-image backpackapp/build:v0.28.0 --solana-version 1.16.14 -- --features enable-gpl + anchor build --verifiable --docker-image backpackapp/build:v0.28.0 --solana-version 1.16.14 --env GITHUB_SHA --env GITHUB_REF_NAME -- --features enable-gpl - name: Generate Checksum run: | diff --git a/RELEASING.md b/RELEASING.md index 8d8f7587c..01496df95 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -23,7 +23,9 @@ - Do a verifiable build - anchor build --verifiable --solana-version 1.14.13 -- --features enable-gpl + Set GITHUB_SHA and GITHUB_REF_NAME to the release sha1 and tag name. + + anchor build --verifiable --docker-image backpackapp/build:v0.28.0 --solana-version 1.16.14 --env GITHUB_SHA --env GITHUB_REF_NAME -- --features enable-gpl (or wait for github to finish and create the release) From e57dcdc2a9248f2aa74722d49fe47b48a7dbfd05 Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Tue, 13 Feb 2024 12:39:28 +0100 Subject: [PATCH 25/42] Add collateral fees (#868) - New permissionless instruction to regularly charge collateral fees - Bank and group configuration to set rate and interval - Keeper addition to call the instruction --- bin/keeper/src/crank.rs | 144 +++++++++- bin/keeper/src/main.rs | 4 + lib/client/src/client.rs | 37 +++ mango_v4.json | 126 ++++++++- programs/mango-v4/src/accounts_ix/mod.rs | 2 + .../token_charge_collateral_fees.rs | 16 ++ .../mango-v4/src/instructions/group_edit.rs | 10 + programs/mango-v4/src/instructions/mod.rs | 2 + .../token_charge_collateral_fees.rs | 111 ++++++++ .../mango-v4/src/instructions/token_edit.rs | 17 +- .../src/instructions/token_register.rs | 5 +- .../instructions/token_register_trustless.rs | 4 +- programs/mango-v4/src/lib.rs | 12 + programs/mango-v4/src/logs.rs | 9 + programs/mango-v4/src/state/bank.rs | 20 +- programs/mango-v4/src/state/group.rs | 25 +- programs/mango-v4/src/state/mango_account.rs | 18 +- programs/mango-v4/tests/cases/mod.rs | 1 + .../tests/cases/test_collateral_fees.rs | 171 ++++++++++++ .../tests/program_test/mango_client.rs | 48 ++++ ts/client/src/accounts/bank.ts | 8 + ts/client/src/accounts/group.ts | 3 + ts/client/src/client.ts | 4 + ts/client/src/clientIxParamBuilder.ts | 4 + ts/client/src/mango_v4.ts | 252 +++++++++++++++++- 25 files changed, 1024 insertions(+), 29 deletions(-) create mode 100644 programs/mango-v4/src/accounts_ix/token_charge_collateral_fees.rs create mode 100644 programs/mango-v4/src/instructions/token_charge_collateral_fees.rs create mode 100644 programs/mango-v4/tests/cases/test_collateral_fees.rs diff --git a/bin/keeper/src/crank.rs b/bin/keeper/src/crank.rs index 8082f41a8..705ea8b2b 100644 --- a/bin/keeper/src/crank.rs +++ b/bin/keeper/src/crank.rs @@ -1,12 +1,27 @@ -use std::{collections::HashSet, sync::Arc, time::Duration, time::Instant}; +use std::{ + collections::HashSet, + sync::Arc, + time::Instant, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; use crate::MangoClient; +use anyhow::Context; use itertools::Itertools; -use anchor_lang::{__private::bytemuck::cast_ref, solana_program}; +use anchor_lang::{__private::bytemuck::cast_ref, solana_program, Discriminator}; use futures::Future; -use mango_v4::state::{EventQueue, EventType, FillEvent, OutEvent, TokenIndex}; -use mango_v4_client::PerpMarketContext; +use mango_v4::{ + accounts_zerocopy::AccountReader, + state::{ + EventQueue, EventType, FillEvent, Group, MangoAccount, MangoAccountValue, OutEvent, + TokenIndex, + }, +}; +use mango_v4_client::{ + account_fetcher_fetch_anchor_account, AccountFetcher, PerpMarketContext, PreparedInstructions, + RpcAccountFetcher, TransactionBuilder, +}; use prometheus::{register_histogram, Encoder, Histogram, IntCounter, Registry}; use solana_sdk::{ instruction::{AccountMeta, Instruction}, @@ -81,6 +96,7 @@ pub async fn runner( interval_consume_events: u64, interval_update_funding: u64, interval_check_for_changes_and_abort: u64, + interval_charge_collateral_fees: u64, extra_jobs: Vec>, ) -> Result<(), anyhow::Error> { let handles1 = mango_client @@ -140,6 +156,7 @@ pub async fn runner( futures::future::join_all(handles1), futures::future::join_all(handles2), futures::future::join_all(handles3), + loop_charge_collateral_fees(mango_client.clone(), interval_charge_collateral_fees), MangoClient::loop_check_for_context_changes_and_abort( mango_client.clone(), Duration::from_secs(interval_check_for_changes_and_abort), @@ -412,3 +429,122 @@ pub async fn loop_update_funding( } } } + +pub async fn loop_charge_collateral_fees(mango_client: Arc, interval: u64) { + if interval == 0 { + return; + } + + // Make a new one separate from the mango_client.account_fetcher, + // because we don't want cached responses + let fetcher = RpcAccountFetcher { + rpc: mango_client.client.new_rpc_async(), + }; + + let group: Group = account_fetcher_fetch_anchor_account(&fetcher, &mango_client.context.group) + .await + .unwrap(); + let collateral_fee_interval = group.collateral_fee_interval; + + let mut interval = mango_v4_client::delay_interval(Duration::from_secs(interval)); + loop { + interval.tick().await; + + match charge_collateral_fees_inner(&mango_client, &fetcher, collateral_fee_interval).await { + Ok(()) => {} + Err(err) => { + error!("charge_collateral_fees error: {err:?}"); + } + } + } +} + +async fn charge_collateral_fees_inner( + client: &MangoClient, + fetcher: &RpcAccountFetcher, + collateral_fee_interval: u64, +) -> anyhow::Result<()> { + let mango_accounts = fetcher + .fetch_program_accounts(&mango_v4::id(), MangoAccount::DISCRIMINATOR) + .await + .context("fetching mango accounts")? + .into_iter() + .filter_map( + |(pk, data)| match MangoAccountValue::from_bytes(&data.data()[8..]) { + Ok(acc) => Some((pk, acc)), + Err(err) => { + error!(pk=%pk, "charge_collateral_fees could not parse account: {err:?}"); + None + } + }, + ); + + let mut ix_to_send = Vec::new(); + let now_ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as u64; + for (pk, account) in mango_accounts { + let should_reset = + collateral_fee_interval == 0 && account.fixed.last_collateral_fee_charge > 0; + let should_charge = collateral_fee_interval > 0 + && now_ts > account.fixed.last_collateral_fee_charge + collateral_fee_interval; + if !(should_reset || should_charge) { + continue; + } + + let ixs = match client + .token_charge_collateral_fees_instruction((&pk, &account)) + .await + { + Ok(ixs) => ixs, + Err(err) => { + error!(pk=%pk, "charge_collateral_fees could not build instruction: {err:?}"); + continue; + } + }; + + ix_to_send.push(ixs); + } + + send_batched_log_errors_no_confirm( + client.transaction_builder().await?, + &client.client, + &ix_to_send, + ) + .await; + + Ok(()) +} + +/// Try to batch the instructions into transactions and send them +async fn send_batched_log_errors_no_confirm( + mut tx_builder: TransactionBuilder, + client: &mango_v4_client::Client, + ixs_list: &[PreparedInstructions], +) { + let mut current_batch = PreparedInstructions::new(); + for ixs in ixs_list { + let previous_batch = current_batch.clone(); + current_batch.append(ixs.clone()); + + tx_builder.instructions = current_batch.clone().to_instructions(); + if !tx_builder.transaction_size().is_ok() { + tx_builder.instructions = previous_batch.to_instructions(); + match tx_builder.send(client).await { + Err(err) => error!("could not send transaction: {err:?}"), + _ => {} + } + + current_batch = ixs.clone(); + } + } + + if !current_batch.is_empty() { + tx_builder.instructions = current_batch.to_instructions(); + match tx_builder.send(client).await { + Err(err) => error!("could not send transaction: {err:?}"), + _ => {} + } + } +} diff --git a/bin/keeper/src/main.rs b/bin/keeper/src/main.rs index dad08649e..194d5a46b 100644 --- a/bin/keeper/src/main.rs +++ b/bin/keeper/src/main.rs @@ -61,6 +61,9 @@ struct Cli { #[clap(long, env, default_value_t = 120)] interval_check_new_listings_and_abort: u64, + #[clap(long, env, default_value_t = 300)] + interval_charge_collateral_fees: u64, + #[clap(long, env, default_value_t = 10)] timeout: u64, @@ -153,6 +156,7 @@ async fn main() -> Result<(), anyhow::Error> { cli.interval_consume_events, cli.interval_update_funding, cli.interval_check_new_listings_and_abort, + cli.interval_charge_collateral_fees, prio_jobs, ) .await diff --git a/lib/client/src/client.rs b/lib/client/src/client.rs index c3795cc01..1ee3b2f90 100644 --- a/lib/client/src/client.rs +++ b/lib/client/src/client.rs @@ -1487,6 +1487,43 @@ impl MangoClient { )) } + pub async fn token_charge_collateral_fees_instruction( + &self, + account: (&Pubkey, &MangoAccountValue), + ) -> anyhow::Result { + let (mut health_remaining_ams, health_cu) = self + .derive_health_check_remaining_account_metas(account.1, vec![], vec![], vec![]) + .await + .unwrap(); + + // The instruction requires mutable banks + for am in &mut health_remaining_ams[0..account.1.active_token_positions().count()] { + am.is_writable = true; + } + + let ix = Instruction { + program_id: mango_v4::id(), + accounts: { + let mut ams = anchor_lang::ToAccountMetas::to_account_metas( + &mango_v4::accounts::TokenChargeCollateralFees { + group: self.group(), + account: *account.0, + }, + None, + ); + ams.extend(health_remaining_ams); + ams + }, + data: anchor_lang::InstructionData::data( + &mango_v4::instruction::TokenChargeCollateralFees {}, + ), + }; + Ok(PreparedInstructions::from_single( + ix, + self.instruction_cu(health_cu), + )) + } + // // Liquidation // diff --git a/mango_v4.json b/mango_v4.json index 35363b714..9c9a7bde6 100644 --- a/mango_v4.json +++ b/mango_v4.json @@ -277,6 +277,12 @@ "type": { "option": "u16" } + }, + { + "name": "collateralFeeIntervalOpt", + "type": { + "option": "u64" + } } ] }, @@ -635,6 +641,10 @@ { "name": "disableAssetLiquidation", "type": "bool" + }, + { + "name": "collateralFeePerDay", + "type": "f32" } ] }, @@ -1051,6 +1061,12 @@ "type": { "option": "bool" } + }, + { + "name": "collateralFeePerDayOpt", + "type": { + "option": "f32" + } } ] }, @@ -5963,6 +5979,25 @@ } ] }, + { + "name": "tokenChargeCollateralFees", + "accounts": [ + { + "name": "group", + "isMut": false, + "isSigner": false + }, + { + "name": "account", + "isMut": true, + "isSigner": false, + "relations": [ + "group" + ] + } + ], + "args": [] + }, { "name": "altSet", "accounts": [ @@ -7531,12 +7566,30 @@ "defined": "I80F48" } }, + { + "name": "collectedCollateralFees", + "docs": [ + "Collateral fees that have been collected (in native tokens)", + "", + "See also collected_fees_native and fees_withdrawn." + ], + "type": { + "defined": "I80F48" + } + }, + { + "name": "collateralFeePerDay", + "docs": [ + "The daily collateral fees rate for fully utilized collateral." + ], + "type": "f32" + }, { "name": "reserved", "type": { "array": [ "u8", - 1920 + 1900 ] } } @@ -7664,12 +7717,28 @@ ], "type": "u16" }, + { + "name": "padding2", + "type": { + "array": [ + "u8", + 4 + ] + } + }, + { + "name": "collateralFeeInterval", + "docs": [ + "Intervals in which collateral fee is applied" + ], + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 1812 + 1800 ] } } @@ -7791,12 +7860,27 @@ ], "type": "u64" }, + { + "name": "temporaryDelegate", + "type": "publicKey" + }, + { + "name": "temporaryDelegateExpiry", + "type": "u64" + }, + { + "name": "lastCollateralFeeCharge", + "docs": [ + "Time at which the last collateral fee was charged" + ], + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 200 + 152 ] } }, @@ -9566,12 +9650,16 @@ "name": "temporaryDelegateExpiry", "type": "u64" }, + { + "name": "lastCollateralFeeCharge", + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 160 + 152 ] } } @@ -13699,6 +13787,36 @@ "index": false } ] + }, + { + "name": "TokenCollateralFeeLog", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "mangoAccount", + "type": "publicKey", + "index": false + }, + { + "name": "tokenIndex", + "type": "u16", + "index": false + }, + { + "name": "assetUsageFraction", + "type": "i128", + "index": false + }, + { + "name": "fee", + "type": "i128", + "index": false + } + ] } ], "errors": [ diff --git a/programs/mango-v4/src/accounts_ix/mod.rs b/programs/mango-v4/src/accounts_ix/mod.rs index d2b42f23c..289430440 100644 --- a/programs/mango-v4/src/accounts_ix/mod.rs +++ b/programs/mango-v4/src/accounts_ix/mod.rs @@ -59,6 +59,7 @@ pub use stub_oracle_close::*; pub use stub_oracle_create::*; pub use stub_oracle_set::*; pub use token_add_bank::*; +pub use token_charge_collateral_fees::*; pub use token_conditional_swap_cancel::*; pub use token_conditional_swap_create::*; pub use token_conditional_swap_start::*; @@ -135,6 +136,7 @@ mod stub_oracle_close; mod stub_oracle_create; mod stub_oracle_set; mod token_add_bank; +mod token_charge_collateral_fees; mod token_conditional_swap_cancel; mod token_conditional_swap_create; mod token_conditional_swap_start; diff --git a/programs/mango-v4/src/accounts_ix/token_charge_collateral_fees.rs b/programs/mango-v4/src/accounts_ix/token_charge_collateral_fees.rs new file mode 100644 index 000000000..d6c7f218f --- /dev/null +++ b/programs/mango-v4/src/accounts_ix/token_charge_collateral_fees.rs @@ -0,0 +1,16 @@ +use crate::error::MangoError; +use crate::state::*; +use anchor_lang::prelude::*; + +/// Charges collateral fees on an account +#[derive(Accounts)] +pub struct TokenChargeCollateralFees<'info> { + pub group: AccountLoader<'info, Group>, + + #[account( + mut, + has_one = group, + constraint = account.load()?.is_operational() @ MangoError::AccountIsFrozen + )] + pub account: AccountLoader<'info, MangoAccountFixed>, +} diff --git a/programs/mango-v4/src/instructions/group_edit.rs b/programs/mango-v4/src/instructions/group_edit.rs index 7eb631f61..e74df1c2a 100644 --- a/programs/mango-v4/src/instructions/group_edit.rs +++ b/programs/mango-v4/src/instructions/group_edit.rs @@ -19,6 +19,7 @@ pub fn group_edit( mngo_token_index_opt: Option, buyback_fees_expiry_interval_opt: Option, allowed_fast_listings_per_interval_opt: Option, + collateral_fee_interval_opt: Option, ) -> Result<()> { let mut group = ctx.accounts.group.load_mut()?; @@ -116,5 +117,14 @@ pub fn group_edit( group.allowed_fast_listings_per_interval = allowed_fast_listings_per_interval; } + if let Some(collateral_fee_interval) = collateral_fee_interval_opt { + msg!( + "Collateral fee interval old {:?}, new {:?}", + group.collateral_fee_interval, + collateral_fee_interval + ); + group.collateral_fee_interval = collateral_fee_interval; + } + Ok(()) } diff --git a/programs/mango-v4/src/instructions/mod.rs b/programs/mango-v4/src/instructions/mod.rs index 297eb5611..6a6dc9220 100644 --- a/programs/mango-v4/src/instructions/mod.rs +++ b/programs/mango-v4/src/instructions/mod.rs @@ -50,6 +50,7 @@ pub use stub_oracle_close::*; pub use stub_oracle_create::*; pub use stub_oracle_set::*; pub use token_add_bank::*; +pub use token_charge_collateral_fees::*; pub use token_conditional_swap_cancel::*; pub use token_conditional_swap_create::*; pub use token_conditional_swap_start::*; @@ -117,6 +118,7 @@ mod stub_oracle_close; mod stub_oracle_create; mod stub_oracle_set; mod token_add_bank; +mod token_charge_collateral_fees; mod token_conditional_swap_cancel; mod token_conditional_swap_create; mod token_conditional_swap_start; diff --git a/programs/mango-v4/src/instructions/token_charge_collateral_fees.rs b/programs/mango-v4/src/instructions/token_charge_collateral_fees.rs new file mode 100644 index 000000000..028a287ff --- /dev/null +++ b/programs/mango-v4/src/instructions/token_charge_collateral_fees.rs @@ -0,0 +1,111 @@ +use crate::accounts_zerocopy::*; +use crate::health::*; +use crate::state::*; +use anchor_lang::prelude::*; +use fixed::types::I80F48; + +use crate::accounts_ix::*; +use crate::logs::{emit_stack, TokenCollateralFeeLog}; + +pub fn token_charge_collateral_fees(ctx: Context) -> Result<()> { + let group = ctx.accounts.group.load()?; + let mut account = ctx.accounts.account.load_full_mut()?; + let now_ts: u64 = Clock::get()?.unix_timestamp.try_into().unwrap(); + + if group.collateral_fee_interval == 0 { + // By resetting, a new enabling of collateral fees will not immediately create a charge + account.fixed.last_collateral_fee_charge = 0; + return Ok(()); + } + + // When collateral fees are enabled the first time, don't immediately charge + if account.fixed.last_collateral_fee_charge == 0 { + account.fixed.last_collateral_fee_charge = now_ts; + return Ok(()); + } + + // Is the next fee-charging due? + let last_charge_ts = account.fixed.last_collateral_fee_charge; + if now_ts < last_charge_ts + group.collateral_fee_interval { + return Ok(()); + } + account.fixed.last_collateral_fee_charge = now_ts; + + // Charge the user at most for 2x the interval. So if no one calls this for a long time + // there won't be a huge charge based only on the end state. + let charge_seconds = (now_ts - last_charge_ts).min(2 * group.collateral_fee_interval); + + // The fees are configured in "interest per day" so we need to get the fraction of days + // that has passed since the last update for scaling + let inv_seconds_per_day = I80F48::from_num(1.157407407407e-5); // 1 / (24 * 60 * 60) + let time_scaling = I80F48::from(charge_seconds) * inv_seconds_per_day; + + let health_cache = { + let retriever = + new_fixed_order_account_retriever(ctx.remaining_accounts, &account.borrow())?; + new_health_cache(&account.borrow(), &retriever, now_ts)? + }; + + // We want to find the total asset health and total liab health, but don't want + // to treat borrows that moved into open orders accounts as realized. Hence we + // pretend all spot orders are closed and settled and add their funds back to + // the token positions. + let mut token_balances = health_cache.effective_token_balances(HealthType::Maint); + for s3info in health_cache.serum3_infos.iter() { + token_balances[s3info.base_info_index].spot_and_perp += s3info.reserved_base; + token_balances[s3info.quote_info_index].spot_and_perp += s3info.reserved_quote; + } + + let mut total_liab_health = I80F48::ZERO; + let mut total_asset_health = I80F48::ZERO; + for (info, balance) in health_cache.token_infos.iter().zip(token_balances.iter()) { + let health = info.health_contribution(HealthType::Maint, balance.spot_and_perp); + if health.is_positive() { + total_asset_health += health; + } else { + total_liab_health -= health; + } + } + + // Users only pay for assets that are actively used to cover their liabilities. + let asset_usage_scaling = (total_liab_health / total_asset_health) + .max(I80F48::ZERO) + .min(I80F48::ONE); + + let scaling = asset_usage_scaling * time_scaling; + + let token_position_count = account.active_token_positions().count(); + for bank_ai in &ctx.remaining_accounts[0..token_position_count] { + let mut bank = bank_ai.load_mut::()?; + if bank.collateral_fee_per_day <= 0.0 { + continue; + } + + let (token_position, raw_token_index) = account.token_position_mut(bank.token_index)?; + let token_balance = token_position.native(&bank); + if token_balance <= 0 { + continue; + } + + let fee = token_balance * scaling * I80F48::from_num(bank.collateral_fee_per_day); + assert!(fee <= token_balance); + + let is_active = bank.withdraw_without_fee(token_position, fee, now_ts)?; + if !is_active { + account.deactivate_token_position_and_log(raw_token_index, ctx.accounts.account.key()); + } + + bank.collected_fees_native += fee; + bank.collected_collateral_fees += fee; + + emit_stack(TokenCollateralFeeLog { + mango_group: ctx.accounts.group.key(), + mango_account: ctx.accounts.account.key(), + token_index: bank.token_index, + fee: fee.to_bits(), + asset_usage_fraction: asset_usage_scaling.to_bits(), + }) + } + + Ok(()) +} diff --git a/programs/mango-v4/src/instructions/token_edit.rs b/programs/mango-v4/src/instructions/token_edit.rs index e98d65014..8cde77def 100644 --- a/programs/mango-v4/src/instructions/token_edit.rs +++ b/programs/mango-v4/src/instructions/token_edit.rs @@ -54,6 +54,7 @@ pub fn token_edit( zero_util_rate: Option, platform_liquidation_fee: Option, disable_asset_liquidation_opt: Option, + collateral_fee_per_day: Option, ) -> Result<()> { let group = ctx.accounts.group.load()?; @@ -483,7 +484,21 @@ pub fn token_edit( platform_liquidation_fee ); bank.platform_liquidation_fee = I80F48::from_num(platform_liquidation_fee); - require_group_admin = true; + if platform_liquidation_fee != 0.0 { + require_group_admin = true; + } + } + + if let Some(collateral_fee_per_day) = collateral_fee_per_day { + msg!( + "Collateral fee per day old {:?}, new {:?}", + bank.collateral_fee_per_day, + collateral_fee_per_day + ); + bank.collateral_fee_per_day = collateral_fee_per_day; + if collateral_fee_per_day != 0.0 { + require_group_admin = true; + } } if let Some(disable_asset_liquidation) = disable_asset_liquidation_opt { diff --git a/programs/mango-v4/src/instructions/token_register.rs b/programs/mango-v4/src/instructions/token_register.rs index d1a82adb8..252ca5670 100644 --- a/programs/mango-v4/src/instructions/token_register.rs +++ b/programs/mango-v4/src/instructions/token_register.rs @@ -45,6 +45,7 @@ pub fn token_register( zero_util_rate: f32, platform_liquidation_fee: f32, disable_asset_liquidation: bool, + collateral_fee_per_day: f32, ) -> Result<()> { // Require token 0 to be in the insurance token if token_index == INSURANCE_TOKEN_INDEX { @@ -129,7 +130,9 @@ pub fn token_register( zero_util_rate: I80F48::from_num(zero_util_rate), platform_liquidation_fee: I80F48::from_num(platform_liquidation_fee), collected_liquidation_fees: I80F48::ZERO, - reserved: [0; 1920], + collected_collateral_fees: I80F48::ZERO, + collateral_fee_per_day, + reserved: [0; 1900], }; let oracle_ref = &AccountInfoRef::borrow(ctx.accounts.oracle.as_ref())?; diff --git a/programs/mango-v4/src/instructions/token_register_trustless.rs b/programs/mango-v4/src/instructions/token_register_trustless.rs index 54c5fdc78..06f3526e1 100644 --- a/programs/mango-v4/src/instructions/token_register_trustless.rs +++ b/programs/mango-v4/src/instructions/token_register_trustless.rs @@ -108,7 +108,9 @@ pub fn token_register_trustless( deposit_limit: 0, zero_util_rate: I80F48::ZERO, collected_liquidation_fees: I80F48::ZERO, - reserved: [0; 1920], + collected_collateral_fees: I80F48::ZERO, + collateral_fee_per_day: 0.0, // TODO + reserved: [0; 1900], }; let oracle_ref = &AccountInfoRef::borrow(ctx.accounts.oracle.as_ref())?; if let Ok(oracle_price) = bank.oracle_price(&OracleAccountInfos::from_reader(oracle_ref), None) diff --git a/programs/mango-v4/src/lib.rs b/programs/mango-v4/src/lib.rs index e3ef47a95..73246fab7 100644 --- a/programs/mango-v4/src/lib.rs +++ b/programs/mango-v4/src/lib.rs @@ -84,6 +84,7 @@ pub mod mango_v4 { mngo_token_index_opt: Option, buyback_fees_expiry_interval_opt: Option, allowed_fast_listings_per_interval_opt: Option, + collateral_fee_interval_opt: Option, ) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::group_edit( @@ -100,6 +101,7 @@ pub mod mango_v4 { mngo_token_index_opt, buyback_fees_expiry_interval_opt, allowed_fast_listings_per_interval_opt, + collateral_fee_interval_opt, )?; Ok(()) } @@ -158,6 +160,7 @@ pub mod mango_v4 { zero_util_rate: f32, platform_liquidation_fee: f32, disable_asset_liquidation: bool, + collateral_fee_per_day: f32, ) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::token_register( @@ -192,6 +195,7 @@ pub mod mango_v4 { zero_util_rate, platform_liquidation_fee, disable_asset_liquidation, + collateral_fee_per_day, )?; Ok(()) } @@ -248,6 +252,7 @@ pub mod mango_v4 { zero_util_rate_opt: Option, platform_liquidation_fee_opt: Option, disable_asset_liquidation_opt: Option, + collateral_fee_per_day_opt: Option, ) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::token_edit( @@ -291,6 +296,7 @@ pub mod mango_v4 { zero_util_rate_opt, platform_liquidation_fee_opt, disable_asset_liquidation_opt, + collateral_fee_per_day_opt, )?; Ok(()) } @@ -1609,6 +1615,12 @@ pub mod mango_v4 { Ok(()) } + pub fn token_charge_collateral_fees(ctx: Context) -> Result<()> { + #[cfg(feature = "enable-gpl")] + instructions::token_charge_collateral_fees(ctx)?; + Ok(()) + } + pub fn alt_set(ctx: Context, index: u8) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::alt_set(ctx, index)?; diff --git a/programs/mango-v4/src/logs.rs b/programs/mango-v4/src/logs.rs index d920e04d6..bf9d699d6 100644 --- a/programs/mango-v4/src/logs.rs +++ b/programs/mango-v4/src/logs.rs @@ -779,3 +779,12 @@ pub struct TokenConditionalSwapStartLog { pub incentive_token_index: u16, pub incentive_amount: u64, } + +#[event] +pub struct TokenCollateralFeeLog { + pub mango_group: Pubkey, + pub mango_account: Pubkey, + pub token_index: u16, + pub asset_usage_fraction: i128, + pub fee: i128, +} diff --git a/programs/mango-v4/src/state/bank.rs b/programs/mango-v4/src/state/bank.rs index cafd731cb..8625d85a3 100644 --- a/programs/mango-v4/src/state/bank.rs +++ b/programs/mango-v4/src/state/bank.rs @@ -221,8 +221,16 @@ pub struct Bank { /// See also collected_fees_native and fees_withdrawn. pub collected_liquidation_fees: I80F48, + /// Collateral fees that have been collected (in native tokens) + /// + /// See also collected_fees_native and fees_withdrawn. + pub collected_collateral_fees: I80F48, + + /// The daily collateral fees rate for fully utilized collateral. + pub collateral_fee_per_day: f32, + #[derivative(Debug = "ignore")] - pub reserved: [u8; 1920], + pub reserved: [u8; 1900], } const_assert_eq!( size_of::(), @@ -259,8 +267,9 @@ const_assert_eq!( + 16 * 3 + 32 + 8 - + 16 * 3 - + 1920 + + 16 * 4 + + 4 + + 1900 ); const_assert_eq!(size_of::(), 3064); const_assert_eq!(size_of::() % 8, 0); @@ -304,6 +313,7 @@ impl Bank { indexed_borrows: I80F48::ZERO, collected_fees_native: I80F48::ZERO, collected_liquidation_fees: I80F48::ZERO, + collected_collateral_fees: I80F48::ZERO, fees_withdrawn: 0, dust: I80F48::ZERO, flash_loan_approved_amount: 0, @@ -368,7 +378,8 @@ impl Bank { deposit_limit: existing_bank.deposit_limit, zero_util_rate: existing_bank.zero_util_rate, platform_liquidation_fee: existing_bank.platform_liquidation_fee, - reserved: [0; 1920], + collateral_fee_per_day: existing_bank.collateral_fee_per_day, + reserved: [0; 1900], } } @@ -405,6 +416,7 @@ impl Bank { require!(self.are_borrows_reduce_only(), MangoError::SomeError); require_eq!(self.maint_asset_weight, I80F48::ZERO); } + require_gte!(self.collateral_fee_per_day, 0.0); Ok(()) } diff --git a/programs/mango-v4/src/state/group.rs b/programs/mango-v4/src/state/group.rs index b0e55a987..60812b280 100644 --- a/programs/mango-v4/src/state/group.rs +++ b/programs/mango-v4/src/state/group.rs @@ -98,11 +98,32 @@ pub struct Group { /// Number of fast listings that are allowed per interval pub allowed_fast_listings_per_interval: u16, - pub reserved: [u8; 1812], + pub padding2: [u8; 4], + + /// Intervals in which collateral fee is applied + pub collateral_fee_interval: u64, + + pub reserved: [u8; 1800], } const_assert_eq!( size_of::(), - 32 + 4 + 32 * 2 + 4 + 32 * 2 + 4 + 4 + 20 * 32 + 32 + 8 + 16 + 32 + 8 + 8 + 2 * 2 + 1812 + 32 + 4 + + 32 * 2 + + 4 + + 32 * 2 + + 4 + + 4 + + 20 * 32 + + 32 + + 8 + + 16 + + 32 + + 8 + + 8 + + 2 * 2 + + 4 + + 8 + + 1800 ); const_assert_eq!(size_of::(), 2736); const_assert_eq!(size_of::() % 8, 0); diff --git a/programs/mango-v4/src/state/mango_account.rs b/programs/mango-v4/src/state/mango_account.rs index ac8c215ef..fbfbe1bf6 100644 --- a/programs/mango-v4/src/state/mango_account.rs +++ b/programs/mango-v4/src/state/mango_account.rs @@ -151,8 +151,14 @@ pub struct MangoAccount { /// Next id to use when adding a token condition swap pub next_token_conditional_swap_id: u64, + pub temporary_delegate: Pubkey, + pub temporary_delegate_expiry: u64, + + /// Time at which the last collateral fee was charged + pub last_collateral_fee_charge: u64, + #[derivative(Debug = "ignore")] - pub reserved: [u8; 200], + pub reserved: [u8; 152], // dynamic pub header_version: u8, @@ -203,7 +209,10 @@ impl MangoAccount { buyback_fees_accrued_previous: 0, buyback_fees_expiry_timestamp: 0, next_token_conditional_swap_id: 0, - reserved: [0; 200], + temporary_delegate: Pubkey::default(), + temporary_delegate_expiry: 0, + last_collateral_fee_charge: 0, + reserved: [0; 152], header_version: DEFAULT_MANGO_ACCOUNT_VERSION, padding3: Default::default(), padding4: Default::default(), @@ -327,11 +336,12 @@ pub struct MangoAccountFixed { pub next_token_conditional_swap_id: u64, pub temporary_delegate: Pubkey, pub temporary_delegate_expiry: u64, - pub reserved: [u8; 160], + pub last_collateral_fee_charge: u64, + pub reserved: [u8; 152], } const_assert_eq!( size_of::(), - 32 * 4 + 8 + 8 * 8 + 32 + 8 + 160 + 32 * 4 + 8 + 8 * 8 + 32 + 8 + 8 + 152 ); const_assert_eq!(size_of::(), 400); const_assert_eq!(size_of::() % 8, 0); diff --git a/programs/mango-v4/tests/cases/mod.rs b/programs/mango-v4/tests/cases/mod.rs index 3b6d5324c..3d2c3b175 100644 --- a/programs/mango-v4/tests/cases/mod.rs +++ b/programs/mango-v4/tests/cases/mod.rs @@ -17,6 +17,7 @@ mod test_bankrupt_tokens; mod test_basic; mod test_benchmark; mod test_borrow_limits; +mod test_collateral_fees; mod test_delegate; mod test_fees_buyback_with_mngo; mod test_force_close; diff --git a/programs/mango-v4/tests/cases/test_collateral_fees.rs b/programs/mango-v4/tests/cases/test_collateral_fees.rs new file mode 100644 index 000000000..b7fbb3d6c --- /dev/null +++ b/programs/mango-v4/tests/cases/test_collateral_fees.rs @@ -0,0 +1,171 @@ +use super::*; + +#[tokio::test] +async fn test_collateral_fees() -> Result<(), TransportError> { + let context = TestContext::new().await; + let solana = &context.solana.clone(); + + let admin = TestKeypair::new(); + let owner = context.users[0].key; + let payer = context.users[1].key; + let mints = &context.mints[0..2]; + + let mango_setup::GroupWithTokens { group, tokens, .. } = mango_setup::GroupWithTokensConfig { + admin, + payer, + mints: mints.to_vec(), + ..mango_setup::GroupWithTokensConfig::default() + } + .create(solana) + .await; + + // fund the vaults to allow borrowing + create_funded_account( + &solana, + group, + owner, + 0, + &context.users[1], + mints, + 1_000_000, + 0, + ) + .await; + + let account = create_funded_account( + &solana, + group, + owner, + 1, + &context.users[1], + &mints[0..1], + 1_500, // maint: 0.8 * 1500 = 1200 + 0, + ) + .await; + + let hour = 60 * 60; + + send_tx( + solana, + GroupEdit { + group, + admin, + options: mango_v4::instruction::GroupEdit { + collateral_fee_interval_opt: Some(6 * hour), + ..group_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); + + send_tx( + solana, + TokenEdit { + group, + admin, + mint: mints[0].pubkey, + fallback_oracle: Pubkey::default(), + options: mango_v4::instruction::TokenEdit { + collateral_fee_per_day_opt: Some(0.1), + ..token_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); + + send_tx( + solana, + TokenEdit { + group, + admin, + mint: mints[1].pubkey, + fallback_oracle: Pubkey::default(), + options: mango_v4::instruction::TokenEdit { + loan_origination_fee_rate_opt: Some(0.0), + ..token_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); + + // + // TEST: Without borrows, charging collateral fees has no effect + // + + send_tx(solana, TokenChargeCollateralFeesInstruction { account }) + .await + .unwrap(); + let mut last_time = solana.clock_timestamp().await; + // no effect + assert_eq!( + account_position(solana, account, tokens[0].bank).await, + 1_500 + ); + + // + // TEST: With borrows, there's an effect depending on the time that has passed + // + + send_tx( + solana, + TokenWithdrawInstruction { + amount: 500, // maint: -1.2 * 500 = -600 (half of 1200) + allow_borrow: true, + account, + owner, + token_account: context.users[1].token_accounts[1], + bank_index: 0, + }, + ) + .await + .unwrap(); + + solana.set_clock_timestamp(last_time + 9 * hour).await; + + send_tx(solana, TokenChargeCollateralFeesInstruction { account }) + .await + .unwrap(); + last_time = solana.clock_timestamp().await; + assert!(assert_equal_f64_f64( + account_position_f64(solana, account, tokens[0].bank).await, + 1500.0 * (1.0 - 0.1 * (9.0 / 24.0) * (600.0 / 1200.0)), + 0.01 + )); + let last_balance = account_position_f64(solana, account, tokens[0].bank).await; + + // + // TEST: More borrows + // + + send_tx( + solana, + TokenWithdrawInstruction { + amount: 100, // maint: -1.2 * 600 = -720 + allow_borrow: true, + account, + owner, + token_account: context.users[1].token_accounts[1], + bank_index: 0, + }, + ) + .await + .unwrap(); + + solana.set_clock_timestamp(last_time + 7 * hour).await; + + send_tx(solana, TokenChargeCollateralFeesInstruction { account }) + .await + .unwrap(); + //last_time = solana.clock_timestamp().await; + assert!(assert_equal_f64_f64( + account_position_f64(solana, account, tokens[0].bank).await, + last_balance * (1.0 - 0.1 * (7.0 / 24.0) * (720.0 / (last_balance * 0.8))), + 0.01 + )); + + Ok(()) +} diff --git a/programs/mango-v4/tests/program_test/mango_client.rs b/programs/mango-v4/tests/program_test/mango_client.rs index e3a3c23cd..178508b4b 100644 --- a/programs/mango-v4/tests/program_test/mango_client.rs +++ b/programs/mango-v4/tests/program_test/mango_client.rs @@ -1078,6 +1078,7 @@ impl ClientInstruction for TokenRegisterInstruction { zero_util_rate: 0.0, platform_liquidation_fee: self.platform_liquidation_fee, disable_asset_liquidation: false, + collateral_fee_per_day: 0.0, }; let bank = Pubkey::find_program_address( @@ -1326,6 +1327,7 @@ pub fn token_edit_instruction_default() -> mango_v4::instruction::TokenEdit { zero_util_rate_opt: None, platform_liquidation_fee_opt: None, disable_asset_liquidation_opt: None, + collateral_fee_per_day_opt: None, } } @@ -1844,6 +1846,7 @@ pub fn group_edit_instruction_default() -> mango_v4::instruction::GroupEdit { mngo_token_index_opt: None, buyback_fees_expiry_interval_opt: None, allowed_fast_listings_per_interval_opt: None, + collateral_fee_interval_opt: None, } } @@ -5038,3 +5041,48 @@ impl ClientInstruction for TokenConditionalSwapStartInstruction { vec![self.liqor_owner] } } + +#[derive(Clone)] +pub struct TokenChargeCollateralFeesInstruction { + pub account: Pubkey, +} +#[async_trait::async_trait(?Send)] +impl ClientInstruction for TokenChargeCollateralFeesInstruction { + type Accounts = mango_v4::accounts::TokenChargeCollateralFees; + type Instruction = mango_v4::instruction::TokenChargeCollateralFees; + async fn to_instruction( + &self, + account_loader: impl ClientAccountLoader + 'async_trait, + ) -> (Self::Accounts, instruction::Instruction) { + let program_id = mango_v4::id(); + + let account = account_loader + .load_mango_account(&self.account) + .await + .unwrap(); + + let instruction = Self::Instruction {}; + + let health_check_metas = derive_health_check_remaining_account_metas( + &account_loader, + &account, + None, + true, + None, + ) + .await; + + let accounts = Self::Accounts { + group: account.fixed.group, + 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 { + vec![] + } +} diff --git a/ts/client/src/accounts/bank.ts b/ts/client/src/accounts/bank.ts index 9f4408c09..d88b24ae1 100644 --- a/ts/client/src/accounts/bank.ts +++ b/ts/client/src/accounts/bank.ts @@ -83,6 +83,7 @@ export class Bank implements BankForHealth { public zeroUtilRate: I80F48; public platformLiquidationFee: I80F48; public collectedLiquidationFees: I80F48; + public collectedCollateralFees: I80F48; static from( publicKey: PublicKey, @@ -148,6 +149,8 @@ export class Bank implements BankForHealth { zeroUtilRate: I80F48Dto; platformLiquidationFee: I80F48Dto; collectedLiquidationFees: I80F48Dto; + collectedCollateralFees: I80F48Dto; + collateralFeePerDay: number; }, ): Bank { return new Bank( @@ -213,6 +216,8 @@ export class Bank implements BankForHealth { obj.platformLiquidationFee, obj.collectedLiquidationFees, obj.disableAssetLiquidation == 0, + obj.collectedCollateralFees, + obj.collateralFeePerDay, ); } @@ -279,6 +284,8 @@ export class Bank implements BankForHealth { platformLiquidationFee: I80F48Dto, collectedLiquidationFees: I80F48Dto, public allowAssetLiquidation: boolean, + collectedCollateralFees: I80F48Dto, + public collateralFeePerDay: number, ) { this.name = utf8.decode(new Uint8Array(name)).split('\x00')[0]; this.oracleConfig = { @@ -311,6 +318,7 @@ export class Bank implements BankForHealth { this.zeroUtilRate = I80F48.from(zeroUtilRate); this.platformLiquidationFee = I80F48.from(platformLiquidationFee); this.collectedLiquidationFees = I80F48.from(collectedLiquidationFees); + this.collectedCollateralFees = I80F48.from(collectedCollateralFees); this._price = undefined; this._uiPrice = undefined; this._oracleLastUpdatedSlot = undefined; diff --git a/ts/client/src/accounts/group.ts b/ts/client/src/accounts/group.ts index 9873a9e77..9524094dd 100644 --- a/ts/client/src/accounts/group.ts +++ b/ts/client/src/accounts/group.ts @@ -50,6 +50,7 @@ export class Group { fastListingIntervalStart: BN; fastListingsInInterval: number; allowedFastListingsPerInterval: number; + collateralFeeInterval: BN; }, ): Group { return new Group( @@ -74,6 +75,7 @@ export class Group { obj.fastListingIntervalStart, obj.fastListingsInInterval, obj.allowedFastListingsPerInterval, + obj.collateralFeeInterval, [], // addressLookupTablesList new Map(), // banksMapByName new Map(), // banksMapByMint @@ -113,6 +115,7 @@ export class Group { public fastListingIntervalStart: BN, public fastListingsInInterval: number, public allowedFastListingsPerInterval: number, + public collateralFeeInterval: BN, public addressLookupTablesList: AddressLookupTableAccount[], public banksMapByName: Map, public banksMapByMint: Map, diff --git a/ts/client/src/client.ts b/ts/client/src/client.ts index 24067689a..1ffc79c50 100644 --- a/ts/client/src/client.ts +++ b/ts/client/src/client.ts @@ -304,6 +304,7 @@ export class MangoClient { feesMngoTokenIndex?: TokenIndex, feesExpiryInterval?: BN, allowedFastListingsPerInterval?: number, + collateralFeeInterval?: BN, ): Promise { const ix = await this.program.methods .groupEdit( @@ -319,6 +320,7 @@ export class MangoClient { feesMngoTokenIndex ?? null, feesExpiryInterval ?? null, allowedFastListingsPerInterval ?? null, + collateralFeeInterval ?? null, ) .accounts({ group: group.publicKey, @@ -462,6 +464,7 @@ export class MangoClient { params.zeroUtilRate, params.platformLiquidationFee, params.disableAssetLiquidation, + params.collateralFeePerDay, ) .accounts({ group: group.publicKey, @@ -550,6 +553,7 @@ export class MangoClient { params.zeroUtilRate, params.platformLiquidationFee, params.disableAssetLiquidation, + params.collateralFeePerDay, ) .accounts({ group: group.publicKey, diff --git a/ts/client/src/clientIxParamBuilder.ts b/ts/client/src/clientIxParamBuilder.ts index 20b83fe33..dc140db2e 100644 --- a/ts/client/src/clientIxParamBuilder.ts +++ b/ts/client/src/clientIxParamBuilder.ts @@ -31,6 +31,7 @@ export interface TokenRegisterParams { zeroUtilRate: number; platformLiquidationFee: number; disableAssetLiquidation: boolean; + collateralFeePerDay: number; } export const DefaultTokenRegisterParams: TokenRegisterParams = { @@ -72,6 +73,7 @@ export const DefaultTokenRegisterParams: TokenRegisterParams = { zeroUtilRate: 0.0, platformLiquidationFee: 0.0, disableAssetLiquidation: false, + collateralFeePerDay: 0.0, }; export interface TokenEditParams { @@ -114,6 +116,7 @@ export interface TokenEditParams { zeroUtilRate: number | null; platformLiquidationFee: number | null; disableAssetLiquidation: boolean | null; + collateralFeePerDay: number | null; } export const NullTokenEditParams: TokenEditParams = { @@ -156,6 +159,7 @@ export const NullTokenEditParams: TokenEditParams = { zeroUtilRate: null, platformLiquidationFee: null, disableAssetLiquidation: null, + collateralFeePerDay: null, }; export interface PerpEditParams { diff --git a/ts/client/src/mango_v4.ts b/ts/client/src/mango_v4.ts index ee363dabb..33e07c805 100644 --- a/ts/client/src/mango_v4.ts +++ b/ts/client/src/mango_v4.ts @@ -277,6 +277,12 @@ export type MangoV4 = { "type": { "option": "u16" } + }, + { + "name": "collateralFeeIntervalOpt", + "type": { + "option": "u64" + } } ] }, @@ -635,6 +641,10 @@ export type MangoV4 = { { "name": "disableAssetLiquidation", "type": "bool" + }, + { + "name": "collateralFeePerDay", + "type": "f32" } ] }, @@ -1051,6 +1061,12 @@ export type MangoV4 = { "type": { "option": "bool" } + }, + { + "name": "collateralFeePerDayOpt", + "type": { + "option": "f32" + } } ] }, @@ -5963,6 +5979,25 @@ export type MangoV4 = { } ] }, + { + "name": "tokenChargeCollateralFees", + "accounts": [ + { + "name": "group", + "isMut": false, + "isSigner": false + }, + { + "name": "account", + "isMut": true, + "isSigner": false, + "relations": [ + "group" + ] + } + ], + "args": [] + }, { "name": "altSet", "accounts": [ @@ -7531,12 +7566,30 @@ export type MangoV4 = { "defined": "I80F48" } }, + { + "name": "collectedCollateralFees", + "docs": [ + "Collateral fees that have been collected (in native tokens)", + "", + "See also collected_fees_native and fees_withdrawn." + ], + "type": { + "defined": "I80F48" + } + }, + { + "name": "collateralFeePerDay", + "docs": [ + "The daily collateral fees rate for fully utilized collateral." + ], + "type": "f32" + }, { "name": "reserved", "type": { "array": [ "u8", - 1920 + 1900 ] } } @@ -7664,12 +7717,28 @@ export type MangoV4 = { ], "type": "u16" }, + { + "name": "padding2", + "type": { + "array": [ + "u8", + 4 + ] + } + }, + { + "name": "collateralFeeInterval", + "docs": [ + "Intervals in which collateral fee is applied" + ], + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 1812 + 1800 ] } } @@ -7791,12 +7860,27 @@ export type MangoV4 = { ], "type": "u64" }, + { + "name": "temporaryDelegate", + "type": "publicKey" + }, + { + "name": "temporaryDelegateExpiry", + "type": "u64" + }, + { + "name": "lastCollateralFeeCharge", + "docs": [ + "Time at which the last collateral fee was charged" + ], + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 200 + 152 ] } }, @@ -9566,12 +9650,16 @@ export type MangoV4 = { "name": "temporaryDelegateExpiry", "type": "u64" }, + { + "name": "lastCollateralFeeCharge", + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 160 + 152 ] } } @@ -13699,6 +13787,36 @@ export type MangoV4 = { "index": false } ] + }, + { + "name": "TokenCollateralFeeLog", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "mangoAccount", + "type": "publicKey", + "index": false + }, + { + "name": "tokenIndex", + "type": "u16", + "index": false + }, + { + "name": "assetUsageFraction", + "type": "i128", + "index": false + }, + { + "name": "fee", + "type": "i128", + "index": false + } + ] } ], "errors": [ @@ -14334,6 +14452,12 @@ export const IDL: MangoV4 = { "type": { "option": "u16" } + }, + { + "name": "collateralFeeIntervalOpt", + "type": { + "option": "u64" + } } ] }, @@ -14692,6 +14816,10 @@ export const IDL: MangoV4 = { { "name": "disableAssetLiquidation", "type": "bool" + }, + { + "name": "collateralFeePerDay", + "type": "f32" } ] }, @@ -15108,6 +15236,12 @@ export const IDL: MangoV4 = { "type": { "option": "bool" } + }, + { + "name": "collateralFeePerDayOpt", + "type": { + "option": "f32" + } } ] }, @@ -20020,6 +20154,25 @@ export const IDL: MangoV4 = { } ] }, + { + "name": "tokenChargeCollateralFees", + "accounts": [ + { + "name": "group", + "isMut": false, + "isSigner": false + }, + { + "name": "account", + "isMut": true, + "isSigner": false, + "relations": [ + "group" + ] + } + ], + "args": [] + }, { "name": "altSet", "accounts": [ @@ -21588,12 +21741,30 @@ export const IDL: MangoV4 = { "defined": "I80F48" } }, + { + "name": "collectedCollateralFees", + "docs": [ + "Collateral fees that have been collected (in native tokens)", + "", + "See also collected_fees_native and fees_withdrawn." + ], + "type": { + "defined": "I80F48" + } + }, + { + "name": "collateralFeePerDay", + "docs": [ + "The daily collateral fees rate for fully utilized collateral." + ], + "type": "f32" + }, { "name": "reserved", "type": { "array": [ "u8", - 1920 + 1900 ] } } @@ -21721,12 +21892,28 @@ export const IDL: MangoV4 = { ], "type": "u16" }, + { + "name": "padding2", + "type": { + "array": [ + "u8", + 4 + ] + } + }, + { + "name": "collateralFeeInterval", + "docs": [ + "Intervals in which collateral fee is applied" + ], + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 1812 + 1800 ] } } @@ -21848,12 +22035,27 @@ export const IDL: MangoV4 = { ], "type": "u64" }, + { + "name": "temporaryDelegate", + "type": "publicKey" + }, + { + "name": "temporaryDelegateExpiry", + "type": "u64" + }, + { + "name": "lastCollateralFeeCharge", + "docs": [ + "Time at which the last collateral fee was charged" + ], + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 200 + 152 ] } }, @@ -23623,12 +23825,16 @@ export const IDL: MangoV4 = { "name": "temporaryDelegateExpiry", "type": "u64" }, + { + "name": "lastCollateralFeeCharge", + "type": "u64" + }, { "name": "reserved", "type": { "array": [ "u8", - 160 + 152 ] } } @@ -27756,6 +27962,36 @@ export const IDL: MangoV4 = { "index": false } ] + }, + { + "name": "TokenCollateralFeeLog", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "mangoAccount", + "type": "publicKey", + "index": false + }, + { + "name": "tokenIndex", + "type": "u16", + "index": false + }, + { + "name": "assetUsageFraction", + "type": "i128", + "index": false + }, + { + "name": "fee", + "type": "i128", + "index": false + } + ] } ], "errors": [ From 4f5ec41d7ad8480a262c384a93cfe0ac4daca33f Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Wed, 14 Feb 2024 10:00:09 +0100 Subject: [PATCH 26/42] tests: Check mango account backwards compatibility (#878) --- .../resources/test/mangoaccount-v0.21.3.bin | Bin 0 -> 11432 bytes programs/mango-v4/src/state/mango_account.rs | 100 ++++++++++++++++-- .../src/state/mango_account_components.rs | 8 +- .../src/state/token_conditional_swap.rs | 2 +- 4 files changed, 98 insertions(+), 12 deletions(-) create mode 100644 programs/mango-v4/resources/test/mangoaccount-v0.21.3.bin diff --git a/programs/mango-v4/resources/test/mangoaccount-v0.21.3.bin b/programs/mango-v4/resources/test/mangoaccount-v0.21.3.bin new file mode 100644 index 0000000000000000000000000000000000000000..f8389611f7a842d337ddc6f031485770c80134da GIT binary patch literal 11432 zcmezTS;94e>bFEt`ba1EyypX+|sn_C>5_~9~x zixDyVU!(w%lh~o`wx5bSEdSdthPgXzqt7JWUwaNBDIHLzsW9)$8mVV`Fh{UK8P@L0 zg4b$HZ-cqJ`ixqs(RxP*n7Vw_`#UEK&2)D>yd&nJ>E|g9 zP-O!~y>>gfYbt{X+9lR0&1V-*4>8j3AsOR!ppjUYnD5})IC@+1{1*|;S1ebxSpgrvXwt{wP7~+Fb^YO>z zX!wkV5B?CqFFTrkM#Bex2;i3;O+TaIgFgiD%Z{d>(eS|^0{CS|)6Z!5;12=(vZLu| zG<@)f0Djrg^fMYh_(K4{>}dKK4Ilg=fM0er{fveW{t&<~JDPq*!v}u|5RwJ0KOz>u u<|_!P2dN{ZW+3H9{V|ZCk7@U4d|-yjK#GjU$3TWYrrpCOJ}`X-5di>d5)M89 literal 0 HcmV?d00001 diff --git a/programs/mango-v4/src/state/mango_account.rs b/programs/mango-v4/src/state/mango_account.rs index fbfbe1bf6..99ea08781 100644 --- a/programs/mango-v4/src/state/mango_account.rs +++ b/programs/mango-v4/src/state/mango_account.rs @@ -86,7 +86,7 @@ impl MangoAccountPdaSeeds { // When not reading via idl, MangoAccount binary data is backwards compatible: when ignoring trailing bytes, // a v2 account can be read as a v1 account and a v3 account can be read as v1 or v2 etc. #[account] -#[derive(Derivative)] +#[derive(Derivative, PartialEq)] #[derivative(Debug)] pub struct MangoAccount { // fixed @@ -747,6 +747,12 @@ impl< self.dynamic.deref_or_borrow() } + #[allow(dead_code)] + fn dynamic_reserved_bytes(&self) -> &[u8] { + let reserved_offset = self.header().reserved_bytes_offset(); + &self.dynamic()[reserved_offset..reserved_offset + DYNAMIC_RESERVED_BYTES] + } + /// Returns /// - the position /// - the raw index into the token positions list (for use with get_raw/deactivate) @@ -1876,6 +1882,7 @@ impl<'a, 'info: 'a> MangoAccountLoader<'a> for &'a AccountLoader<'info, MangoAcc mod tests { use bytemuck::Zeroable; use itertools::Itertools; + use std::path::PathBuf; use crate::state::PostOrderType; @@ -2402,12 +2409,7 @@ mod tests { ); } - let reserved_offset = account.header.reserved_bytes_offset(); - assert!( - account.dynamic[reserved_offset..reserved_offset + DYNAMIC_RESERVED_BYTES] - .iter() - .all(|&v| v == 0) - ); + assert!(account.dynamic_reserved_bytes().iter().all(|&v| v == 0)); Ok(()) } @@ -2862,4 +2864,88 @@ mod tests { assert_eq!(to_be_closed_account_opt.unwrap().market_index, 3) } + + // Attempts reading old mango account data with borsh and with zerocopy + #[test] + fn test_mango_account_backwards_compatibility() -> Result<()> { + use solana_program_test::{find_file, read_file}; + + let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + d.push("resources/test"); + + // Grab live accounts with + // solana account CZGf1qbYPaSoabuA1EmdN8W5UHvH5CeXcNZ7RTx65aVQ --output-file programs/mango-v4/resources/test/mangoaccount-v0.21.3.bin + let fixtures = vec!["mangoaccount-v0.21.3"]; + + for fixture in fixtures { + let filename = format!("resources/test/{}.bin", fixture); + let account_bytes = read_file(find_file(&filename).unwrap()); + + // Read with borsh + let mut account_bytes_slice: &[u8] = &account_bytes; + let borsh_account = MangoAccount::try_deserialize(&mut account_bytes_slice)?; + + // Read with zerocopy + let zerocopy_reader = MangoAccountValue::from_bytes(&account_bytes[8..])?; + let fixed = &zerocopy_reader.fixed; + let zerocopy_account = MangoAccount { + group: fixed.group, + owner: fixed.owner, + name: fixed.name, + delegate: fixed.delegate, + account_num: fixed.account_num, + being_liquidated: fixed.being_liquidated, + in_health_region: fixed.in_health_region, + bump: fixed.bump, + padding: Default::default(), + net_deposits: fixed.net_deposits, + perp_spot_transfers: fixed.perp_spot_transfers, + health_region_begin_init_health: fixed.health_region_begin_init_health, + frozen_until: fixed.frozen_until, + buyback_fees_accrued_current: fixed.buyback_fees_accrued_current, + buyback_fees_accrued_previous: fixed.buyback_fees_accrued_previous, + buyback_fees_expiry_timestamp: fixed.buyback_fees_expiry_timestamp, + next_token_conditional_swap_id: fixed.next_token_conditional_swap_id, + temporary_delegate: fixed.temporary_delegate, + temporary_delegate_expiry: fixed.temporary_delegate_expiry, + last_collateral_fee_charge: fixed.last_collateral_fee_charge, + reserved: [0u8; 152], + + header_version: *zerocopy_reader.header_version(), + padding3: Default::default(), + + padding4: Default::default(), + tokens: zerocopy_reader.all_token_positions().cloned().collect_vec(), + + padding5: Default::default(), + serum3: zerocopy_reader.all_serum3_orders().cloned().collect_vec(), + + padding6: Default::default(), + perps: zerocopy_reader.all_perp_positions().cloned().collect_vec(), + + padding7: Default::default(), + perp_open_orders: zerocopy_reader.all_perp_orders().cloned().collect_vec(), + + padding8: Default::default(), + token_conditional_swaps: zerocopy_reader + .all_token_conditional_swaps() + .cloned() + .collect_vec(), + + reserved_dynamic: zerocopy_reader.dynamic_reserved_bytes().try_into().unwrap(), + }; + + // Both methods agree? + assert_eq!(borsh_account, zerocopy_account); + + // Serializing and deserializing produces the same data? + let mut borsh_bytes = Vec::new(); + borsh_account.try_serialize(&mut borsh_bytes)?; + let mut slice: &[u8] = &borsh_bytes; + let roundtrip_account = MangoAccount::try_deserialize(&mut slice)?; + assert_eq!(borsh_account, roundtrip_account); + } + + Ok(()) + } } diff --git a/programs/mango-v4/src/state/mango_account_components.rs b/programs/mango-v4/src/state/mango_account_components.rs index 06d30efc5..0b2444720 100644 --- a/programs/mango-v4/src/state/mango_account_components.rs +++ b/programs/mango-v4/src/state/mango_account_components.rs @@ -12,7 +12,7 @@ use crate::state::*; pub const FREE_ORDER_SLOT: PerpMarketIndex = PerpMarketIndex::MAX; #[zero_copy] -#[derive(AnchorDeserialize, AnchorSerialize, Derivative)] +#[derive(AnchorDeserialize, AnchorSerialize, Derivative, PartialEq)] #[derivative(Debug)] pub struct TokenPosition { // TODO: Why did we have deposits and borrows as two different values @@ -110,7 +110,7 @@ impl TokenPosition { } #[zero_copy] -#[derive(AnchorSerialize, AnchorDeserialize, Derivative)] +#[derive(AnchorSerialize, AnchorDeserialize, Derivative, PartialEq)] #[derivative(Debug)] pub struct Serum3Orders { pub open_orders: Pubkey, @@ -203,7 +203,7 @@ impl Default for Serum3Orders { } #[zero_copy] -#[derive(AnchorSerialize, AnchorDeserialize, Derivative)] +#[derive(AnchorSerialize, AnchorDeserialize, Derivative, PartialEq)] #[derivative(Debug)] pub struct PerpPosition { pub market_index: PerpMarketIndex, @@ -785,7 +785,7 @@ impl PerpPosition { } #[zero_copy] -#[derive(AnchorSerialize, AnchorDeserialize, Derivative)] +#[derive(AnchorSerialize, AnchorDeserialize, Derivative, PartialEq)] #[derivative(Debug)] pub struct PerpOpenOrder { pub side_and_tree: u8, // SideAndOrderTree -- enums aren't POD diff --git a/programs/mango-v4/src/state/token_conditional_swap.rs b/programs/mango-v4/src/state/token_conditional_swap.rs index 190485769..c14cb2724 100644 --- a/programs/mango-v4/src/state/token_conditional_swap.rs +++ b/programs/mango-v4/src/state/token_conditional_swap.rs @@ -45,7 +45,7 @@ pub enum TokenConditionalSwapType { } #[zero_copy] -#[derive(AnchorDeserialize, AnchorSerialize, Derivative)] +#[derive(AnchorDeserialize, AnchorSerialize, Derivative, PartialEq)] #[derivative(Debug)] pub struct TokenConditionalSwap { pub id: u64, From 3993a3fa66efe9b69cfeb8457fc76de76aa8e0b3 Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Wed, 14 Feb 2024 11:32:57 +0100 Subject: [PATCH 27/42] collateral fees: fixes after devnet test (#880) --- bin/keeper/src/crank.rs | 14 ++++-- .../token_charge_collateral_fees.rs | 7 ++- .../tests/cases/test_collateral_fees.rs | 49 ++++++++++++++++++- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/bin/keeper/src/crank.rs b/bin/keeper/src/crank.rs index 705ea8b2b..10c12d412 100644 --- a/bin/keeper/src/crank.rs +++ b/bin/keeper/src/crank.rs @@ -26,6 +26,7 @@ use prometheus::{register_histogram, Encoder, Histogram, IntCounter, Registry}; use solana_sdk::{ instruction::{AccountMeta, Instruction}, pubkey::Pubkey, + signature::Signature, }; use tokio::task::JoinHandle; use tracing::*; @@ -507,12 +508,13 @@ async fn charge_collateral_fees_inner( ix_to_send.push(ixs); } - send_batched_log_errors_no_confirm( + let txsigs = send_batched_log_errors_no_confirm( client.transaction_builder().await?, &client.client, &ix_to_send, ) .await; + info!("charge collateral fees: {:?}", txsigs); Ok(()) } @@ -522,7 +524,9 @@ async fn send_batched_log_errors_no_confirm( mut tx_builder: TransactionBuilder, client: &mango_v4_client::Client, ixs_list: &[PreparedInstructions], -) { +) -> Vec { + let mut txsigs = Vec::new(); + let mut current_batch = PreparedInstructions::new(); for ixs in ixs_list { let previous_batch = current_batch.clone(); @@ -533,7 +537,7 @@ async fn send_batched_log_errors_no_confirm( tx_builder.instructions = previous_batch.to_instructions(); match tx_builder.send(client).await { Err(err) => error!("could not send transaction: {err:?}"), - _ => {} + Ok(txsig) => txsigs.push(txsig), } current_batch = ixs.clone(); @@ -544,7 +548,9 @@ async fn send_batched_log_errors_no_confirm( tx_builder.instructions = current_batch.to_instructions(); match tx_builder.send(client).await { Err(err) => error!("could not send transaction: {err:?}"), - _ => {} + Ok(txsig) => txsigs.push(txsig), } } + + txsigs } diff --git a/programs/mango-v4/src/instructions/token_charge_collateral_fees.rs b/programs/mango-v4/src/instructions/token_charge_collateral_fees.rs index 028a287ff..fc145ba11 100644 --- a/programs/mango-v4/src/instructions/token_charge_collateral_fees.rs +++ b/programs/mango-v4/src/instructions/token_charge_collateral_fees.rs @@ -67,6 +67,11 @@ pub fn token_charge_collateral_fees(ctx: Context) -> } } + // If there's no assets or no liabs, we can't charge fees + if total_asset_health.is_zero() || total_liab_health.is_zero() { + return Ok(()); + } + // Users only pay for assets that are actively used to cover their liabilities. let asset_usage_scaling = (total_liab_health / total_asset_health) .max(I80F48::ZERO) @@ -77,7 +82,7 @@ pub fn token_charge_collateral_fees(ctx: Context) -> let token_position_count = account.active_token_positions().count(); for bank_ai in &ctx.remaining_accounts[0..token_position_count] { let mut bank = bank_ai.load_mut::()?; - if bank.collateral_fee_per_day <= 0.0 { + if bank.collateral_fee_per_day <= 0.0 || bank.maint_asset_weight.is_zero() { continue; } diff --git a/programs/mango-v4/tests/cases/test_collateral_fees.rs b/programs/mango-v4/tests/cases/test_collateral_fees.rs index b7fbb3d6c..5d069f023 100644 --- a/programs/mango-v4/tests/cases/test_collateral_fees.rs +++ b/programs/mango-v4/tests/cases/test_collateral_fees.rs @@ -1,3 +1,4 @@ +#![allow(unused_assignments)] use super::*; #[tokio::test] @@ -44,6 +45,18 @@ async fn test_collateral_fees() -> Result<(), TransportError> { ) .await; + let empty_account = create_funded_account( + &solana, + group, + owner, + 2, + &context.users[1], + &mints[0..0], + 0, + 0, + ) + .await; + let hour = 60 * 60; send_tx( @@ -92,6 +105,32 @@ async fn test_collateral_fees() -> Result<(), TransportError> { .await .unwrap(); + // + // TEST: It works on empty accounts + // + + send_tx( + solana, + TokenChargeCollateralFeesInstruction { + account: empty_account, + }, + ) + .await + .unwrap(); + let mut last_time = solana.clock_timestamp().await; + solana.set_clock_timestamp(last_time + 9 * hour).await; + + // send it twice, because the first time will never charge anything + send_tx( + solana, + TokenChargeCollateralFeesInstruction { + account: empty_account, + }, + ) + .await + .unwrap(); + last_time = solana.clock_timestamp().await; + // // TEST: Without borrows, charging collateral fees has no effect // @@ -99,7 +138,15 @@ async fn test_collateral_fees() -> Result<(), TransportError> { send_tx(solana, TokenChargeCollateralFeesInstruction { account }) .await .unwrap(); - let mut last_time = solana.clock_timestamp().await; + last_time = solana.clock_timestamp().await; + solana.set_clock_timestamp(last_time + 9 * hour).await; + + // send it twice, because the first time will never charge anything + send_tx(solana, TokenChargeCollateralFeesInstruction { account }) + .await + .unwrap(); + last_time = solana.clock_timestamp().await; + // no effect assert_eq!( account_position(solana, account, tokens[0].bank).await, From 7a8d46e36210d6352bc4b15678d344e87595ab10 Mon Sep 17 00:00:00 2001 From: Serge Farny Date: Wed, 14 Feb 2024 14:20:23 +0100 Subject: [PATCH 28/42] rust client: remove jupiter v4 (#879) * rust client: remove jupiter v4 * rust client: remove dead code * rust client: allow large enum variant for RawQuote enum --- bin/cli/src/main.rs | 11 +- bin/liquidator/src/main.rs | 7 - bin/liquidator/src/rebalance.rs | 26 +-- lib/client/src/client.rs | 55 ----- lib/client/src/jupiter/mod.rs | 43 +--- lib/client/src/jupiter/v4.rs | 376 -------------------------------- 6 files changed, 6 insertions(+), 512 deletions(-) delete mode 100644 lib/client/src/jupiter/v4.rs diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index 70c4e95d5..560840b1f 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -246,15 +246,8 @@ async fn main() -> Result<(), anyhow::Error> { let output_mint = pubkey_from_cli(&cmd.output_mint); let client = MangoClient::new_for_existing_account(client, account, owner).await?; let txsig = client - .jupiter_v4() - .swap( - input_mint, - output_mint, - cmd.amount, - cmd.slippage_bps, - mango_v4_client::JupiterSwapMode::ExactIn, - false, - ) + .jupiter_v6() + .swap(input_mint, output_mint, cmd.amount, cmd.slippage_bps, false) .await?; println!("{}", txsig); } diff --git a/bin/liquidator/src/main.rs b/bin/liquidator/src/main.rs index f489fabe8..047a7a169 100644 --- a/bin/liquidator/src/main.rs +++ b/bin/liquidator/src/main.rs @@ -57,7 +57,6 @@ enum BoolArg { #[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] enum JupiterVersionArg { Mock, - V4, V6, } @@ -65,7 +64,6 @@ impl From for jupiter::Version { fn from(a: JupiterVersionArg) -> Self { match a { JupiterVersionArg::Mock => jupiter::Version::Mock, - JupiterVersionArg::V4 => jupiter::Version::V4, JupiterVersionArg::V6 => jupiter::Version::V6, } } @@ -172,10 +170,6 @@ struct Cli { #[clap(long, env, value_enum, default_value = "v6")] jupiter_version: JupiterVersionArg, - /// override the url to jupiter v4 - #[clap(long, env, default_value = "https://quote-api.jup.ag/v4")] - jupiter_v4_url: String, - /// override the url to jupiter v6 #[clap(long, env, default_value = "https://quote-api.jup.ag/v6")] jupiter_v6_url: String, @@ -235,7 +229,6 @@ async fn main() -> anyhow::Result<()> { .commitment(commitment) .fee_payer(Some(liqor_owner.clone())) .timeout(rpc_timeout) - .jupiter_v4_url(cli.jupiter_v4_url) .jupiter_v6_url(cli.jupiter_v6_url) .jupiter_token(cli.jupiter_token) .transaction_builder_config( diff --git a/bin/liquidator/src/rebalance.rs b/bin/liquidator/src/rebalance.rs index 23757976a..eb982ec84 100644 --- a/bin/liquidator/src/rebalance.rs +++ b/bin/liquidator/src/rebalance.rs @@ -151,18 +151,7 @@ impl Rebalancer { let direct_sol_route_job = self.jupiter_quote(sol_mint, output_mint, in_amount_sol, true, jupiter_version); - let mut jobs = vec![full_route_job, direct_quote_route_job, direct_sol_route_job]; - - // for v6, add a v4 fallback - if self.config.jupiter_version == jupiter::Version::V6 { - jobs.push(self.jupiter_quote( - quote_mint, - output_mint, - in_amount_quote, - false, - jupiter::Version::V4, - )); - } + let jobs = vec![full_route_job, direct_quote_route_job, direct_sol_route_job]; let mut results = futures::future::join_all(jobs).await; let full_route = results.remove(0)?; @@ -211,18 +200,7 @@ impl Rebalancer { let direct_sol_route_job = self.jupiter_quote(input_mint, sol_mint, in_amount, true, jupiter_version); - let mut jobs = vec![full_route_job, direct_quote_route_job, direct_sol_route_job]; - - // for v6, add a v4 fallback - if self.config.jupiter_version == jupiter::Version::V6 { - jobs.push(self.jupiter_quote( - input_mint, - quote_mint, - in_amount, - false, - jupiter::Version::V4, - )); - } + let jobs = vec![full_route_job, direct_quote_route_job, direct_sol_route_job]; let mut results = futures::future::join_all(jobs).await; let full_route = results.remove(0)?; diff --git a/lib/client/src/client.rs b/lib/client/src/client.rs index 1ee3b2f90..5fe89a8aa 100644 --- a/lib/client/src/client.rs +++ b/lib/client/src/client.rs @@ -90,9 +90,6 @@ pub struct ClientConfig { #[builder(default = "ClientBuilder::default_rpc_confirm_transaction_config()")] pub rpc_confirm_transaction_config: RpcConfirmTransactionConfig, - #[builder(default = "\"https://quote-api.jup.ag/v4\".into()")] - pub jupiter_v4_url: String, - #[builder(default = "\"https://quote-api.jup.ag/v6\".into()")] pub jupiter_v6_url: String, @@ -1823,10 +1820,6 @@ impl MangoClient { // jupiter - pub fn jupiter_v4(&self) -> jupiter::v4::JupiterV4 { - jupiter::v4::JupiterV4 { mango_client: self } - } - pub fn jupiter_v6(&self) -> jupiter::v6::JupiterV6 { jupiter::v6::JupiterV6 { mango_client: self } } @@ -1869,54 +1862,6 @@ impl MangoClient { .await } - pub(crate) async fn deserialize_instructions_and_alts( - &self, - message: &solana_sdk::message::VersionedMessage, - ) -> anyhow::Result<(Vec, Vec)> { - let lookups = message.address_table_lookups().unwrap_or_default(); - let address_lookup_tables = self - .fetch_address_lookup_tables(lookups.iter().map(|a| &a.account_key)) - .await?; - - let mut account_keys = message.static_account_keys().to_vec(); - for (lookups, table) in lookups.iter().zip(address_lookup_tables.iter()) { - account_keys.extend( - lookups - .writable_indexes - .iter() - .map(|&index| table.addresses[index as usize]), - ); - } - for (lookups, table) in lookups.iter().zip(address_lookup_tables.iter()) { - account_keys.extend( - lookups - .readonly_indexes - .iter() - .map(|&index| table.addresses[index as usize]), - ); - } - - let compiled_ix = message - .instructions() - .iter() - .map(|ci| solana_sdk::instruction::Instruction { - program_id: *ci.program_id(&account_keys), - accounts: ci - .accounts - .iter() - .map(|&index| AccountMeta { - pubkey: account_keys[index as usize], - is_signer: message.is_signer(index.into()), - is_writable: message.is_maybe_writable(index.into()), - }) - .collect(), - data: ci.data.clone(), - }) - .collect(); - - Ok((compiled_ix, address_lookup_tables)) - } - fn instruction_cu(&self, health_cu: u32) -> u32 { self.context.compute_estimates.cu_per_mango_instruction + health_cu } diff --git a/lib/client/src/jupiter/mod.rs b/lib/client/src/jupiter/mod.rs index 85434c793..e8eeeb2ed 100644 --- a/lib/client/src/jupiter/mod.rs +++ b/lib/client/src/jupiter/mod.rs @@ -1,23 +1,21 @@ -pub mod v4; pub mod v6; use anchor_lang::prelude::*; use std::str::FromStr; -use crate::{JupiterSwapMode, MangoClient, TransactionBuilder}; +use crate::{MangoClient, TransactionBuilder}; use fixed::types::I80F48; #[derive(Clone, Copy, PartialEq, Eq)] pub enum Version { Mock, - V4, V6, } #[derive(Clone)] +#[allow(clippy::large_enum_variant)] pub enum RawQuote { Mock, - V4(v4::QueryRoute), V6(v6::QuoteResponse), } @@ -32,21 +30,6 @@ pub struct Quote { } impl Quote { - pub fn try_from_v4( - input_mint: Pubkey, - output_mint: Pubkey, - route: v4::QueryRoute, - ) -> anyhow::Result { - Ok(Quote { - input_mint, - output_mint, - price_impact_pct: route.price_impact_pct, - in_amount: route.in_amount.parse()?, - out_amount: route.out_amount.parse()?, - raw: RawQuote::V4(route), - }) - } - pub fn try_from_v6(query: v6::QuoteResponse) -> anyhow::Result { Ok(Quote { input_mint: Pubkey::from_str(&query.input_mint)?, @@ -65,7 +48,6 @@ impl Quote { pub fn first_route_label(&self) -> String { let label_maybe = match &self.raw { RawQuote::Mock => Some("mock".into()), - RawQuote::V4(raw) => raw.market_infos.first().map(|v| v.label.clone()), RawQuote::V6(raw) => raw .route_plan .first() @@ -129,21 +111,6 @@ impl<'a> Jupiter<'a> { ) -> anyhow::Result { Ok(match version { Version::Mock => self.quote_mock(input_mint, output_mint, amount).await?, - Version::V4 => Quote::try_from_v4( - input_mint, - output_mint, - self.mango_client - .jupiter_v4() - .quote( - input_mint, - output_mint, - amount, - slippage_bps, - JupiterSwapMode::ExactIn, - only_direct_routes, - ) - .await?, - )?, Version::V6 => Quote::try_from_v6( self.mango_client .jupiter_v6() @@ -165,12 +132,6 @@ impl<'a> Jupiter<'a> { ) -> anyhow::Result { match "e.raw { RawQuote::Mock => anyhow::bail!("can't prepare jupiter swap for the mock"), - RawQuote::V4(raw) => { - self.mango_client - .jupiter_v4() - .prepare_swap_transaction(quote.input_mint, quote.output_mint, raw) - .await - } RawQuote::V6(raw) => { self.mango_client .jupiter_v6() diff --git a/lib/client/src/jupiter/v4.rs b/lib/client/src/jupiter/v4.rs deleted file mode 100644 index 2c6dfb271..000000000 --- a/lib/client/src/jupiter/v4.rs +++ /dev/null @@ -1,376 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::str::FromStr; - -use anchor_lang::Id; -use anchor_spl::token::Token; - -use bincode::Options; - -use crate::{util, TransactionBuilder}; -use crate::{JupiterSwapMode, MangoClient}; - -use anyhow::Context; -use solana_sdk::instruction::Instruction; -use solana_sdk::signature::Signature; -use solana_sdk::{pubkey::Pubkey, signer::Signer}; - -#[derive(Deserialize, Serialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct QueryResult { - pub data: Vec, - pub time_taken: f64, - pub context_slot: u64, -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct QueryRoute { - pub in_amount: String, - pub out_amount: String, - pub price_impact_pct: f64, - pub market_infos: Vec, - pub amount: String, - pub slippage_bps: u64, - pub other_amount_threshold: String, - pub swap_mode: String, - pub fees: Option, -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct QueryMarketInfo { - pub id: String, - pub label: String, - pub input_mint: String, - pub output_mint: String, - pub not_enough_liquidity: bool, - pub in_amount: String, - pub out_amount: String, - pub min_in_amount: Option, - pub min_out_amount: Option, - pub price_impact_pct: Option, - pub lp_fee: QueryFee, - pub platform_fee: QueryFee, -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct QueryFee { - pub amount: String, - pub mint: String, - pub pct: Option, -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct QueryRouteFees { - pub signature_fee: f64, - pub open_orders_deposits: Vec, - pub ata_deposits: Vec, - pub total_fee_and_deposits: f64, - #[serde(rename = "minimalSOLForTransaction")] - pub minimal_sol_for_transaction: f64, -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct SwapRequest { - pub route: QueryRoute, - pub user_public_key: String, - #[serde(rename = "wrapUnwrapSOL")] - pub wrap_unwrap_sol: bool, - pub compute_unit_price_micro_lamports: Option, -} - -#[derive(Deserialize, Serialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct SwapResponse { - pub setup_transaction: Option, - pub swap_transaction: String, - pub cleanup_transaction: Option, -} - -pub struct JupiterV4<'a> { - pub mango_client: &'a MangoClient, -} - -impl<'a> JupiterV4<'a> { - pub async fn quote( - &self, - input_mint: Pubkey, - output_mint: Pubkey, - amount: u64, - slippage_bps: u64, - swap_mode: JupiterSwapMode, - only_direct_routes: bool, - ) -> anyhow::Result { - let response = self - .mango_client - .http_client - .get(format!( - "{}/quote", - self.mango_client.client.config().jupiter_v4_url - )) - .query(&[ - ("inputMint", input_mint.to_string()), - ("outputMint", output_mint.to_string()), - ("amount", format!("{}", amount)), - ("onlyDirectRoutes", only_direct_routes.to_string()), - ("enforceSingleTx", "true".into()), - ("filterTopNResult", "10".into()), - ("slippageBps", format!("{}", slippage_bps)), - ( - "swapMode", - match swap_mode { - JupiterSwapMode::ExactIn => "ExactIn", - JupiterSwapMode::ExactOut => "ExactOut", - } - .into(), - ), - ]) - .send() - .await - .context("quote request to jupiter")?; - let quote: QueryResult = util::http_error_handling(response).await.with_context(|| { - format!("error requesting jupiter route between {input_mint} and {output_mint}") - })?; - - let route = quote.data.first().ok_or_else(|| { - anyhow::anyhow!( - "no route for swap. found {} routes, but none were usable", - quote.data.len() - ) - })?; - - Ok(route.clone()) - } - - /// Find the instructions and account lookup tables for a jupiter swap through mango - /// - /// It would be nice if we didn't have to pass input_mint/output_mint - the data is - /// definitely in QueryRoute - but it's unclear how. - pub async fn prepare_swap_transaction( - &self, - input_mint: Pubkey, - output_mint: Pubkey, - route: &QueryRoute, - ) -> anyhow::Result { - let source_token = self.mango_client.context.token_by_mint(&input_mint)?; - let target_token = self.mango_client.context.token_by_mint(&output_mint)?; - - let swap_response = self - .mango_client - .http_client - .post(format!( - "{}/swap", - self.mango_client.client.config().jupiter_v4_url - )) - .json(&SwapRequest { - route: route.clone(), - user_public_key: self.mango_client.owner.pubkey().to_string(), - wrap_unwrap_sol: false, - compute_unit_price_micro_lamports: None, // we already prioritize - }) - .send() - .await - .context("swap transaction request to jupiter")?; - - let swap: SwapResponse = util::http_error_handling(swap_response) - .await - .context("error requesting jupiter swap")?; - - if swap.setup_transaction.is_some() || swap.cleanup_transaction.is_some() { - anyhow::bail!( - "chosen jupiter route requires setup or cleanup transactions, can't execute" - ); - } - - let jup_tx = bincode::options() - .with_fixint_encoding() - .reject_trailing_bytes() - .deserialize::( - &base64::decode(&swap.swap_transaction) - .context("base64 decoding jupiter transaction")?, - ) - .context("parsing jupiter transaction")?; - let ata_program = anchor_spl::associated_token::ID; - let token_program = anchor_spl::token::ID; - let compute_budget_program = solana_sdk::compute_budget::ID; - // these setup instructions should be placed outside of flashloan begin-end - let is_setup_ix = |k: Pubkey| -> bool { - k == ata_program || k == token_program || k == compute_budget_program - }; - let (jup_ixs, jup_alts) = self - .mango_client - .deserialize_instructions_and_alts(&jup_tx.message) - .await?; - let jup_action_ix_begin = jup_ixs - .iter() - .position(|ix| !is_setup_ix(ix.program_id)) - .ok_or_else(|| { - anyhow::anyhow!("jupiter swap response only had setup-like instructions") - })?; - let jup_action_ix_end = jup_ixs.len() - - jup_ixs - .iter() - .rev() - .position(|ix| !is_setup_ix(ix.program_id)) - .unwrap(); - - let bank_ams = [source_token.first_bank(), target_token.first_bank()] - .into_iter() - .map(util::to_writable_account_meta) - .collect::>(); - - let vault_ams = [source_token.first_vault(), target_token.first_vault()] - .into_iter() - .map(util::to_writable_account_meta) - .collect::>(); - - let owner = self.mango_client.owner(); - let account = &self.mango_client.mango_account().await?; - - let token_ams = [source_token.mint, target_token.mint] - .into_iter() - .map(|mint| { - util::to_writable_account_meta( - anchor_spl::associated_token::get_associated_token_address(&owner, &mint), - ) - }) - .collect::>(); - - let source_loan = if route.swap_mode == "ExactIn" { - u64::from_str(&route.amount).unwrap() - } else if route.swap_mode == "ExactOut" { - u64::from_str(&route.other_amount_threshold).unwrap() - } else { - anyhow::bail!("unknown swap mode: {}", route.swap_mode); - }; - let loan_amounts = vec![source_loan, 0u64]; - let num_loans: u8 = loan_amounts.len().try_into().unwrap(); - - // This relies on the fact that health account banks will be identical to the first_bank above! - let (health_ams, _health_cu) = self - .mango_client - .derive_health_check_remaining_account_metas( - account, - vec![source_token.token_index, target_token.token_index], - vec![source_token.token_index, target_token.token_index], - vec![], - ) - .await - .context("building health accounts")?; - - let mut instructions = Vec::new(); - - for ix in &jup_ixs[..jup_action_ix_begin] { - instructions.push(ix.clone()); - } - - // Ensure the source token account is created (jupiter takes care of the output account) - instructions.push( - spl_associated_token_account::instruction::create_associated_token_account_idempotent( - &owner, - &owner, - &source_token.mint, - &Token::id(), - ), - ); - - instructions.push(Instruction { - program_id: mango_v4::id(), - accounts: { - let mut ams = anchor_lang::ToAccountMetas::to_account_metas( - &mango_v4::accounts::FlashLoanBegin { - account: self.mango_client.mango_account_address, - owner, - token_program: Token::id(), - instructions: solana_sdk::sysvar::instructions::id(), - }, - None, - ); - ams.extend(bank_ams); - ams.extend(vault_ams.clone()); - ams.extend(token_ams.clone()); - ams.push(util::to_readonly_account_meta(self.mango_client.group())); - ams - }, - data: anchor_lang::InstructionData::data(&mango_v4::instruction::FlashLoanBegin { - loan_amounts, - }), - }); - for ix in &jup_ixs[jup_action_ix_begin..jup_action_ix_end] { - instructions.push(ix.clone()); - } - instructions.push(Instruction { - program_id: mango_v4::id(), - accounts: { - let mut ams = anchor_lang::ToAccountMetas::to_account_metas( - &mango_v4::accounts::FlashLoanEnd { - account: self.mango_client.mango_account_address, - owner, - token_program: Token::id(), - }, - None, - ); - ams.extend(health_ams); - ams.extend(vault_ams); - ams.extend(token_ams); - ams.push(util::to_readonly_account_meta(self.mango_client.group())); - ams - }, - data: anchor_lang::InstructionData::data(&mango_v4::instruction::FlashLoanEndV2 { - num_loans, - flash_loan_type: mango_v4::accounts_ix::FlashLoanType::Swap, - }), - }); - for ix in &jup_ixs[jup_action_ix_end..] { - instructions.push(ix.clone()); - } - - let mut address_lookup_tables = self.mango_client.mango_address_lookup_tables().await?; - address_lookup_tables.extend(jup_alts.into_iter()); - - let payer = owner; // maybe use fee_payer? but usually it's the same - - Ok(TransactionBuilder { - instructions, - address_lookup_tables, - payer, - signers: vec![self.mango_client.owner.clone()], - config: self - .mango_client - .client - .config() - .transaction_builder_config - .clone(), - }) - } - - pub async fn swap( - &self, - input_mint: Pubkey, - output_mint: Pubkey, - amount: u64, - slippage_bps: u64, - swap_mode: JupiterSwapMode, - only_direct_routes: bool, - ) -> anyhow::Result { - let route = self - .quote( - input_mint, - output_mint, - amount, - slippage_bps, - swap_mode, - only_direct_routes, - ) - .await?; - - let tx_builder = self - .prepare_swap_transaction(input_mint, output_mint, &route) - .await?; - - tx_builder.send_and_confirm(&self.mango_client.client).await - } -} From 34d86ef0f417e77022fbd6ca705bb3889d8f3bee Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Fri, 16 Feb 2024 11:30:32 +0100 Subject: [PATCH 29/42] collateral fees: better compute estimation (#881) --- lib/client/src/client.rs | 23 +++++++++++++++++++---- lib/client/src/context.rs | 6 ++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/lib/client/src/client.rs b/lib/client/src/client.rs index 5fe89a8aa..2af47dea0 100644 --- a/lib/client/src/client.rs +++ b/lib/client/src/client.rs @@ -1515,10 +1515,25 @@ impl MangoClient { &mango_v4::instruction::TokenChargeCollateralFees {}, ), }; - Ok(PreparedInstructions::from_single( - ix, - self.instruction_cu(health_cu), - )) + + let mut chargeable_token_positions = 0; + for tp in account.1.active_token_positions() { + let bank = self.first_bank(tp.token_index).await?; + let native = tp.native(&bank); + if native.is_positive() + && bank.maint_asset_weight.is_positive() + && bank.collateral_fee_per_day > 0.0 + { + chargeable_token_positions += 1; + } + } + + let cu_est = &self.context.compute_estimates; + let cu = cu_est.cu_per_charge_collateral_fees + + cu_est.cu_per_charge_collateral_fees_token * chargeable_token_positions + + health_cu; + + Ok(PreparedInstructions::from_single(ix, cu)) } // diff --git a/lib/client/src/context.rs b/lib/client/src/context.rs index 3b8267b67..2654c7ab8 100644 --- a/lib/client/src/context.rs +++ b/lib/client/src/context.rs @@ -120,6 +120,8 @@ pub struct ComputeEstimates { pub cu_per_perp_order_match: u32, pub cu_per_perp_order_cancel: u32, pub cu_per_oracle_fallback: u32, + pub cu_per_charge_collateral_fees: u32, + pub cu_per_charge_collateral_fees_token: u32, } impl Default for ComputeEstimates { @@ -139,6 +141,10 @@ impl Default for ComputeEstimates { cu_per_perp_order_cancel: 7_000, // measured around 2k, see test_health_compute_tokens_fallback_oracles cu_per_oracle_fallback: 2000, + // the base cost is mostly the division + cu_per_charge_collateral_fees: 20_000, + // per-chargable-token cost + cu_per_charge_collateral_fees_token: 12_000, } } } From 5d29eb2f0bde7bdaf78a427f82fe28c48d2bdd41 Mon Sep 17 00:00:00 2001 From: Serge Farny Date: Fri, 16 Feb 2024 14:01:18 +0100 Subject: [PATCH 30/42] Rust client: auto close spot account if needed to place a new order (#877) rust client: - add serum3 place order to command - add serum3 create open orders command - add serum3 close open orders command - auto create serum3 open orders if needed when placing a new order - auto close serum3 slot if needed when placing a new order & also close unused token if needed to place a serum3 order --- bin/cli/src/main.rs | 120 +++++++++++++ lib/client/src/client.rs | 371 +++++++++++++++++++++++++++++++++------ 2 files changed, 435 insertions(+), 56 deletions(-) diff --git a/bin/cli/src/main.rs b/bin/cli/src/main.rs index 560840b1f..06ab893be 100644 --- a/bin/cli/src/main.rs +++ b/bin/cli/src/main.rs @@ -1,5 +1,6 @@ use clap::clap_derive::ArgEnum; use clap::{Args, Parser, Subcommand}; +use mango_v4::accounts_ix::{Serum3OrderType, Serum3SelfTradeBehavior, Serum3Side}; use mango_v4::state::{PlaceOrderType, SelfTradeBehavior, Side}; use mango_v4_client::{ keypair_from_cli, pubkey_from_cli, Client, MangoClient, TransactionBuilderConfig, @@ -126,6 +127,63 @@ struct PerpPlaceOrder { rpc: Rpc, } +#[derive(Args, Debug, Clone)] +struct Serum3CreateOpenOrders { + #[clap(long)] + account: String, + + /// also pays for everything + #[clap(short, long)] + owner: String, + + #[clap(long)] + market_name: String, + + #[clap(flatten)] + rpc: Rpc, +} + +#[derive(Args, Debug, Clone)] +struct Serum3CloseOpenOrders { + #[clap(long)] + account: String, + + /// also pays for everything + #[clap(short, long)] + owner: String, + + #[clap(long)] + market_name: String, + + #[clap(flatten)] + rpc: Rpc, +} + +#[derive(Args, Debug, Clone)] +struct Serum3PlaceOrder { + #[clap(long)] + account: String, + + /// also pays for everything + #[clap(short, long)] + owner: String, + + #[clap(long)] + market_name: String, + + #[clap(long, value_enum)] + side: CliSide, + + #[clap(short, long)] + price: f64, + + #[clap(long)] + quantity: f64, + + #[clap(flatten)] + rpc: Rpc, +} + #[derive(Subcommand, Debug, Clone)] enum Command { CreateAccount(CreateAccount), @@ -167,6 +225,9 @@ enum Command { output: String, }, PerpPlaceOrder(PerpPlaceOrder), + Serum3CloseOpenOrders(Serum3CloseOpenOrders), + Serum3CreateOpenOrders(Serum3CreateOpenOrders), + Serum3PlaceOrder(Serum3PlaceOrder), } impl Rpc { @@ -326,6 +387,65 @@ async fn main() -> Result<(), anyhow::Error> { .await?; println!("{}", txsig); } + Command::Serum3CreateOpenOrders(cmd) => { + let client = cmd.rpc.client(Some(&cmd.owner))?; + let account = pubkey_from_cli(&cmd.account); + let owner = Arc::new(keypair_from_cli(&cmd.owner)); + let client = MangoClient::new_for_existing_account(client, account, owner).await?; + + let txsig = client.serum3_create_open_orders(&cmd.market_name).await?; + println!("{}", txsig); + } + Command::Serum3CloseOpenOrders(cmd) => { + let client = cmd.rpc.client(Some(&cmd.owner))?; + let account = pubkey_from_cli(&cmd.account); + let owner = Arc::new(keypair_from_cli(&cmd.owner)); + let client = MangoClient::new_for_existing_account(client, account, owner).await?; + + let txsig = client.serum3_close_open_orders(&cmd.market_name).await?; + println!("{}", txsig); + } + Command::Serum3PlaceOrder(cmd) => { + let client = cmd.rpc.client(Some(&cmd.owner))?; + let account = pubkey_from_cli(&cmd.account); + let owner = Arc::new(keypair_from_cli(&cmd.owner)); + let client = MangoClient::new_for_existing_account(client, account, owner).await?; + let market_index = client.context.serum3_market_index(&cmd.market_name); + let market = client.context.serum3(market_index); + let base_token = client.context.token(market.base_token_index); + let quote_token = client.context.token(market.quote_token_index); + + fn native(x: f64, b: u32) -> u64 { + (x * (10_i64.pow(b)) as f64) as u64 + } + + // coin_lot_size = base lot size ? + // cf priceNumberToLots + let price_lots = native(cmd.price, quote_token.decimals as u32) * market.coin_lot_size + / (native(1.0, base_token.decimals as u32) * market.pc_lot_size); + + // cf baseSizeNumberToLots + let max_base_lots = + native(cmd.quantity, base_token.decimals as u32) / market.coin_lot_size; + + let txsig = client + .serum3_place_order( + &cmd.market_name, + match cmd.side { + CliSide::Bid => Serum3Side::Bid, + CliSide::Ask => Serum3Side::Ask, + }, + price_lots, + max_base_lots as u64, + ((price_lots * max_base_lots) as f64 * 1.01) as u64, + Serum3SelfTradeBehavior::AbortTransaction, + Serum3OrderType::Limit, + SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(), + 10, + ) + .await?; + println!("{}", txsig); + } }; Ok(()) diff --git a/lib/client/src/client.rs b/lib/client/src/client.rs index 2af47dea0..0b4d1b646 100644 --- a/lib/client/src/client.rs +++ b/lib/client/src/client.rs @@ -1,3 +1,4 @@ +use anchor_client::ClientError::AnchorError; use std::ops::Deref; use std::str::FromStr; use std::sync::Arc; @@ -126,6 +127,7 @@ impl ClientBuilder { } } } + pub struct Client { config: ClientConfig, rpc_async: RpcClientAsync, @@ -577,40 +579,40 @@ impl MangoClient { let ixs = PreparedInstructions::from_vec( vec![ - spl_associated_token_account::instruction::create_associated_token_account_idempotent( - &self.owner(), - &self.owner(), - &mint, - &Token::id(), - ), - Instruction { - program_id: mango_v4::id(), - accounts: { - let mut ams = anchor_lang::ToAccountMetas::to_account_metas( - &mango_v4::accounts::TokenWithdraw { - group: self.group(), - account: self.mango_account_address, - owner: self.owner(), - bank: token.first_bank(), - vault: token.first_vault(), - oracle: token.oracle, - token_account: get_associated_token_address( - &self.owner(), - &token.mint, - ), - token_program: Token::id(), - }, - None, - ); - ams.extend(health_check_metas.into_iter()); - ams + spl_associated_token_account::instruction::create_associated_token_account_idempotent( + &self.owner(), + &self.owner(), + &mint, + &Token::id(), + ), + Instruction { + program_id: mango_v4::id(), + accounts: { + let mut ams = anchor_lang::ToAccountMetas::to_account_metas( + &mango_v4::accounts::TokenWithdraw { + group: self.group(), + account: self.mango_account_address, + owner: self.owner(), + bank: token.first_bank(), + vault: token.first_vault(), + oracle: token.oracle, + token_account: get_associated_token_address( + &self.owner(), + &token.mint, + ), + token_program: Token::id(), + }, + None, + ); + ams.extend(health_check_metas.into_iter()); + ams + }, + data: anchor_lang::InstructionData::data(&mango_v4::instruction::TokenWithdraw { + amount, + allow_borrow, + }), }, - data: anchor_lang::InstructionData::data(&mango_v4::instruction::TokenWithdraw { - amount, - allow_borrow, - }), - }, - ], + ], self.instruction_cu(health_cu), ); Ok(ixs) @@ -658,6 +660,45 @@ impl MangoClient { // Serum3 // + pub fn serum3_close_open_orders_instruction( + &self, + market_index: Serum3MarketIndex, + ) -> PreparedInstructions { + let account_pubkey = self.mango_account_address; + let s3 = self.context.serum3(market_index); + + let open_orders = self.serum3_create_open_orders_address(market_index); + + PreparedInstructions::from_single( + Instruction { + program_id: mango_v4::id(), + accounts: anchor_lang::ToAccountMetas::to_account_metas( + &mango_v4::accounts::Serum3CloseOpenOrders { + group: self.group(), + account: account_pubkey, + serum_market: s3.address, + serum_program: s3.serum_program, + serum_market_external: s3.serum_market_external, + open_orders, + owner: self.owner(), + sol_destination: self.owner(), + }, + None, + ), + data: anchor_lang::InstructionData::data( + &mango_v4::instruction::Serum3CloseOpenOrders {}, + ), + }, + self.context.compute_estimates.cu_per_mango_instruction, + ) + } + + pub async fn serum3_close_open_orders(&self, name: &str) -> anyhow::Result { + let market_index = self.context.serum3_market_index(name); + let ix = self.serum3_close_open_orders_instruction(market_index); + self.send_and_confirm_owner_tx(ix.to_instructions()).await + } + pub fn serum3_create_open_orders_instruction( &self, market_index: Serum3MarketIndex, @@ -665,15 +706,7 @@ impl MangoClient { let account_pubkey = self.mango_account_address; let s3 = self.context.serum3(market_index); - let open_orders = Pubkey::find_program_address( - &[ - b"Serum3OO".as_ref(), - account_pubkey.as_ref(), - s3.address.as_ref(), - ], - &mango_v4::ID, - ) - .0; + let open_orders = self.serum3_create_open_orders_address(market_index); Instruction { program_id: mango_v4::id(), @@ -698,6 +731,23 @@ impl MangoClient { } } + fn serum3_create_open_orders_address(&self, market_index: Serum3MarketIndex) -> Pubkey { + let account_pubkey = self.mango_account_address; + let s3 = self.context.serum3(market_index); + + let open_orders = Pubkey::find_program_address( + &[ + b"Serum3OO".as_ref(), + account_pubkey.as_ref(), + s3.address.as_ref(), + ], + &mango_v4::ID, + ) + .0; + + open_orders + } + pub async fn serum3_create_open_orders(&self, name: &str) -> anyhow::Result { let market_index = self.context.serum3_market_index(name); let ix = self.serum3_create_open_orders_instruction(market_index); @@ -721,20 +771,22 @@ impl MangoClient { let s3 = self.context.serum3(market_index); let base = self.context.serum3_base_token(market_index); let quote = self.context.serum3_quote_token(market_index); - let open_orders = account - .serum3_orders(market_index) - .expect("oo is created") - .open_orders; + let (payer_token, receiver_token) = match side { + Serum3Side::Bid => ("e, &base), + Serum3Side::Ask => (&base, "e), + }; + + let open_orders = account.serum3_orders(market_index).map(|x| x.open_orders)?; let (health_check_metas, health_cu) = self - .derive_health_check_remaining_account_metas(account, vec![], vec![], vec![]) + .derive_health_check_remaining_account_metas( + &account, + vec![], + vec![receiver_token.token_index], + vec![], + ) .await?; - let payer_token = match side { - Serum3Side::Bid => "e, - Serum3Side::Ask => &base, - }; - let ixs = PreparedInstructions::from_single( Instruction { program_id: mango_v4::id(), @@ -766,7 +818,7 @@ impl MangoClient { ams }, data: anchor_lang::InstructionData::data( - &mango_v4::instruction::Serum3PlaceOrder { + &mango_v4::instruction::Serum3PlaceOrderV2 { side, limit_price, max_base_qty, @@ -785,6 +837,205 @@ impl MangoClient { Ok(ixs) } + #[allow(clippy::too_many_arguments)] + pub async fn serum3_create_or_replace_account_instruction( + &self, + mut account: &mut MangoAccountValue, + market_index: Serum3MarketIndex, + side: Serum3Side, + ) -> anyhow::Result { + let mut ixs = PreparedInstructions::new(); + + let base = self.context.serum3_base_token(market_index); + let quote = self.context.serum3_quote_token(market_index); + let (payer_token, receiver_token) = match side { + Serum3Side::Bid => ("e, &base), + Serum3Side::Ask => (&base, "e), + }; + + let open_orders_opt = account + .serum3_orders(market_index) + .map(|x| x.open_orders) + .ok(); + + let mut missing_tokens = false; + + let token_replace_ixs = self + .find_existing_or_try_to_replace_token_positions( + &mut account, + &[payer_token.token_index, receiver_token.token_index], + ) + .await; + match token_replace_ixs { + Ok(res) => { + ixs.append(res); + } + Err(_) => missing_tokens = true, + } + + if open_orders_opt.is_none() { + let has_available_slot = account.all_serum3_orders().any(|p| !p.is_active()); + let should_close_one_open_orders_account = !has_available_slot || missing_tokens; + + if should_close_one_open_orders_account { + ixs.append( + self.deactivate_first_active_unused_serum3_orders(&mut account) + .await?, + ); + } + + // in case of missing token slots + // try again to create, as maybe deactivating the market slot resulted in some token being now unused + // but this time, in case of error, propagate to caller + if missing_tokens { + ixs.append( + self.find_existing_or_try_to_replace_token_positions( + &mut account, + &[payer_token.token_index, receiver_token.token_index], + ) + .await?, + ); + } + + ixs.push( + self.serum3_create_open_orders_instruction(market_index), + self.context.compute_estimates.cu_per_mango_instruction, + ); + + let created_open_orders = self.serum3_create_open_orders_address(market_index); + + account.create_serum3_orders(market_index)?.open_orders = created_open_orders; + } + + Ok(ixs) + } + + async fn deactivate_first_active_unused_serum3_orders( + &self, + account: &mut MangoAccountValue, + ) -> anyhow::Result { + let mut serum3_closable_order_market_index = None; + + for p in account.all_serum3_orders() { + let open_orders_acc = self + .account_fetcher + .fetch_raw_account(&p.open_orders) + .await?; + let open_orders_bytes = open_orders_acc.data(); + let open_orders_data: &serum_dex::state::OpenOrders = bytemuck::from_bytes( + &open_orders_bytes[5..5 + std::mem::size_of::()], + ); + + let is_closable = open_orders_data.free_slot_bits == u128::MAX + && open_orders_data.native_coin_total == 0 + && open_orders_data.native_pc_total == 0; + + if is_closable { + serum3_closable_order_market_index = Some(p.market_index); + break; + } + } + + let first_closable_slot = + serum3_closable_order_market_index.expect("couldn't find any serum3 slot available"); + + let ixs = self.serum3_close_open_orders_instruction(first_closable_slot); + + let first_closable_market = account.serum3_orders(first_closable_slot)?; + let (tk1, tk2) = ( + first_closable_market.base_token_index, + first_closable_market.quote_token_index, + ); + account.token_position_mut(tk1)?.0.decrement_in_use(); + account.token_position_mut(tk2)?.0.decrement_in_use(); + account.deactivate_serum3_orders(first_closable_slot)?; + + Ok(ixs) + } + + async fn find_existing_or_try_to_replace_token_positions( + &self, + account: &mut MangoAccountValue, + token_indexes: &[TokenIndex], + ) -> anyhow::Result { + let mut ixs = PreparedInstructions::new(); + + for token_index in token_indexes { + let result = self + .find_existing_or_try_to_replace_token_position(account, *token_index) + .await?; + if let Some(ix) = result { + ixs.append(ix); + } + } + + Ok(ixs) + } + + async fn find_existing_or_try_to_replace_token_position( + &self, + account: &mut MangoAccountValue, + token_index: TokenIndex, + ) -> anyhow::Result> { + let token_position_missing = account + .ensure_token_position(token_index) + .is_anchor_error_with_code(MangoError::NoFreeTokenPositionIndex.error_code()); + + if !token_position_missing { + return Ok(None); + } + + let ixs = self.deactivate_first_active_unused_token(account).await?; + account.ensure_token_position(token_index)?; + + Ok(Some(ixs)) + } + + async fn deactivate_first_active_unused_token( + &self, + account: &mut MangoAccountValue, + ) -> anyhow::Result { + let closable_tokens = account + .all_token_positions() + .enumerate() + .filter(|(_, p)| p.is_active() && !p.is_in_use()); + + let mut closable_token_position_raw_index_opt = None; + let mut closable_token_bank_opt = None; + + for (closable_token_position_raw_index, closable_token_position) in closable_tokens { + let bank = self.first_bank(closable_token_position.token_index).await?; + let native_balance = closable_token_position.native(&bank); + + if native_balance < I80F48::ZERO { + continue; + } + if native_balance > I80F48::ONE { + continue; + } + + closable_token_position_raw_index_opt = Some(closable_token_position_raw_index); + closable_token_bank_opt = Some(bank); + break; + } + + if closable_token_bank_opt.is_none() { + return Err(AnchorError(MangoError::NoFreeTokenPositionIndex.into()).into()); + } + + let withdraw_ixs = self + .token_withdraw_instructions( + &account, + closable_token_bank_opt.unwrap().mint, + u64::MAX, + false, + ) + .await?; + + account.deactivate_token_position(closable_token_position_raw_index_opt.unwrap()); + return Ok(withdraw_ixs); + } + #[allow(clippy::too_many_arguments)] pub async fn serum3_place_order( &self, @@ -798,9 +1049,12 @@ impl MangoClient { client_order_id: u64, limit: u16, ) -> anyhow::Result { - let account = self.mango_account().await?; + let mut account = self.mango_account().await?.clone(); let market_index = self.context.serum3_market_index(name); - let ixs = self + let create_or_replace_ixs = self + .serum3_create_or_replace_account_instruction(&mut account, market_index, side) + .await?; + let place_order_ixs = self .serum3_place_order_instruction( &account, market_index, @@ -814,6 +1068,10 @@ impl MangoClient { limit, ) .await?; + + let mut ixs = PreparedInstructions::new(); + ixs.append(create_or_replace_ixs); + ixs.append(place_order_ixs); self.send_and_confirm_owner_tx(ixs.to_instructions()).await } @@ -1960,7 +2218,7 @@ impl MangoClient { #[derive(Debug, thiserror::Error)] pub enum MangoClientError { #[error("Transaction simulation error. Error: {err:?}, Logs: {}", - .logs.iter().join("; ") + .logs.iter().join("; ") )] SendTransactionPreflightFailure { err: Option, @@ -1999,6 +2257,7 @@ pub enum FallbackOracleConfig { /// Every possible fallback oracle (may cause serious issues with the 64 accounts-per-tx limit) All, } + impl Default for FallbackOracleConfig { fn default() -> Self { FallbackOracleConfig::Dynamic From 8a3a3bf70b87b01b7836d1f46a0e5243a0e8fef9 Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Mon, 19 Feb 2024 09:00:30 +0100 Subject: [PATCH 31/42] flash loan: Add a "swap without fees" option (#882) --- mango_v4.json | 3 +++ programs/mango-v4/src/accounts_ix/flash_loan.rs | 6 ++++++ programs/mango-v4/src/instructions/flash_loan.rs | 4 ++-- ts/client/src/mango_v4.ts | 6 ++++++ ts/client/src/types.ts | 4 +++- 5 files changed, 20 insertions(+), 3 deletions(-) diff --git a/mango_v4.json b/mango_v4.json index 9c9a7bde6..948ecd217 100644 --- a/mango_v4.json +++ b/mango_v4.json @@ -10580,6 +10580,9 @@ }, { "name": "Swap" + }, + { + "name": "SwapWithoutFee" } ] } diff --git a/programs/mango-v4/src/accounts_ix/flash_loan.rs b/programs/mango-v4/src/accounts_ix/flash_loan.rs index a60a6b202..424af18d0 100644 --- a/programs/mango-v4/src/accounts_ix/flash_loan.rs +++ b/programs/mango-v4/src/accounts_ix/flash_loan.rs @@ -92,6 +92,12 @@ pub struct FlashLoanEnd<'info> { #[derive(PartialEq, Copy, Clone, Debug, AnchorSerialize, AnchorDeserialize)] #[repr(u8)] pub enum FlashLoanType { + /// An arbitrary flash loan Unknown, + /// A flash loan used for a swap where one token is exchanged for another. + /// + /// Deposits in this type get charged the flash_loan_swap_fee_rate Swap, + /// Like Swap, but without the flash_loan_swap_fee_rate + SwapWithoutFee, } diff --git a/programs/mango-v4/src/instructions/flash_loan.rs b/programs/mango-v4/src/instructions/flash_loan.rs index cdd3a59cd..5b2ecfc18 100644 --- a/programs/mango-v4/src/instructions/flash_loan.rs +++ b/programs/mango-v4/src/instructions/flash_loan.rs @@ -378,10 +378,10 @@ pub fn flash_loan_end<'key, 'accounts, 'remaining, 'info>( match flash_loan_type { FlashLoanType::Unknown => {} - FlashLoanType::Swap => { + FlashLoanType::Swap | FlashLoanType::SwapWithoutFee => { require_msg!( changes.len() == 2, - "when flash_loan_type is Swap there must be exactly 2 token vault changes" + "when flash_loan_type is Swap or SwapWithoutFee there must be exactly 2 token vault changes" ) } } diff --git a/ts/client/src/mango_v4.ts b/ts/client/src/mango_v4.ts index 33e07c805..bb18e1f3a 100644 --- a/ts/client/src/mango_v4.ts +++ b/ts/client/src/mango_v4.ts @@ -10580,6 +10580,9 @@ export type MangoV4 = { }, { "name": "Swap" + }, + { + "name": "SwapWithoutFee" } ] } @@ -24755,6 +24758,9 @@ export const IDL: MangoV4 = { }, { "name": "Swap" + }, + { + "name": "SwapWithoutFee" } ] } diff --git a/ts/client/src/types.ts b/ts/client/src/types.ts index 09d1b97f5..47d378477 100644 --- a/ts/client/src/types.ts +++ b/ts/client/src/types.ts @@ -9,11 +9,13 @@ export class FlashLoanWithdraw { export type FlashLoanType = | { unknown: Record } - | { swap: Record }; + | { swap: Record } + | { swapWithoutFee: Record }; // eslint-disable-next-line @typescript-eslint/no-namespace export namespace FlashLoanType { export const unknown = { unknown: {} }; export const swap = { swap: {} }; + export const swapWithoutFee = { swapWithoutFee: {} }; } export class InterestRateParams { From 338a9cb7b85680690da098382624056102f3e9ef Mon Sep 17 00:00:00 2001 From: Serge Farny Date: Mon, 19 Feb 2024 10:20:12 +0100 Subject: [PATCH 32/42] liquidator: add allow/forbid token list (#883) liquidator: add a way to restrict token accepted by the liquidator - add allow/forbid list of token for liquidation & conditional token swap triggering - add allow/forbid list for perp market liquidation - housekeeping: extract cli args to a dedicated file - move more hardcoded thing to config and stop using token name (replace with token index) --- bin/liquidator/src/cli_args.rs | 207 ++++++++++++++++++++++++++++++ bin/liquidator/src/liquidate.rs | 130 ++++++++++++------- bin/liquidator/src/main.rs | 203 ++++------------------------- bin/liquidator/src/trigger_tcs.rs | 39 +++++- 4 files changed, 356 insertions(+), 223 deletions(-) create mode 100644 bin/liquidator/src/cli_args.rs diff --git a/bin/liquidator/src/cli_args.rs b/bin/liquidator/src/cli_args.rs new file mode 100644 index 000000000..fe0eb1b76 --- /dev/null +++ b/bin/liquidator/src/cli_args.rs @@ -0,0 +1,207 @@ +use crate::trigger_tcs; +use anchor_lang::prelude::Pubkey; +use clap::Parser; +use mango_v4_client::{jupiter, priority_fees_cli}; +use std::collections::HashSet; + +#[derive(Parser, Debug)] +#[clap()] +pub(crate) struct CliDotenv { + // When --dotenv is passed, read the specified dotenv file before parsing args + #[clap(long)] + pub(crate) dotenv: std::path::PathBuf, + + pub(crate) remaining_args: Vec, +} + +// Prefer "--rebalance false" over "--no-rebalance" because it works +// better with REBALANCE=false env values. +#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum BoolArg { + True, + False, +} + +#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum JupiterVersionArg { + Mock, + V6, +} + +impl From for jupiter::Version { + fn from(a: JupiterVersionArg) -> Self { + match a { + JupiterVersionArg::Mock => jupiter::Version::Mock, + JupiterVersionArg::V6 => jupiter::Version::V6, + } + } +} + +#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TcsMode { + BorrowBuy, + SwapSellIntoBuy, + SwapCollateralIntoBuy, +} + +impl From for trigger_tcs::Mode { + fn from(a: TcsMode) -> Self { + match a { + TcsMode::BorrowBuy => trigger_tcs::Mode::BorrowBuyToken, + TcsMode::SwapSellIntoBuy => trigger_tcs::Mode::SwapSellIntoBuy, + TcsMode::SwapCollateralIntoBuy => trigger_tcs::Mode::SwapCollateralIntoBuy, + } + } +} + +pub(crate) fn cli_to_hashset>( + str_list: Option>, +) -> HashSet { + return str_list + .map(|v| v.iter().map(|x| T::from(*x)).collect::>()) + .unwrap_or_default(); +} + +#[derive(Parser)] +#[clap()] +pub struct Cli { + #[clap(short, long, env)] + pub(crate) rpc_url: String, + + #[clap(long, env, value_delimiter = ';')] + pub(crate) override_send_transaction_url: Option>, + + #[clap(long, env)] + pub(crate) liqor_mango_account: Pubkey, + + #[clap(long, env)] + pub(crate) liqor_owner: String, + + #[clap(long, env, default_value = "1000")] + pub(crate) check_interval_ms: u64, + + #[clap(long, env, default_value = "300")] + pub(crate) snapshot_interval_secs: u64, + + // how often do we refresh token swap route/prices + #[clap(long, env, default_value = "30")] + pub(crate) token_swap_refresh_interval_secs: u64, + + /// how many getMultipleAccounts requests to send in parallel + #[clap(long, env, default_value = "10")] + pub(crate) parallel_rpc_requests: usize, + + /// typically 100 is the max number of accounts getMultipleAccounts will retrieve at once + #[clap(long, env, default_value = "100")] + pub(crate) get_multiple_accounts_count: usize, + + /// liquidator health ratio should not fall below this value + #[clap(long, env, default_value = "50")] + pub(crate) min_health_ratio: f64, + + /// if rebalancing is enabled + /// + /// typically only disabled for tests where swaps are unavailable + #[clap(long, env, value_enum, default_value = "true")] + pub(crate) rebalance: BoolArg, + + /// max slippage to request on swaps to rebalance spot tokens + #[clap(long, env, default_value = "100")] + pub(crate) rebalance_slippage_bps: u64, + + /// tokens to not rebalance (in addition to USDC=0); use a comma separated list of token index + #[clap(long, env, value_parser, value_delimiter = ',')] + pub(crate) rebalance_skip_tokens: Option>, + + /// When closing borrows, the rebalancer can't close token positions exactly. + /// Instead it purchases too much and then gets rid of the excess in a second step. + /// If this is 0.05, then it'll swap borrow_value * (1 + 0.05) quote token into borrow token. + #[clap(long, env, default_value = "0.05")] + pub(crate) rebalance_borrow_settle_excess: f64, + + #[clap(long, env, default_value = "30")] + pub(crate) rebalance_refresh_timeout_secs: u64, + + /// if taking tcs orders is enabled + /// + /// typically only disabled for tests where swaps are unavailable + #[clap(long, env, value_enum, default_value = "true")] + pub(crate) take_tcs: BoolArg, + + /// profit margin at which to take tcs orders + #[clap(long, env, default_value = "0.0005")] + pub(crate) tcs_profit_fraction: f64, + + /// control how tcs triggering provides buy tokens + #[clap(long, env, value_enum, default_value = "swap-sell-into-buy")] + pub(crate) tcs_mode: TcsMode, + + /// largest tcs amount to trigger in one transaction, in dollar + #[clap(long, env, default_value = "1000.0")] + pub(crate) tcs_max_trigger_amount: f64, + + /// Minimum fraction of max_buy to buy for success when triggering, + /// useful in conjunction with jupiter swaps in same tx to avoid over-buying. + /// + /// Can be set to 0 to allow executions of any size. + #[clap(long, env, default_value = "0.7")] + pub(crate) tcs_min_buy_fraction: f64, + + #[clap(flatten)] + pub(crate) prioritization_fee_cli: priority_fees_cli::PriorityFeeArgs, + + /// url to the lite-rpc websocket, optional + #[clap(long, env, default_value = "")] + pub(crate) lite_rpc_url: String, + + /// compute limit requested for liquidation instructions + #[clap(long, env, default_value = "250000")] + pub(crate) compute_limit_for_liquidation: u32, + + /// compute limit requested for tcs trigger instructions + #[clap(long, env, default_value = "300000")] + pub(crate) compute_limit_for_tcs: u32, + + /// control which version of jupiter to use + #[clap(long, env, value_enum, default_value = "v6")] + pub(crate) jupiter_version: JupiterVersionArg, + + /// override the url to jupiter v6 + #[clap(long, env, default_value = "https://quote-api.jup.ag/v6")] + pub(crate) jupiter_v6_url: String, + + /// provide a jupiter token, currently only for jup v6 + #[clap(long, env, default_value = "")] + pub(crate) jupiter_token: String, + + /// size of the swap to quote via jupiter to get slippage info, in dollar + /// should be larger than tcs_max_trigger_amount + #[clap(long, env, default_value = "1000.0")] + pub(crate) jupiter_swap_info_amount: f64, + + /// report liquidator's existence and pubkey + #[clap(long, env, value_enum, default_value = "true")] + pub(crate) telemetry: BoolArg, + + /// liquidation refresh timeout in secs + #[clap(long, env, default_value = "30")] + pub(crate) liquidation_refresh_timeout_secs: u8, + + /// tokens to exclude for liquidation/tcs (never liquidate any pair where base or quote is in this list) + #[clap(long, env, value_parser, value_delimiter = ' ')] + pub(crate) forbidden_tokens: Option>, + + /// tokens to allow for liquidation/tcs (only liquidate a pair if base or quote is in this list) + /// when empty, allows all pairs + #[clap(long, env, value_parser, value_delimiter = ' ')] + pub(crate) only_allow_tokens: Option>, + + /// perp market to exclude for liquidation + #[clap(long, env, value_parser, value_delimiter = ' ')] + pub(crate) liquidation_forbidden_perp_markets: Option>, + + /// perp market to allow for liquidation (only liquidate if is in this list) + /// when empty, allows all pairs + #[clap(long, env, value_parser, value_delimiter = ' ')] + pub(crate) liquidation_only_allow_perp_markets: Option>, +} diff --git a/bin/liquidator/src/liquidate.rs b/bin/liquidator/src/liquidate.rs index e03d594c3..82854eb3d 100644 --- a/bin/liquidator/src/liquidate.rs +++ b/bin/liquidator/src/liquidate.rs @@ -1,3 +1,4 @@ +use std::cmp::Reverse; use std::collections::HashSet; use std::time::Duration; @@ -20,6 +21,12 @@ pub struct Config { pub refresh_timeout: Duration, pub compute_limit_for_liq_ix: u32, + pub only_allowed_tokens: HashSet, + pub forbidden_tokens: HashSet, + + pub only_allowed_perp_markets: HashSet, + pub forbidden_perp_markets: HashSet, + /// If we cram multiple ix into a transaction, don't exceed this level /// of expected-cu. pub max_cu_per_transaction: u32, @@ -33,8 +40,6 @@ struct LiquidateHelper<'a> { health_cache: &'a HealthCache, maint_health: I80F48, liqor_min_health_ratio: I80F48, - allowed_asset_tokens: HashSet, - allowed_liab_tokens: HashSet, config: Config, } @@ -136,6 +141,25 @@ impl<'a> LiquidateHelper<'a> { let all_perp_base_positions: anyhow::Result< Vec>, > = stream::iter(self.liqee.active_perp_positions()) + .filter(|pp| async { + if self + .config + .forbidden_perp_markets + .contains(&pp.market_index) + { + return false; + } + if !self.config.only_allowed_perp_markets.is_empty() + && !self + .config + .only_allowed_perp_markets + .contains(&pp.market_index) + { + return false; + } + + true + }) .then(|pp| async { let base_lots = pp.base_position_lots(); if (base_lots == 0 && pp.quote_position_native() <= 0) || pp.has_open_taker_fills() @@ -353,6 +377,7 @@ impl<'a> LiquidateHelper<'a> { .health_cache .token_infos .iter() + .filter(|p| !self.config.forbidden_tokens.contains(&p.token_index)) .zip( self.health_cache .effective_token_balances(HealthType::LiquidationEnd) @@ -378,26 +403,9 @@ impl<'a> LiquidateHelper<'a> { is_valid_asset.then_some((ti.token_index, is_preferred, quote_value)) }) .collect_vec(); - // sort such that preferred tokens are at the end, and the one with the larget quote value is - // at the very end - potential_assets.sort_by_key(|(_, is_preferred, amount)| (*is_preferred, *amount)); - - // filter only allowed assets - let potential_allowed_assets = potential_assets.iter().filter_map(|(ti, _, _)| { - let is_allowed = self - .allowed_asset_tokens - .contains(&self.client.context.token(*ti).mint); - is_allowed.then_some(*ti) - }); - - let asset_token_index = match potential_allowed_assets.last() { - Some(token_index) => token_index, - None => anyhow::bail!( - "mango account {}, has no allowed asset tokens that are liquidatable: {:?}", - self.pubkey, - potential_assets, - ), - }; + // sort such that preferred tokens are at the start, and the one with the larget quote value is + // at 0 + potential_assets.sort_by_key(|(_, is_preferred, amount)| Reverse((*is_preferred, *amount))); // // find a good liab, same as for assets @@ -410,29 +418,69 @@ impl<'a> LiquidateHelper<'a> { let tokens = (-ti.balance_spot).min(-effective.spot_and_perp); let is_valid_liab = tokens > 0; let quote_value = tokens * ti.prices.oracle; - is_valid_liab.then_some((ti.token_index, quote_value)) + is_valid_liab.then_some((ti.token_index, false, quote_value)) }) .collect_vec(); - // largest liquidatable liability at the end - potential_liabs.sort_by_key(|(_, amount)| *amount); + // largest liquidatable liability at the start + potential_liabs.sort_by_key(|(_, is_preferred, amount)| Reverse((*is_preferred, *amount))); - // filter only allowed liabs - let potential_allowed_liabs = potential_liabs.iter().filter_map(|(ti, _)| { - let is_allowed = self - .allowed_liab_tokens - .contains(&self.client.context.token(*ti).mint); - is_allowed.then_some(*ti) - }); + // + // Find a pair + // - let liab_token_index = match potential_allowed_liabs.last() { - Some(token_index) => token_index, - None => anyhow::bail!( - "mango account {}, has no liab tokens that are liquidatable: {:?}", + fn find_best_token( + lh: &LiquidateHelper, + token_list: &Vec<(TokenIndex, bool, I80F48)>, + ) -> (Option, Option) { + let mut best_whitelisted = None; + let mut best = None; + + let allowed_token_list = token_list + .iter() + .filter_map(|(ti, _, _)| (!lh.config.forbidden_tokens.contains(ti)).then_some(ti)); + + for ti in allowed_token_list { + let whitelisted = lh.config.only_allowed_tokens.is_empty() + || lh.config.only_allowed_tokens.contains(ti); + if best.is_none() { + best = Some(*ti); + } + + if best_whitelisted.is_none() && whitelisted { + best_whitelisted = Some(*ti); + break; + } + } + + return (best, best_whitelisted); + } + + let (best_asset, best_whitelisted_asset) = find_best_token(self, &potential_assets); + let (best_liab, best_whitelisted_liab) = find_best_token(self, &potential_liabs); + + let best_pair_opt = [ + (best_whitelisted_asset, best_liab), + (best_asset, best_whitelisted_liab), + ] + .iter() + .filter_map(|(a, l)| (a.is_some() && l.is_some()).then_some((a.unwrap(), l.unwrap()))) + .next(); + + if best_pair_opt.is_none() { + anyhow::bail!( + "mango account {}, has no allowed asset/liab tokens pair that are liquidatable: assets={:?}; liabs={:?}", self.pubkey, + potential_assets, potential_liabs, - ), + ) }; + let (asset_token_index, liab_token_index) = best_pair_opt.unwrap(); + + // + // Compute max transfer size + // + let max_liab_transfer = self .max_token_liab_transfer(liab_token_index, asset_token_index) .await @@ -484,9 +532,7 @@ impl<'a> LiquidateHelper<'a> { .iter() .find(|(liab_token_index, _liab_price, liab_usdc_equivalent)| { liab_usdc_equivalent.is_negative() - && self - .allowed_liab_tokens - .contains(&self.client.context.token(*liab_token_index).mint) + && !self.config.forbidden_tokens.contains(liab_token_index) }) .ok_or_else(|| { anyhow::anyhow!( @@ -643,8 +689,6 @@ pub async fn maybe_liquidate_account( let maint_health = health_cache.health(HealthType::Maint); - let all_token_mints = HashSet::from_iter(mango_client.context.tokens.values().map(|c| c.mint)); - // try liquidating let maybe_txsig = LiquidateHelper { client: mango_client, @@ -654,8 +698,6 @@ pub async fn maybe_liquidate_account( health_cache: &health_cache, maint_health, liqor_min_health_ratio, - allowed_asset_tokens: all_token_mints.clone(), - allowed_liab_tokens: all_token_mints, config: config.clone(), } .send_liq_tx() diff --git a/bin/liquidator/src/main.rs b/bin/liquidator/src/main.rs index 047a7a169..dfa9b190b 100644 --- a/bin/liquidator/src/main.rs +++ b/bin/liquidator/src/main.rs @@ -7,10 +7,9 @@ use anchor_client::Cluster; use anyhow::Context; use clap::Parser; use mango_v4::state::{PerpMarketIndex, TokenIndex}; -use mango_v4_client::priority_fees_cli; use mango_v4_client::AsyncChannelSendUnlessFull; use mango_v4_client::{ - account_update_stream, chain_data, error_tracking::ErrorTracking, jupiter, keypair_from_cli, + account_update_stream, chain_data, error_tracking::ErrorTracking, keypair_from_cli, snapshot_source, websocket_source, Client, MangoClient, MangoClientError, MangoGroupContext, TransactionBuilderConfig, }; @@ -21,6 +20,7 @@ use solana_sdk::pubkey::Pubkey; use solana_sdk::signer::Signer; use tracing::*; +pub mod cli_args; pub mod liquidate; pub mod metrics; pub mod rebalance; @@ -36,158 +36,6 @@ use crate::util::{is_mango_account, is_mint_info, is_perp_market}; #[global_allocator] static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc; -#[derive(Parser, Debug)] -#[clap()] -struct CliDotenv { - // When --dotenv is passed, read the specified dotenv file before parsing args - #[clap(long)] - dotenv: std::path::PathBuf, - - remaining_args: Vec, -} - -// Prefer "--rebalance false" over "--no-rebalance" because it works -// better with REBALANCE=false env values. -#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] -enum BoolArg { - True, - False, -} - -#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] -enum JupiterVersionArg { - Mock, - V6, -} - -impl From for jupiter::Version { - fn from(a: JupiterVersionArg) -> Self { - match a { - JupiterVersionArg::Mock => jupiter::Version::Mock, - JupiterVersionArg::V6 => jupiter::Version::V6, - } - } -} - -#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] -enum TcsMode { - BorrowBuy, - SwapSellIntoBuy, - SwapCollateralIntoBuy, -} - -impl From for trigger_tcs::Mode { - fn from(a: TcsMode) -> Self { - match a { - TcsMode::BorrowBuy => trigger_tcs::Mode::BorrowBuyToken, - TcsMode::SwapSellIntoBuy => trigger_tcs::Mode::SwapSellIntoBuy, - TcsMode::SwapCollateralIntoBuy => trigger_tcs::Mode::SwapCollateralIntoBuy, - } - } -} - -#[derive(Parser)] -#[clap()] -struct Cli { - #[clap(short, long, env)] - rpc_url: String, - - #[clap(long, env, value_delimiter = ';')] - override_send_transaction_url: Option>, - - #[clap(long, env)] - liqor_mango_account: Pubkey, - - #[clap(long, env)] - liqor_owner: String, - - #[clap(long, env, default_value = "1000")] - check_interval_ms: u64, - - #[clap(long, env, default_value = "300")] - snapshot_interval_secs: u64, - - /// how many getMultipleAccounts requests to send in parallel - #[clap(long, env, default_value = "10")] - parallel_rpc_requests: usize, - - /// typically 100 is the max number of accounts getMultipleAccounts will retrieve at once - #[clap(long, env, default_value = "100")] - get_multiple_accounts_count: usize, - - /// liquidator health ratio should not fall below this value - #[clap(long, env, default_value = "50")] - min_health_ratio: f64, - - /// if rebalancing is enabled - /// - /// typically only disabled for tests where swaps are unavailable - #[clap(long, env, value_enum, default_value = "true")] - rebalance: BoolArg, - - /// max slippage to request on swaps to rebalance spot tokens - #[clap(long, env, default_value = "100")] - rebalance_slippage_bps: u64, - - /// tokens to not rebalance (in addition to USDC); use a comma separated list of names - #[clap(long, env, default_value = "")] - rebalance_skip_tokens: String, - - /// if taking tcs orders is enabled - /// - /// typically only disabled for tests where swaps are unavailable - #[clap(long, env, value_enum, default_value = "true")] - take_tcs: BoolArg, - - /// profit margin at which to take tcs orders - #[clap(long, env, default_value = "0.0005")] - tcs_profit_fraction: f64, - - /// control how tcs triggering provides buy tokens - #[clap(long, env, value_enum, default_value = "swap-sell-into-buy")] - tcs_mode: TcsMode, - - /// largest tcs amount to trigger in one transaction, in dollar - #[clap(long, env, default_value = "1000.0")] - tcs_max_trigger_amount: f64, - - #[clap(flatten)] - prioritization_fee_cli: priority_fees_cli::PriorityFeeArgs, - - /// url to the lite-rpc websocket, optional - #[clap(long, env, default_value = "")] - lite_rpc_url: String, - - /// compute limit requested for liquidation instructions - #[clap(long, env, default_value = "250000")] - compute_limit_for_liquidation: u32, - - /// compute limit requested for tcs trigger instructions - #[clap(long, env, default_value = "300000")] - compute_limit_for_tcs: u32, - - /// control which version of jupiter to use - #[clap(long, env, value_enum, default_value = "v6")] - jupiter_version: JupiterVersionArg, - - /// override the url to jupiter v6 - #[clap(long, env, default_value = "https://quote-api.jup.ag/v6")] - jupiter_v6_url: String, - - /// provide a jupiter token, currently only for jup v6 - #[clap(long, env, default_value = "")] - jupiter_token: String, - - /// size of the swap to quote via jupiter to get slippage info, in dollar - /// should be larger than tcs_max_trigger_amount - #[clap(long, env, default_value = "1000.0")] - jupiter_swap_info_amount: f64, - - /// report liquidator's existence and pubkey - #[clap(long, env, value_enum, default_value = "true")] - telemetry: BoolArg, -} - pub fn encode_address(addr: &Pubkey) -> String { bs58::encode(&addr.to_bytes()).into_string() } @@ -356,8 +204,15 @@ async fn main() -> anyhow::Result<()> { min_health_ratio: cli.min_health_ratio, compute_limit_for_liq_ix: cli.compute_limit_for_liquidation, max_cu_per_transaction: 1_000_000, - // TODO: config - refresh_timeout: Duration::from_secs(30), + refresh_timeout: Duration::from_secs(cli.liquidation_refresh_timeout_secs as u64), + only_allowed_tokens: cli_args::cli_to_hashset::(cli.only_allow_tokens), + forbidden_tokens: cli_args::cli_to_hashset::(cli.forbidden_tokens), + only_allowed_perp_markets: cli_args::cli_to_hashset::( + cli.liquidation_only_allow_perp_markets, + ), + forbidden_perp_markets: cli_args::cli_to_hashset::( + cli.liquidation_forbidden_perp_markets, + ), }; let tcs_config = trigger_tcs::Config { @@ -366,14 +221,15 @@ async fn main() -> anyhow::Result<()> { compute_limit_for_trigger: cli.compute_limit_for_tcs, profit_fraction: cli.tcs_profit_fraction, collateral_token_index: 0, // USDC - // TODO: config - refresh_timeout: Duration::from_secs(30), jupiter_version: cli.jupiter_version.into(), jupiter_slippage_bps: cli.rebalance_slippage_bps, mode: cli.tcs_mode.into(), - min_buy_fraction: 0.7, + min_buy_fraction: cli.tcs_min_buy_fraction, + + only_allowed_tokens: liq_config.only_allowed_tokens.clone(), + forbidden_tokens: liq_config.forbidden_tokens.clone(), }; let mut rebalance_interval = tokio::time::interval(Duration::from_secs(30)); @@ -381,16 +237,10 @@ async fn main() -> anyhow::Result<()> { let rebalance_config = rebalance::Config { enabled: cli.rebalance == BoolArg::True, slippage_bps: cli.rebalance_slippage_bps, - // TODO: config - borrow_settle_excess: 1.05, - refresh_timeout: Duration::from_secs(30), + borrow_settle_excess: (1f64 + cli.rebalance_borrow_settle_excess).max(1f64), + refresh_timeout: Duration::from_secs(cli.rebalance_refresh_timeout_secs), jupiter_version: cli.jupiter_version.into(), - skip_tokens: cli - .rebalance_skip_tokens - .split(',') - .filter(|v| !v.is_empty()) - .map(|name| mango_client.context.token_by_name(name).token_index) - .collect(), + skip_tokens: cli.rebalance_skip_tokens.unwrap_or(Vec::new()), allow_withdraws: signer_is_owner, }; @@ -532,16 +382,13 @@ async fn main() -> anyhow::Result<()> { let mut took_tcs = false; if !liquidated && cli.take_tcs == BoolArg::True { - took_tcs = match liquidation + took_tcs = liquidation .maybe_take_token_conditional_swap(account_addresses.iter()) .await - { - Ok(v) => v, - Err(err) => { + .unwrap_or_else(|err| { error!("error during maybe_take_token_conditional_swap: {err}"); false - } - } + }) } if liquidated || took_tcs { @@ -552,14 +399,15 @@ async fn main() -> anyhow::Result<()> { }); let token_swap_info_job = tokio::spawn({ - // TODO: configurable interval - let mut interval = mango_v4_client::delay_interval(Duration::from_secs(60)); + let mut interval = mango_v4_client::delay_interval(Duration::from_secs( + cli.token_swap_refresh_interval_secs, + )); let mut startup_wait = mango_v4_client::delay_interval(Duration::from_secs(1)); let shared_state = shared_state.clone(); async move { loop { - startup_wait.tick().await; if !shared_state.read().unwrap().one_snapshot_done { + startup_wait.tick().await; continue; } @@ -594,6 +442,7 @@ async fn main() -> anyhow::Result<()> { )); } + use cli_args::{BoolArg, Cli, CliDotenv}; use futures::StreamExt; let mut jobs: futures::stream::FuturesUnordered<_> = vec![ data_job, diff --git a/bin/liquidator/src/trigger_tcs.rs b/bin/liquidator/src/trigger_tcs.rs index 7e3f00203..d42104846 100644 --- a/bin/liquidator/src/trigger_tcs.rs +++ b/bin/liquidator/src/trigger_tcs.rs @@ -1,8 +1,9 @@ +use std::collections::HashSet; use std::{ collections::HashMap, pin::Pin, sync::{Arc, RwLock}, - time::{Duration, Instant}, + time::Instant, }; use futures_core::Future; @@ -56,7 +57,6 @@ pub enum Mode { pub struct Config { pub min_health_ratio: f64, pub max_trigger_quote_amount: u64, - pub refresh_timeout: Duration, pub compute_limit_for_trigger: u32, pub collateral_token_index: TokenIndex, @@ -73,6 +73,9 @@ pub struct Config { pub jupiter_version: jupiter::Version, pub jupiter_slippage_bps: u64, pub mode: Mode, + + pub only_allowed_tokens: HashSet, + pub forbidden_tokens: HashSet, } pub enum JupiterQuoteCacheResult { @@ -401,11 +404,43 @@ impl Context { Ok(taker_price >= base_price * cost_over_oracle * (1.0 + self.config.profit_fraction)) } + // excluded by config + fn tcs_pair_is_allowed( + &self, + buy_token_index: TokenIndex, + sell_token_index: TokenIndex, + ) -> bool { + if self.config.forbidden_tokens.contains(&buy_token_index) { + return false; + } + + if self.config.forbidden_tokens.contains(&sell_token_index) { + return false; + } + + if self.config.only_allowed_tokens.is_empty() { + return true; + } + + if self.config.only_allowed_tokens.contains(&buy_token_index) { + return true; + } + + if self.config.only_allowed_tokens.contains(&sell_token_index) { + return true; + } + + return false; + } + // Either expired or triggerable with ok-looking price. fn tcs_is_interesting(&self, tcs: &TokenConditionalSwap) -> anyhow::Result { if tcs.is_expired(self.now_ts) { return Ok(true); } + if !self.tcs_pair_is_allowed(tcs.buy_token_index, tcs.buy_token_index) { + return Ok(false); + } let (_, buy_token_price, _) = self.token_bank_price_mint(tcs.buy_token_index)?; let (_, sell_token_price, _) = self.token_bank_price_mint(tcs.sell_token_index)?; From 46c6e86206473ae1ad4efc2b479e440482043a87 Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Mon, 19 Feb 2024 15:06:51 +0100 Subject: [PATCH 33/42] Add force_withdraw state and instruction (#884) Co-authored-by: microwavedcola1 --- mango_v4.json | 72 ++++++++- programs/mango-v4/src/accounts_ix/mod.rs | 2 + .../src/accounts_ix/token_force_withdraw.rs | 54 +++++++ .../mango-v4/src/instructions/ix_gate_set.rs | 1 + programs/mango-v4/src/instructions/mod.rs | 2 + .../mango-v4/src/instructions/token_edit.rs | 11 ++ .../src/instructions/token_force_withdraw.rs | 100 ++++++++++++ .../src/instructions/token_register.rs | 1 + .../instructions/token_register_trustless.rs | 1 + programs/mango-v4/src/lib.rs | 8 + programs/mango-v4/src/logs.rs | 10 ++ programs/mango-v4/src/state/bank.rs | 16 +- programs/mango-v4/src/state/group.rs | 1 + .../mango-v4/tests/cases/test_force_close.rs | 110 +++++++++++++ .../tests/program_test/mango_client.rs | 53 +++++++ ts/client/scripts/force-withdraw-token.ts | 73 +++++++++ ts/client/src/accounts/bank.ts | 3 + ts/client/src/client.ts | 107 +++++++++++-- ts/client/src/clientIxParamBuilder.ts | 5 + ts/client/src/mango_v4.ts | 144 +++++++++++++++++- 20 files changed, 756 insertions(+), 18 deletions(-) create mode 100644 programs/mango-v4/src/accounts_ix/token_force_withdraw.rs create mode 100644 programs/mango-v4/src/instructions/token_force_withdraw.rs create mode 100644 ts/client/scripts/force-withdraw-token.ts diff --git a/mango_v4.json b/mango_v4.json index 948ecd217..bf1308a1d 100644 --- a/mango_v4.json +++ b/mango_v4.json @@ -1067,6 +1067,12 @@ "type": { "option": "f32" } + }, + { + "name": "forceWithdrawOpt", + "type": { + "option": "bool" + } } ] }, @@ -3789,6 +3795,63 @@ } ] }, + { + "name": "tokenForceWithdraw", + "accounts": [ + { + "name": "group", + "isMut": false, + "isSigner": false + }, + { + "name": "account", + "isMut": true, + "isSigner": false, + "relations": [ + "group" + ] + }, + { + "name": "bank", + "isMut": true, + "isSigner": false, + "relations": [ + "group", + "vault", + "oracle" + ] + }, + { + "name": "vault", + "isMut": true, + "isSigner": false + }, + { + "name": "oracle", + "isMut": false, + "isSigner": false + }, + { + "name": "ownerAtaTokenAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "alternateOwnerTokenAccount", + "isMut": true, + "isSigner": false, + "docs": [ + "Only for the unusual case where the owner_ata account is not owned by account.owner" + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, { "name": "perpCreateMarket", "docs": [ @@ -7426,12 +7489,16 @@ ], "type": "u8" }, + { + "name": "forceWithdraw", + "type": "u8" + }, { "name": "padding", "type": { "array": [ "u8", - 5 + 4 ] } }, @@ -10938,6 +11005,9 @@ }, { "name": "Serum3PlaceOrderV2" + }, + { + "name": "TokenForceWithdraw" } ] } diff --git a/programs/mango-v4/src/accounts_ix/mod.rs b/programs/mango-v4/src/accounts_ix/mod.rs index 289430440..df8ea1f30 100644 --- a/programs/mango-v4/src/accounts_ix/mod.rs +++ b/programs/mango-v4/src/accounts_ix/mod.rs @@ -68,6 +68,7 @@ pub use token_deposit::*; pub use token_deregister::*; pub use token_edit::*; pub use token_force_close_borrows_with_token::*; +pub use token_force_withdraw::*; pub use token_liq_bankruptcy::*; pub use token_liq_with_token::*; pub use token_register::*; @@ -145,6 +146,7 @@ mod token_deposit; mod token_deregister; mod token_edit; mod token_force_close_borrows_with_token; +mod token_force_withdraw; mod token_liq_bankruptcy; mod token_liq_with_token; mod token_register; diff --git a/programs/mango-v4/src/accounts_ix/token_force_withdraw.rs b/programs/mango-v4/src/accounts_ix/token_force_withdraw.rs new file mode 100644 index 000000000..69ee070aa --- /dev/null +++ b/programs/mango-v4/src/accounts_ix/token_force_withdraw.rs @@ -0,0 +1,54 @@ +use anchor_lang::prelude::*; +use anchor_spl::associated_token::get_associated_token_address; +use anchor_spl::token::Token; +use anchor_spl::token::TokenAccount; + +use crate::error::*; +use crate::state::*; + +#[derive(Accounts)] +pub struct TokenForceWithdraw<'info> { + #[account( + constraint = group.load()?.is_ix_enabled(IxGate::TokenForceWithdraw) @ MangoError::IxIsDisabled, + )] + pub group: AccountLoader<'info, Group>, + + #[account( + mut, + has_one = group, + constraint = account.load()?.is_operational() @ MangoError::AccountIsFrozen, + )] + pub account: AccountLoader<'info, MangoAccountFixed>, + + #[account( + mut, + has_one = group, + has_one = vault, + has_one = oracle, + // the mints of bank/vault/token_accounts are implicitly the same because + // spl::token::transfer succeeds between token_account and vault + )] + pub bank: AccountLoader<'info, Bank>, + + #[account(mut)] + pub vault: Box>, + + /// CHECK: The oracle can be one of several different account types + pub oracle: UncheckedAccount<'info>, + + #[account( + mut, + address = get_associated_token_address(&account.load()?.owner, &vault.mint), + // NOTE: the owner may have been changed (before immutable owner was a thing) + )] + pub owner_ata_token_account: Box>, + + /// Only for the unusual case where the owner_ata account is not owned by account.owner + #[account( + mut, + constraint = alternate_owner_token_account.owner == account.load()?.owner, + )] + pub alternate_owner_token_account: Box>, + + pub token_program: Program<'info, Token>, +} diff --git a/programs/mango-v4/src/instructions/ix_gate_set.rs b/programs/mango-v4/src/instructions/ix_gate_set.rs index 22c8255de..413b9ceff 100644 --- a/programs/mango-v4/src/instructions/ix_gate_set.rs +++ b/programs/mango-v4/src/instructions/ix_gate_set.rs @@ -95,6 +95,7 @@ pub fn ix_gate_set(ctx: Context, ix_gate: u128) -> Result<()> { IxGate::TokenConditionalSwapCreateLinearAuction, ); log_if_changed(&group, ix_gate, IxGate::Serum3PlaceOrderV2); + log_if_changed(&group, ix_gate, IxGate::TokenForceWithdraw); group.ix_gate = ix_gate; diff --git a/programs/mango-v4/src/instructions/mod.rs b/programs/mango-v4/src/instructions/mod.rs index 6a6dc9220..faa5d8e88 100644 --- a/programs/mango-v4/src/instructions/mod.rs +++ b/programs/mango-v4/src/instructions/mod.rs @@ -59,6 +59,7 @@ pub use token_deposit::*; pub use token_deregister::*; pub use token_edit::*; pub use token_force_close_borrows_with_token::*; +pub use token_force_withdraw::*; pub use token_liq_bankruptcy::*; pub use token_liq_with_token::*; pub use token_register::*; @@ -127,6 +128,7 @@ mod token_deposit; mod token_deregister; mod token_edit; mod token_force_close_borrows_with_token; +mod token_force_withdraw; mod token_liq_bankruptcy; mod token_liq_with_token; mod token_register; diff --git a/programs/mango-v4/src/instructions/token_edit.rs b/programs/mango-v4/src/instructions/token_edit.rs index 8cde77def..87afd6622 100644 --- a/programs/mango-v4/src/instructions/token_edit.rs +++ b/programs/mango-v4/src/instructions/token_edit.rs @@ -55,6 +55,7 @@ pub fn token_edit( platform_liquidation_fee: Option, disable_asset_liquidation_opt: Option, collateral_fee_per_day: Option, + force_withdraw_opt: Option, ) -> Result<()> { let group = ctx.accounts.group.load()?; @@ -510,6 +511,16 @@ pub fn token_edit( bank.disable_asset_liquidation = u8::from(disable_asset_liquidation); require_group_admin = true; } + + if let Some(force_withdraw) = force_withdraw_opt { + msg!( + "Force withdraw old {:?}, new {:?}", + bank.force_withdraw, + force_withdraw + ); + bank.force_withdraw = u8::from(force_withdraw); + require_group_admin = true; + } } // account constraint #1 diff --git a/programs/mango-v4/src/instructions/token_force_withdraw.rs b/programs/mango-v4/src/instructions/token_force_withdraw.rs new file mode 100644 index 000000000..70eade859 --- /dev/null +++ b/programs/mango-v4/src/instructions/token_force_withdraw.rs @@ -0,0 +1,100 @@ +use crate::accounts_zerocopy::AccountInfoRef; +use crate::error::*; +use crate::state::*; +use anchor_lang::prelude::*; +use anchor_spl::token; +use fixed::types::I80F48; + +use crate::accounts_ix::*; +use crate::logs::{emit_stack, ForceWithdrawLog, TokenBalanceLog}; + +pub fn token_force_withdraw(ctx: Context) -> Result<()> { + let group = ctx.accounts.group.load()?; + let token_index = ctx.accounts.bank.load()?.token_index; + let now_ts: u64 = Clock::get()?.unix_timestamp.try_into().unwrap(); + + let mut bank = ctx.accounts.bank.load_mut()?; + require!(bank.is_force_withdraw(), MangoError::SomeError); + + let mut account = ctx.accounts.account.load_full_mut()?; + + let withdraw_target = if ctx.accounts.owner_ata_token_account.owner == account.fixed.owner { + ctx.accounts.owner_ata_token_account.to_account_info() + } else { + ctx.accounts.alternate_owner_token_account.to_account_info() + }; + + let (position, raw_token_index) = account.token_position_mut(token_index)?; + let native_position = position.native(&bank); + + // Check >= to allow calling this on 0 deposits to close the token position + require_gte!(native_position, I80F48::ZERO); + let amount = native_position.floor().to_num::(); + let amount_i80f48 = I80F48::from(amount); + + // Update the bank and position + let position_is_active = bank.withdraw_without_fee(position, amount_i80f48, now_ts)?; + + // 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( + CpiContext::new( + ctx.accounts.token_program.to_account_info(), + token::Transfer { + from: ctx.accounts.vault.to_account_info(), + to: withdraw_target.clone(), + authority: ctx.accounts.group.to_account_info(), + }, + ) + .with_signer(&[group_seeds]), + amount, + )?; + + emit_stack(TokenBalanceLog { + mango_group: ctx.accounts.group.key(), + mango_account: ctx.accounts.account.key(), + token_index, + indexed_position: position.indexed_position.to_bits(), + deposit_index: bank.deposit_index.to_bits(), + borrow_index: bank.borrow_index.to_bits(), + }); + + // Get the oracle price, even if stale or unconfident: We want to allow force withdraws + // even if the oracle is bad. + let oracle_ref = &AccountInfoRef::borrow(ctx.accounts.oracle.as_ref())?; + let unsafe_oracle_state = oracle_state_unchecked( + &OracleAccountInfos::from_reader(oracle_ref), + bank.mint_decimals, + )?; + + // Update the net deposits - adjust by price so different tokens are on the same basis (in USD terms) + let amount_usd = (amount_i80f48 * unsafe_oracle_state.price).to_num::(); + account.fixed.net_deposits -= amount_usd; + + if !position_is_active { + account.deactivate_token_position_and_log(raw_token_index, ctx.accounts.account.key()); + } + + emit_stack(ForceWithdrawLog { + mango_group: ctx.accounts.group.key(), + mango_account: ctx.accounts.account.key(), + token_index, + quantity: amount, + price: unsafe_oracle_state.price.to_bits(), + to_token_account: withdraw_target.key(), + }); + + bank.enforce_borrows_lte_deposits()?; + + Ok(()) +} diff --git a/programs/mango-v4/src/instructions/token_register.rs b/programs/mango-v4/src/instructions/token_register.rs index 252ca5670..7d545ad97 100644 --- a/programs/mango-v4/src/instructions/token_register.rs +++ b/programs/mango-v4/src/instructions/token_register.rs @@ -112,6 +112,7 @@ pub fn token_register( reduce_only, force_close: 0, disable_asset_liquidation: u8::from(disable_asset_liquidation), + force_withdraw: 0, padding: Default::default(), fees_withdrawn: 0, token_conditional_swap_taker_fee_rate, diff --git a/programs/mango-v4/src/instructions/token_register_trustless.rs b/programs/mango-v4/src/instructions/token_register_trustless.rs index 06f3526e1..6b6284228 100644 --- a/programs/mango-v4/src/instructions/token_register_trustless.rs +++ b/programs/mango-v4/src/instructions/token_register_trustless.rs @@ -91,6 +91,7 @@ pub fn token_register_trustless( reduce_only: 2, // deposit-only force_close: 0, disable_asset_liquidation: 1, + force_withdraw: 0, padding: Default::default(), fees_withdrawn: 0, token_conditional_swap_taker_fee_rate: 0.0, diff --git a/programs/mango-v4/src/lib.rs b/programs/mango-v4/src/lib.rs index 73246fab7..b1f4b9102 100644 --- a/programs/mango-v4/src/lib.rs +++ b/programs/mango-v4/src/lib.rs @@ -253,6 +253,7 @@ pub mod mango_v4 { platform_liquidation_fee_opt: Option, disable_asset_liquidation_opt: Option, collateral_fee_per_day_opt: Option, + force_withdraw_opt: Option, ) -> Result<()> { #[cfg(feature = "enable-gpl")] instructions::token_edit( @@ -297,6 +298,7 @@ pub mod mango_v4 { platform_liquidation_fee_opt, disable_asset_liquidation_opt, collateral_fee_per_day_opt, + force_withdraw_opt, )?; Ok(()) } @@ -817,6 +819,12 @@ pub mod mango_v4 { Ok(()) } + pub fn token_force_withdraw(ctx: Context) -> Result<()> { + #[cfg(feature = "enable-gpl")] + instructions::token_force_withdraw(ctx)?; + Ok(()) + } + /// /// Perps /// diff --git a/programs/mango-v4/src/logs.rs b/programs/mango-v4/src/logs.rs index bf9d699d6..6d8b42f80 100644 --- a/programs/mango-v4/src/logs.rs +++ b/programs/mango-v4/src/logs.rs @@ -788,3 +788,13 @@ pub struct TokenCollateralFeeLog { pub asset_usage_fraction: i128, pub fee: i128, } + +#[event] +pub struct ForceWithdrawLog { + pub mango_group: Pubkey, + pub mango_account: Pubkey, + pub token_index: u16, + pub quantity: u64, + pub price: i128, // I80F48 + pub to_token_account: Pubkey, +} diff --git a/programs/mango-v4/src/state/bank.rs b/programs/mango-v4/src/state/bank.rs index 8625d85a3..6f3657edd 100644 --- a/programs/mango-v4/src/state/bank.rs +++ b/programs/mango-v4/src/state/bank.rs @@ -162,8 +162,10 @@ pub struct Bank { /// That means bankrupt accounts may still have assets of this type deposited. pub disable_asset_liquidation: u8, + pub force_withdraw: u8, + #[derivative(Debug = "ignore")] - pub padding: [u8; 5], + pub padding: [u8; 4], // Do separate bookkeping for how many tokens were withdrawn // This ensures that collected_fees_native is strictly increasing for stats gathering purposes @@ -361,7 +363,8 @@ impl Bank { reduce_only: existing_bank.reduce_only, force_close: existing_bank.force_close, disable_asset_liquidation: existing_bank.disable_asset_liquidation, - padding: [0; 5], + force_withdraw: existing_bank.force_withdraw, + padding: [0; 4], token_conditional_swap_taker_fee_rate: existing_bank .token_conditional_swap_taker_fee_rate, token_conditional_swap_maker_fee_rate: existing_bank @@ -417,6 +420,11 @@ impl Bank { require_eq!(self.maint_asset_weight, I80F48::ZERO); } require_gte!(self.collateral_fee_per_day, 0.0); + if self.is_force_withdraw() { + require!(self.are_deposits_reduce_only(), MangoError::SomeError); + require!(!self.allows_asset_liquidation(), MangoError::SomeError); + require_eq!(self.maint_asset_weight, I80F48::ZERO); + } Ok(()) } @@ -438,6 +446,10 @@ impl Bank { self.force_close == 1 } + pub fn is_force_withdraw(&self) -> bool { + self.force_withdraw == 1 + } + pub fn allows_asset_liquidation(&self) -> bool { self.disable_asset_liquidation == 0 } diff --git a/programs/mango-v4/src/state/group.rs b/programs/mango-v4/src/state/group.rs index 60812b280..19fc8db03 100644 --- a/programs/mango-v4/src/state/group.rs +++ b/programs/mango-v4/src/state/group.rs @@ -245,6 +245,7 @@ pub enum IxGate { TokenConditionalSwapCreatePremiumAuction = 69, TokenConditionalSwapCreateLinearAuction = 70, Serum3PlaceOrderV2 = 71, + TokenForceWithdraw = 72, // NOTE: Adding new variants requires matching changes in ts and the ix_gate_set instruction. } diff --git a/programs/mango-v4/tests/cases/test_force_close.rs b/programs/mango-v4/tests/cases/test_force_close.rs index d9f9ddd11..3deab6252 100644 --- a/programs/mango-v4/tests/cases/test_force_close.rs +++ b/programs/mango-v4/tests/cases/test_force_close.rs @@ -438,3 +438,113 @@ async fn test_force_close_perp() -> Result<(), TransportError> { Ok(()) } + +#[tokio::test] +async fn test_force_withdraw_token() -> Result<(), TransportError> { + let test_builder = TestContextBuilder::new(); + let context = test_builder.start_default().await; + let solana = &context.solana.clone(); + + let admin = TestKeypair::new(); + let owner = context.users[0].key; + let payer = context.users[1].key; + let mints = &context.mints[0..1]; + + // + // SETUP: Create a group and an account to fill the vaults + // + + let mango_setup::GroupWithTokens { group, tokens, .. } = mango_setup::GroupWithTokensConfig { + admin, + payer, + mints: mints.to_vec(), + ..GroupWithTokensConfig::default() + } + .create(solana) + .await; + let token = &tokens[0]; + + let deposit_amount = 100; + + let account = create_funded_account( + &solana, + group, + owner, + 0, + &context.users[0], + mints, + deposit_amount, + 0, + ) + .await; + + // + // TEST: fails when force withdraw isn't enabled + // + assert!(send_tx( + solana, + TokenForceWithdrawInstruction { + account, + bank: token.bank, + target: context.users[0].token_accounts[0], + }, + ) + .await + .is_err()); + + // set force withdraw to enabled + send_tx( + solana, + TokenEdit { + admin, + group, + mint: token.mint.pubkey, + fallback_oracle: Pubkey::default(), + options: mango_v4::instruction::TokenEdit { + maint_asset_weight_opt: Some(0.0), + reduce_only_opt: Some(1), + disable_asset_liquidation_opt: Some(true), + force_withdraw_opt: Some(true), + ..token_edit_instruction_default() + }, + }, + ) + .await + .unwrap(); + + // + // TEST: can't withdraw to foreign address + // + assert!(send_tx( + solana, + TokenForceWithdrawInstruction { + account, + bank: token.bank, + target: context.users[1].token_accounts[0], // bad address/owner + }, + ) + .await + .is_err()); + + // + // TEST: passes and withdraws tokens + // + let token_account = context.users[0].token_accounts[0]; + let before_balance = solana.token_account_balance(token_account).await; + send_tx( + solana, + TokenForceWithdrawInstruction { + account, + bank: token.bank, + target: token_account, + }, + ) + .await + .unwrap(); + + let after_balance = solana.token_account_balance(token_account).await; + assert_eq!(after_balance, before_balance + deposit_amount); + assert!(account_position_closed(solana, account, token.bank).await); + + Ok(()) +} diff --git a/programs/mango-v4/tests/program_test/mango_client.rs b/programs/mango-v4/tests/program_test/mango_client.rs index 178508b4b..a0e28b5a2 100644 --- a/programs/mango-v4/tests/program_test/mango_client.rs +++ b/programs/mango-v4/tests/program_test/mango_client.rs @@ -1328,6 +1328,7 @@ pub fn token_edit_instruction_default() -> mango_v4::instruction::TokenEdit { platform_liquidation_fee_opt: None, disable_asset_liquidation_opt: None, collateral_fee_per_day_opt: None, + force_withdraw_opt: None, } } @@ -3112,6 +3113,58 @@ impl ClientInstruction for TokenForceCloseBorrowsWithTokenInstruction { } } +pub struct TokenForceWithdrawInstruction { + pub account: Pubkey, + pub bank: Pubkey, + pub target: Pubkey, +} +#[async_trait::async_trait(?Send)] +impl ClientInstruction for TokenForceWithdrawInstruction { + type Accounts = mango_v4::accounts::TokenForceWithdraw; + type Instruction = mango_v4::instruction::TokenForceWithdraw; + 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 bank = account_loader.load::(&self.bank).await.unwrap(); + let health_check_metas = derive_health_check_remaining_account_metas( + &account_loader, + &account, + None, + false, + None, + ) + .await; + + let accounts = Self::Accounts { + group: account.fixed.group, + account: self.account, + bank: self.bank, + vault: bank.vault, + oracle: bank.oracle, + owner_ata_token_account: self.target, + alternate_owner_token_account: self.target, + token_program: Token::id(), + }; + + let mut instruction = make_instruction(program_id, &accounts, &instruction); + instruction.accounts.extend(health_check_metas.into_iter()); + + (accounts, instruction) + } + + fn signers(&self) -> Vec { + vec![] + } +} + pub struct TokenLiqWithTokenInstruction { pub liqee: Pubkey, pub liqor: Pubkey, diff --git a/ts/client/scripts/force-withdraw-token.ts b/ts/client/scripts/force-withdraw-token.ts new file mode 100644 index 000000000..3a9b9b32c --- /dev/null +++ b/ts/client/scripts/force-withdraw-token.ts @@ -0,0 +1,73 @@ +import { AnchorProvider, Wallet } from '@coral-xyz/anchor'; +import { Cluster, Connection, Keypair, PublicKey } from '@solana/web3.js'; +import fs from 'fs'; +import { TokenIndex } from '../src/accounts/bank'; +import { MangoClient } from '../src/client'; +import { MANGO_V4_ID } from '../src/constants'; + +const CLUSTER: Cluster = + (process.env.CLUSTER_OVERRIDE as Cluster) || 'mainnet-beta'; +const CLUSTER_URL = + process.env.CLUSTER_URL_OVERRIDE || process.env.MB_CLUSTER_URL; +const USER_KEYPAIR = + process.env.USER_KEYPAIR_OVERRIDE || process.env.MB_PAYER_KEYPAIR; +const GROUP_PK = + process.env.GROUP_PK || '78b8f4cGCwmZ9ysPFMWLaLTkkaYnUjwMJYStWe5RTSSX'; +const TOKEN_INDEX = Number(process.env.TOKEN_INDEX) as TokenIndex; + +async function forceWithdrawTokens(): Promise { + const options = AnchorProvider.defaultOptions(); + const connection = new Connection(CLUSTER_URL!, options); + const user = Keypair.fromSecretKey( + Buffer.from( + JSON.parse( + process.env.KEYPAIR || fs.readFileSync(USER_KEYPAIR!, 'utf-8'), + ), + ), + ); + const userWallet = new Wallet(user); + const userProvider = new AnchorProvider(connection, userWallet, options); + const client = await MangoClient.connect( + userProvider, + CLUSTER, + MANGO_V4_ID[CLUSTER], + { + idsSource: 'get-program-accounts', + }, + ); + + const group = await client.getGroup(new PublicKey(GROUP_PK)); + const forceWithdrawBank = group.getFirstBankByTokenIndex(TOKEN_INDEX); + if (forceWithdrawBank.reduceOnly != 2) { + throw new Error( + `Unexpected reduce only state ${forceWithdrawBank.reduceOnly}`, + ); + } + if (!forceWithdrawBank.forceWithdraw) { + throw new Error( + `Unexpected force withdraw state ${forceWithdrawBank.forceWithdraw}`, + ); + } + + // Get all mango accounts with deposits for given token + const mangoAccountsWithDeposits = ( + await client.getAllMangoAccounts(group) + ).filter((a) => a.getTokenBalanceUi(forceWithdrawBank) > 0); + + for (const mangoAccount of mangoAccountsWithDeposits) { + const sig = await client.tokenForceWithdraw( + group, + mangoAccount, + TOKEN_INDEX, + ); + console.log( + ` tokenForceWithdraw for ${mangoAccount.publicKey}, owner ${ + mangoAccount.owner + }, sig https://explorer.solana.com/tx/${sig}?cluster=${ + CLUSTER == 'devnet' ? 'devnet' : '' + }`, + ); + } +} + +forceWithdrawTokens(); diff --git a/ts/client/src/accounts/bank.ts b/ts/client/src/accounts/bank.ts index 1da560426..986c06cb7 100644 --- a/ts/client/src/accounts/bank.ts +++ b/ts/client/src/accounts/bank.ts @@ -132,6 +132,7 @@ export class Bank implements BankForHealth { reduceOnly: number; forceClose: number; disableAssetLiquidation: number; + forceWithdraw: number; feesWithdrawn: BN; tokenConditionalSwapTakerFeeRate: number; tokenConditionalSwapMakerFeeRate: number; @@ -218,6 +219,7 @@ export class Bank implements BankForHealth { obj.disableAssetLiquidation == 0, obj.collectedCollateralFees, obj.collateralFeePerDay, + obj.forceWithdraw == 1, ); } @@ -286,6 +288,7 @@ export class Bank implements BankForHealth { public allowAssetLiquidation: boolean, collectedCollateralFees: I80F48Dto, public collateralFeePerDay: number, + public forceWithdraw: boolean, ) { this.name = utf8.decode(new Uint8Array(name)).split('\x00')[0]; this.oracleConfig = { diff --git a/ts/client/src/client.ts b/ts/client/src/client.ts index 4b0e303d5..77896e27d 100644 --- a/ts/client/src/client.ts +++ b/ts/client/src/client.ts @@ -1,16 +1,10 @@ -import { - AnchorProvider, - BN, - Instruction, - Program, - Provider, - Wallet, -} from '@coral-xyz/anchor'; -import * as borsh from '@coral-xyz/borsh'; +import { AnchorProvider, BN, Program, Wallet } from '@coral-xyz/anchor'; import { OpenOrders, decodeEventQueue } from '@project-serum/serum'; import { + createAccount, createCloseAccountInstruction, createInitializeAccount3Instruction, + unpackAccount, } from '@solana/spl-token'; import { AccountInfo, @@ -26,13 +20,13 @@ import { RecentPrioritizationFees, SYSVAR_INSTRUCTIONS_PUBKEY, SYSVAR_RENT_PUBKEY, + Signer, SystemProgram, TransactionInstruction, - TransactionSignature, } from '@solana/web3.js'; import bs58 from 'bs58'; -import chunk from 'lodash/chunk'; import copy from 'fast-copy'; +import chunk from 'lodash/chunk'; import groupBy from 'lodash/groupBy'; import mapValues from 'lodash/mapValues'; import maxBy from 'lodash/maxBy'; @@ -45,7 +39,6 @@ import { Serum3Orders, TokenConditionalSwap, TokenConditionalSwapDisplayPriceStyle, - TokenConditionalSwapDto, TokenConditionalSwapIntention, TokenPosition, } from './accounts/mangoAccount'; @@ -70,7 +63,6 @@ import { } from './accounts/serum3'; import { IxGateParams, - PerpEditParams, TokenEditParams, TokenRegisterParams, buildIxGate, @@ -559,6 +551,7 @@ export class MangoClient { params.platformLiquidationFee, params.disableAssetLiquidation, params.collateralFeePerDay, + params.forceWithdraw, ) .accounts({ group: group.publicKey, @@ -625,6 +618,94 @@ export class MangoClient { return await this.sendAndConfirmTransactionForGroup(group, [ix]); } + public async tokenForceWithdraw( + group: Group, + mangoAccount: MangoAccount, + tokenIndex: TokenIndex, + ): Promise { + const bank = group.getFirstBankByTokenIndex(tokenIndex); + if (!bank.forceWithdraw) { + throw new Error('Bank is not in force-withdraw mode'); + } + + const ownerAtaTokenAccount = await getAssociatedTokenAddress( + bank.mint, + mangoAccount.owner, + true, + ); + let alternateOwnerTokenAccount = PublicKey.default; + const preInstructions: TransactionInstruction[] = []; + const postInstructions: TransactionInstruction[] = []; + + const ai = await this.connection.getAccountInfo(ownerAtaTokenAccount); + + // ensure withdraws don't fail with missing ATAs + if (ai == null) { + preInstructions.push( + await createAssociatedTokenAccountIdempotentInstruction( + (this.program.provider as AnchorProvider).wallet.publicKey, + mangoAccount.owner, + bank.mint, + ), + ); + + // wsol case + if (bank.mint.equals(NATIVE_MINT)) { + postInstructions.push( + createCloseAccountInstruction( + ownerAtaTokenAccount, + mangoAccount.owner, + mangoAccount.owner, + ), + ); + } + } else { + const account = await unpackAccount(ownerAtaTokenAccount, ai); + // if owner is not same as mango account's owner on the ATA (for whatever reason) + // then create another token account + if (!account.owner.equals(mangoAccount.owner)) { + const kp = Keypair.generate(); + alternateOwnerTokenAccount = kp.publicKey; + await createAccount( + this.connection, + (this.program.provider as AnchorProvider).wallet as any as Signer, + bank.mint, + mangoAccount.owner, + kp, + ); + + // wsol case + if (bank.mint.equals(NATIVE_MINT)) { + postInstructions.push( + createCloseAccountInstruction( + alternateOwnerTokenAccount, + mangoAccount.owner, + mangoAccount.owner, + ), + ); + } + } + } + + const ix = await this.program.methods + .tokenForceWithdraw() + .accounts({ + group: group.publicKey, + account: mangoAccount.publicKey, + bank: bank.publicKey, + vault: bank.vault, + oracle: bank.oracle, + ownerAtaTokenAccount, + alternateOwnerTokenAccount, + }) + .instruction(); + return await this.sendAndConfirmTransactionForGroup(group, [ + ...preInstructions, + ix, + ...postInstructions, + ]); + } + public async tokenDeregister( group: Group, mintPk: PublicKey, diff --git a/ts/client/src/clientIxParamBuilder.ts b/ts/client/src/clientIxParamBuilder.ts index dc140db2e..1fe24a829 100644 --- a/ts/client/src/clientIxParamBuilder.ts +++ b/ts/client/src/clientIxParamBuilder.ts @@ -117,6 +117,7 @@ export interface TokenEditParams { platformLiquidationFee: number | null; disableAssetLiquidation: boolean | null; collateralFeePerDay: number | null; + forceWithdraw: boolean | null; } export const NullTokenEditParams: TokenEditParams = { @@ -160,6 +161,7 @@ export const NullTokenEditParams: TokenEditParams = { platformLiquidationFee: null, disableAssetLiquidation: null, collateralFeePerDay: null, + forceWithdraw: null, }; export interface PerpEditParams { @@ -307,6 +309,7 @@ export interface IxGateParams { TokenConditionalSwapCreatePremiumAuction: boolean; TokenConditionalSwapCreateLinearAuction: boolean; Serum3PlaceOrderV2: boolean; + TokenForceWithdraw: boolean; } // Default with all ixs enabled, use with buildIxGate @@ -386,6 +389,7 @@ export const TrueIxGateParams: IxGateParams = { TokenConditionalSwapCreatePremiumAuction: true, TokenConditionalSwapCreateLinearAuction: true, Serum3PlaceOrderV2: true, + TokenForceWithdraw: true, }; // build ix gate e.g. buildIxGate(Builder(TrueIxGateParams).TokenDeposit(false).build()).toNumber(), @@ -475,6 +479,7 @@ export function buildIxGate(p: IxGateParams): BN { toggleIx(ixGate, p, 'TokenConditionalSwapCreatePremiumAuction', 69); toggleIx(ixGate, p, 'TokenConditionalSwapCreateLinearAuction', 70); toggleIx(ixGate, p, 'Serum3PlaceOrderV2', 71); + toggleIx(ixGate, p, 'TokenForceWithdraw', 72); return ixGate; } diff --git a/ts/client/src/mango_v4.ts b/ts/client/src/mango_v4.ts index bb18e1f3a..14c08749e 100644 --- a/ts/client/src/mango_v4.ts +++ b/ts/client/src/mango_v4.ts @@ -1067,6 +1067,12 @@ export type MangoV4 = { "type": { "option": "f32" } + }, + { + "name": "forceWithdrawOpt", + "type": { + "option": "bool" + } } ] }, @@ -3789,6 +3795,63 @@ export type MangoV4 = { } ] }, + { + "name": "tokenForceWithdraw", + "accounts": [ + { + "name": "group", + "isMut": false, + "isSigner": false + }, + { + "name": "account", + "isMut": true, + "isSigner": false, + "relations": [ + "group" + ] + }, + { + "name": "bank", + "isMut": true, + "isSigner": false, + "relations": [ + "group", + "vault", + "oracle" + ] + }, + { + "name": "vault", + "isMut": true, + "isSigner": false + }, + { + "name": "oracle", + "isMut": false, + "isSigner": false + }, + { + "name": "ownerAtaTokenAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "alternateOwnerTokenAccount", + "isMut": true, + "isSigner": false, + "docs": [ + "Only for the unusual case where the owner_ata account is not owned by account.owner" + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, { "name": "perpCreateMarket", "docs": [ @@ -7426,12 +7489,16 @@ export type MangoV4 = { ], "type": "u8" }, + { + "name": "forceWithdraw", + "type": "u8" + }, { "name": "padding", "type": { "array": [ "u8", - 5 + 4 ] } }, @@ -10938,6 +11005,9 @@ export type MangoV4 = { }, { "name": "Serum3PlaceOrderV2" + }, + { + "name": "TokenForceWithdraw" } ] } @@ -15245,6 +15315,12 @@ export const IDL: MangoV4 = { "type": { "option": "f32" } + }, + { + "name": "forceWithdrawOpt", + "type": { + "option": "bool" + } } ] }, @@ -17967,6 +18043,63 @@ export const IDL: MangoV4 = { } ] }, + { + "name": "tokenForceWithdraw", + "accounts": [ + { + "name": "group", + "isMut": false, + "isSigner": false + }, + { + "name": "account", + "isMut": true, + "isSigner": false, + "relations": [ + "group" + ] + }, + { + "name": "bank", + "isMut": true, + "isSigner": false, + "relations": [ + "group", + "vault", + "oracle" + ] + }, + { + "name": "vault", + "isMut": true, + "isSigner": false + }, + { + "name": "oracle", + "isMut": false, + "isSigner": false + }, + { + "name": "ownerAtaTokenAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "alternateOwnerTokenAccount", + "isMut": true, + "isSigner": false, + "docs": [ + "Only for the unusual case where the owner_ata account is not owned by account.owner" + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, { "name": "perpCreateMarket", "docs": [ @@ -21604,12 +21737,16 @@ export const IDL: MangoV4 = { ], "type": "u8" }, + { + "name": "forceWithdraw", + "type": "u8" + }, { "name": "padding", "type": { "array": [ "u8", - 5 + 4 ] } }, @@ -25116,6 +25253,9 @@ export const IDL: MangoV4 = { }, { "name": "Serum3PlaceOrderV2" + }, + { + "name": "TokenForceWithdraw" } ] } From efe4a1ae3d90a2636f9952f338f6c99ba81f2036 Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Wed, 21 Feb 2024 09:00:57 +0100 Subject: [PATCH 34/42] Audit v0.22 fixes (#887) - apply recurring settle allowance constraint also in available_settle_limit - bank constraints on util0, util1 - cleanup - perp liq: take over oneshot and recurring limits separately --- .../perp_liq_base_or_positive_pnl.rs | 67 +++++++++++++------ programs/mango-v4/src/logs.rs | 16 +++++ programs/mango-v4/src/state/bank.rs | 3 +- .../src/state/mango_account_components.rs | 41 +++++++++--- 4 files changed, 98 insertions(+), 29 deletions(-) diff --git a/programs/mango-v4/src/instructions/perp_liq_base_or_positive_pnl.rs b/programs/mango-v4/src/instructions/perp_liq_base_or_positive_pnl.rs index 93c867078..54d191656 100644 --- a/programs/mango-v4/src/instructions/perp_liq_base_or_positive_pnl.rs +++ b/programs/mango-v4/src/instructions/perp_liq_base_or_positive_pnl.rs @@ -8,7 +8,7 @@ use crate::health::*; use crate::state::*; use crate::accounts_ix::*; -use crate::logs::{emit_perp_balances, emit_stack, PerpLiqBaseOrPositivePnlLogV2, TokenBalanceLog}; +use crate::logs::{emit_perp_balances, emit_stack, PerpLiqBaseOrPositivePnlLogV3, TokenBalanceLog}; /// This instruction deals with increasing health by: /// - reducing the liqee's base position @@ -100,7 +100,8 @@ pub fn perp_liq_base_or_positive_pnl( quote_transfer_liqor, platform_fee, pnl_transfer, - pnl_settle_limit_transfer, + pnl_settle_limit_transfer_recurring, + pnl_settle_limit_transfer_oneshot, ) = liquidation_action( &mut perp_market, &mut settle_bank, @@ -158,7 +159,7 @@ pub fn perp_liq_base_or_positive_pnl( } if base_transfer != 0 || pnl_transfer != 0 { - emit_stack(PerpLiqBaseOrPositivePnlLogV2 { + emit_stack(PerpLiqBaseOrPositivePnlLogV3 { mango_group: ctx.accounts.group.key(), perp_market_index: perp_market.perp_market_index, liqor: ctx.accounts.liqor.key(), @@ -168,7 +169,8 @@ pub fn perp_liq_base_or_positive_pnl( quote_transfer_liqor: quote_transfer_liqor.to_bits(), quote_platform_fee: platform_fee.to_bits(), pnl_transfer: pnl_transfer.to_bits(), - pnl_settle_limit_transfer: pnl_settle_limit_transfer.to_bits(), + pnl_settle_limit_transfer_recurring, + pnl_settle_limit_transfer_oneshot, price: oracle_price.to_bits(), }); } @@ -215,7 +217,7 @@ pub(crate) fn liquidation_action( now_ts: u64, max_base_transfer: i64, max_pnl_transfer: u64, -) -> Result<(i64, I80F48, I80F48, I80F48, I80F48, I80F48)> { +) -> Result<(i64, I80F48, I80F48, I80F48, I80F48, i64, i64)> { let liq_end_type = HealthType::LiquidationEnd; let perp_market_index = perp_market.perp_market_index; @@ -574,7 +576,9 @@ pub(crate) fn liquidation_action( // Let the liqor take over positive pnl until the account health is positive, // but only while the health_unsettled_pnl is positive (otherwise it would decrease liqee health!) // - let limit_transfer = if pnl_transfer > 0 { + let limit_transfer_recurring: i64; + let limit_transfer_oneshot: i64; + if pnl_transfer > 0 { // Allow taking over *more* than the liqee_positive_settle_limit. In exchange, the liqor // also can't settle fully immediately and just takes over a fractional chunk of the limit. // @@ -582,22 +586,45 @@ pub(crate) fn liquidation_action( // base position to zero and would need to deal with that in bankruptcy. Also, the settle // limit changes with the base position price, so it'd be hard to say when this liquidation // step is done. - let limit_transfer = { + { // take care, liqee_limit may be i64::MAX let liqee_limit: i128 = liqee_positive_settle_limit.into(); + let liqee_oneshot_positive = liqee_perp_position + .oneshot_settle_pnl_allowance + .ceil() + .to_num::() + .max(0); + let liqee_recurring = (liqee_limit - liqee_oneshot_positive).max(0); let liqee_pnl = liqee_perp_position .unsettled_pnl(perp_market, oracle_price)? - .max(I80F48::ONE); + .ceil() + .to_num::() + .max(1); let settle = pnl_transfer.floor().to_num::(); - let total = liqee_pnl.ceil().to_num::(); - let liqor_limit: i64 = (liqee_limit * settle / total).try_into().unwrap(); - I80F48::from(liqor_limit).min(pnl_transfer).max(I80F48::ONE) + let total = liqee_pnl.max(settle); + let transfer_recurring: i64 = (liqee_recurring * settle / total).try_into().unwrap(); + let transfer_oneshot: i64 = (liqee_oneshot_positive * settle / total) + .try_into() + .unwrap(); + + // never transfer more than pnl_transfer rounded up + // and transfer at least 1, to compensate for rounding down `settle` and int div + let max_transfer = pnl_transfer.ceil().to_num::(); + limit_transfer_recurring = transfer_recurring.min(max_transfer).max(1); + // make is so the sum of recurring and oneshot doesn't exceed max_transfer + limit_transfer_oneshot = transfer_oneshot + .min(max_transfer - limit_transfer_recurring) + .max(0); }; // The liqor pays less than the full amount to receive the positive pnl let token_transfer = pnl_transfer * spot_gain_per_settled; - liqor_perp_position.record_liquidation_pnl_takeover(pnl_transfer, limit_transfer); + liqor_perp_position.record_liquidation_pnl_takeover( + pnl_transfer, + limit_transfer_recurring, + limit_transfer_oneshot, + ); liqee_perp_position.record_settle(pnl_transfer, &perp_market); // Update the accounts' perp_spot_transfer statistics. @@ -615,15 +642,15 @@ pub(crate) fn liquidation_action( liqee_health_cache.adjust_token_balance(&settle_bank, token_transfer)?; msg!( - "pnl {} was transferred to liqor for quote {} with settle limit {}", + "pnl {} was transferred to liqor for quote {} with settle limit {} recurring/{} oneshot", pnl_transfer, token_transfer, - limit_transfer + limit_transfer_recurring, + limit_transfer_oneshot, ); - - limit_transfer } else { - I80F48::ZERO + limit_transfer_oneshot = 0; + limit_transfer_recurring = 0; }; let liqee_perp_position = liqee.perp_position_mut(perp_market_index)?; @@ -635,7 +662,8 @@ pub(crate) fn liquidation_action( quote_transfer_liqor, platform_fee, pnl_transfer, - limit_transfer, + limit_transfer_recurring, + limit_transfer_oneshot, )) } @@ -1072,7 +1100,8 @@ mod tests { // The settle limit taken over matches the quote pos when removing the // quote gains from giving away base lots assert_eq_f!( - I80F48::from_num(liqor_perp.recurring_settle_pnl_allowance), + I80F48::from_num(liqor_perp.recurring_settle_pnl_allowance) + + liqor_perp.oneshot_settle_pnl_allowance, liqor_perp.quote_position_native.to_num::() + liqor_perp.base_position_lots as f64, 1.1 diff --git a/programs/mango-v4/src/logs.rs b/programs/mango-v4/src/logs.rs index 6d8b42f80..7f7a44a3c 100644 --- a/programs/mango-v4/src/logs.rs +++ b/programs/mango-v4/src/logs.rs @@ -527,6 +527,22 @@ pub struct PerpLiqBaseOrPositivePnlLogV2 { pub price: i128, } +#[event] +pub struct PerpLiqBaseOrPositivePnlLogV3 { + pub mango_group: Pubkey, + pub perp_market_index: u16, + pub liqor: Pubkey, + pub liqee: Pubkey, + pub base_transfer_liqee: i64, + pub quote_transfer_liqee: i128, + pub quote_transfer_liqor: i128, + pub quote_platform_fee: i128, + pub pnl_transfer: i128, + pub pnl_settle_limit_transfer_recurring: i64, + pub pnl_settle_limit_transfer_oneshot: i64, + pub price: i128, +} + #[event] pub struct PerpLiqBankruptcyLog { pub mango_group: Pubkey, diff --git a/programs/mango-v4/src/state/bank.rs b/programs/mango-v4/src/state/bank.rs index 6f3657edd..6f378a69c 100644 --- a/programs/mango-v4/src/state/bank.rs +++ b/programs/mango-v4/src/state/bank.rs @@ -389,8 +389,9 @@ impl Bank { pub fn verify(&self) -> Result<()> { require_gte!(self.oracle_config.conf_filter, 0.0); require_gte!(self.util0, I80F48::ZERO); + require_gte!(self.util1, self.util0); + require_gte!(I80F48::ONE, self.util1); require_gte!(self.rate0, I80F48::ZERO); - require_gte!(self.util1, I80F48::ZERO); require_gte!(self.rate1, I80F48::ZERO); require_gte!(self.max_rate, I80F48::ZERO); require_gte!(self.loan_fee_rate, 0.0); diff --git a/programs/mango-v4/src/state/mango_account_components.rs b/programs/mango-v4/src/state/mango_account_components.rs index 0b2444720..987f0e8a7 100644 --- a/programs/mango-v4/src/state/mango_account_components.rs +++ b/programs/mango-v4/src/state/mango_account_components.rs @@ -570,7 +570,7 @@ impl PerpPosition { .unwrap(); let upnl_abs = upnl.abs().ceil().to_num::(); self.recurring_settle_pnl_allowance = - self.recurring_settle_pnl_allowance.max(0).min(upnl_abs); + self.recurring_settle_pnl_allowance.min(upnl_abs).max(0); self.recurring_settle_pnl_allowance - before } @@ -667,12 +667,21 @@ impl PerpPosition { } let base_native = self.base_position_native(market); - let position_value = (market.stable_price() * base_native).abs().to_num::(); - let unrealized = (market.settle_pnl_limit_factor as f64 * position_value).clamp_to_i64(); + let position_value = market.stable_price() * base_native; + + let position_value_abs = position_value.abs().to_num::(); + let unrealized = + (market.settle_pnl_limit_factor as f64 * position_value_abs).clamp_to_i64(); + + let upnl_abs = (self.quote_position_native() + position_value) + .abs() + .ceil() + .to_num::(); let mut max_pnl = unrealized - // abs() because of potential migration - + self.recurring_settle_pnl_allowance.abs(); + // .abs() because of potential migration + // .min() to do the same as apply_recurring_settle_pnl_allowance_constraint + + self.recurring_settle_pnl_allowance.abs().min(upnl_abs); let mut min_pnl = -max_pnl; let oneshot = self.oneshot_settle_pnl_allowance; @@ -777,10 +786,16 @@ impl PerpPosition { self.oneshot_settle_pnl_allowance += change; } - /// Adds to the quote position and adds a recurring ("realized trade") settle limit - pub fn record_liquidation_pnl_takeover(&mut self, change: I80F48, recurring_limit: I80F48) { + /// Takes over a quote position along with recurring and oneshot settle limit allowance + pub fn record_liquidation_pnl_takeover( + &mut self, + change: I80F48, + recurring_limit: i64, + oneshot_limit: i64, + ) { self.change_quote_position(change); - self.recurring_settle_pnl_allowance += recurring_limit.abs().ceil().to_num::(); + self.recurring_settle_pnl_allowance += recurring_limit; + self.oneshot_settle_pnl_allowance += I80F48::from(oneshot_limit); } } @@ -1401,6 +1416,14 @@ mod tests { pos.settle_pnl_limit_settled_in_current_window_native = 0; market.stable_price_model.stable_price = 1.0; - assert_eq!(pos.available_settle_limit(&market), (-31, 36)); + // because the upnl is 0 the recurring allowance doesn't count + assert_eq!( + pos.unsettled_pnl(&market, I80F48::from_num(1.0)).unwrap(), + I80F48::ZERO + ); + assert_eq!(pos.available_settle_limit(&market), (-20, 25)); + + pos.quote_position_native += I80F48::from(7); + assert_eq!(pos.available_settle_limit(&market), (-27, 32)); } } From ab8393b52de917dd9082b3d523b90b0b7435d113 Mon Sep 17 00:00:00 2001 From: Serge Farny Date: Wed, 21 Feb 2024 16:35:28 +0100 Subject: [PATCH 35/42] liquidator: avoid logging same oracle error (same token) in loop (#889) * liquidator: avoid logging same oracle error (same token) in loop --- Cargo.lock | 1 + bin/liquidator/Cargo.toml | 1 + bin/liquidator/src/cli_args.rs | 4 + bin/liquidator/src/main.rs | 29 ++++ .../src/unwrappable_oracle_error.rs | 126 ++++++++++++++++++ 5 files changed, 161 insertions(+) create mode 100644 bin/liquidator/src/unwrappable_oracle_error.rs diff --git a/Cargo.lock b/Cargo.lock index 26b1068d6..a5c572ac1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3531,6 +3531,7 @@ dependencies = [ "once_cell", "pyth-sdk-solana", "rand 0.7.3", + "regex", "serde", "serde_derive", "serde_json", diff --git a/bin/liquidator/Cargo.toml b/bin/liquidator/Cargo.toml index 75fe0792d..846aaaf7f 100644 --- a/bin/liquidator/Cargo.toml +++ b/bin/liquidator/Cargo.toml @@ -48,3 +48,4 @@ tokio = { version = "1", features = ["full"] } tokio-stream = { version = "0.1.9"} tokio-tungstenite = "0.16.1" tracing = "0.1" +regex = "1.9.5" diff --git a/bin/liquidator/src/cli_args.rs b/bin/liquidator/src/cli_args.rs index fe0eb1b76..e6d49bac5 100644 --- a/bin/liquidator/src/cli_args.rs +++ b/bin/liquidator/src/cli_args.rs @@ -204,4 +204,8 @@ pub struct Cli { /// when empty, allows all pairs #[clap(long, env, value_parser, value_delimiter = ' ')] pub(crate) liquidation_only_allow_perp_markets: Option>, + + /// how long should it wait before logging an oracle error again (for the same token) + #[clap(long, env, default_value = "30")] + pub(crate) skip_oracle_error_in_logs_duration_secs: u64, } diff --git a/bin/liquidator/src/main.rs b/bin/liquidator/src/main.rs index dfa9b190b..ce0fcbf38 100644 --- a/bin/liquidator/src/main.rs +++ b/bin/liquidator/src/main.rs @@ -27,8 +27,10 @@ pub mod rebalance; pub mod telemetry; pub mod token_swap_info; pub mod trigger_tcs; +mod unwrappable_oracle_error; pub mod util; +use crate::unwrappable_oracle_error::UnwrappableOracleError; use crate::util::{is_mango_account, is_mint_info, is_perp_market}; // jemalloc seems to be better at keeping the memory footprint reasonable over @@ -262,6 +264,12 @@ async fn main() -> anyhow::Result<()> { .skip_threshold_for_type(LiqErrorType::Liq, 5) .skip_duration(Duration::from_secs(120)) .build()?, + oracle_errors: ErrorTracking::builder() + .skip_threshold(1) + .skip_duration(Duration::from_secs( + cli.skip_oracle_error_in_logs_duration_secs, + )) + .build()?, }); info!("main loop"); @@ -375,6 +383,7 @@ async fn main() -> anyhow::Result<()> { }; liquidation.errors.update(); + liquidation.oracle_errors.update(); let liquidated = liquidation .maybe_liquidate_one(account_addresses.iter()) @@ -499,6 +508,7 @@ struct LiquidationState { trigger_tcs_config: trigger_tcs::Config, errors: ErrorTracking, + oracle_errors: ErrorTracking, } impl LiquidationState { @@ -552,6 +562,25 @@ impl LiquidationState { .await; if let Err(err) = result.as_ref() { + if let Some((ti, ti_name)) = err.try_unwrap_oracle_error() { + if self + .oracle_errors + .had_too_many_errors(LiqErrorType::Liq, &ti, Instant::now()) + .is_none() + { + warn!( + "{:?} recording oracle error for token {} {}", + chrono::offset::Utc::now(), + ti_name, + ti + ); + } + + self.oracle_errors + .record(LiqErrorType::Liq, &ti, err.to_string()); + return result; + } + // Keep track of pubkeys that had errors error_tracking.record(LiqErrorType::Liq, pubkey, err.to_string()); diff --git a/bin/liquidator/src/unwrappable_oracle_error.rs b/bin/liquidator/src/unwrappable_oracle_error.rs new file mode 100644 index 000000000..f27340eea --- /dev/null +++ b/bin/liquidator/src/unwrappable_oracle_error.rs @@ -0,0 +1,126 @@ +use anchor_lang::error::Error::AnchorError; +use mango_v4::error::MangoError; +use mango_v4::state::TokenIndex; +use regex::Regex; + +pub trait UnwrappableOracleError { + fn try_unwrap_oracle_error(&self) -> Option<(TokenIndex, String)>; +} + +impl UnwrappableOracleError for anyhow::Error { + fn try_unwrap_oracle_error(&self) -> Option<(TokenIndex, String)> { + let root_cause = self + .root_cause() + .downcast_ref::(); + + if root_cause.is_none() { + return None; + } + + if let AnchorError(ae) = root_cause.unwrap() { + let is_oracle_error = ae.error_code_number == MangoError::OracleConfidence.error_code() + || ae.error_code_number == MangoError::OracleStale.error_code(); + + if !is_oracle_error { + return None; + } + + let error_str = ae.to_string(); + return parse_oracle_error_string(&error_str); + } + + None + } +} + +fn parse_oracle_error_string(error_str: &str) -> Option<(TokenIndex, String)> { + let token_name_regex = Regex::new(r#"name: (\w+)"#).unwrap(); + let token_index_regex = Regex::new(r#"token index (\d+)"#).unwrap(); + let token_name = token_name_regex + .captures(error_str) + .map(|c| c[1].to_string()) + .unwrap_or_default(); + let token_index = token_index_regex + .captures(error_str) + .map(|c| c[1].parse::().ok()) + .unwrap_or_default(); + + if token_index.is_some() { + return Some((TokenIndex::from(token_index.unwrap()), token_name)); + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + use anchor_lang::error; + use anyhow::Context; + use mango_v4::error::Contextable; + use mango_v4::error::MangoError; + use mango_v4::state::{oracle_log_context, OracleConfig, OracleState, OracleType}; + + fn generate_errored_res() -> std::result::Result { + return Err(MangoError::OracleConfidence.into()); + } + + fn generate_errored_res_with_context() -> anyhow::Result { + let value = Contextable::with_context( + Contextable::with_context(generate_errored_res(), || { + oracle_log_context( + "SOL", + &OracleState { + price: Default::default(), + deviation: Default::default(), + last_update_slot: 0, + oracle_type: OracleType::Pyth, + }, + &OracleConfig { + conf_filter: Default::default(), + max_staleness_slots: 0, + reserved: [0; 72], + }, + None, + ) + }), + || { + format!( + "getting oracle for bank with health account index {} and token index {}, passed account {}", + 10, + 11, + 12, + ) + }, + )?; + + Ok(value) + } + + #[test] + fn should_extract_oracle_error_and_token_infos() { + let error = generate_errored_res_with_context() + .context("Something") + .unwrap_err(); + println!("{}", error); + println!("{}", error.root_cause()); + let oracle_error_opt = error.try_unwrap_oracle_error(); + + assert!(oracle_error_opt.is_some()); + assert_eq!( + oracle_error_opt.unwrap(), + (TokenIndex::from(11u16), "SOL".to_string()) + ); + } + + #[test] + fn should_parse_oracle_error_message() { + assert!(parse_oracle_error_string("").is_none()); + assert!(parse_oracle_error_string("Something went wrong").is_none()); + assert_eq!( + parse_oracle_error_string("Something went wrong token index 4, name: SOL, Stale") + .unwrap(), + (TokenIndex::from(4u16), "SOL".to_string()) + ); + } +} From d9f55c4c224b94d9a8ab10f7e39fb4c637c0ce06 Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Sat, 24 Feb 2024 10:04:30 +0100 Subject: [PATCH 36/42] keeper: cu limit when batching charge_collateral_fee ix --- bin/keeper/src/crank.rs | 27 +++++++++++++++++++++++---- bin/keeper/src/main.rs | 6 ++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/bin/keeper/src/crank.rs b/bin/keeper/src/crank.rs index 10c12d412..b9ddbdaab 100644 --- a/bin/keeper/src/crank.rs +++ b/bin/keeper/src/crank.rs @@ -98,6 +98,7 @@ pub async fn runner( interval_update_funding: u64, interval_check_for_changes_and_abort: u64, interval_charge_collateral_fees: u64, + max_cu_when_batching: u32, extra_jobs: Vec>, ) -> Result<(), anyhow::Error> { let handles1 = mango_client @@ -157,7 +158,11 @@ pub async fn runner( futures::future::join_all(handles1), futures::future::join_all(handles2), futures::future::join_all(handles3), - loop_charge_collateral_fees(mango_client.clone(), interval_charge_collateral_fees), + loop_charge_collateral_fees( + mango_client.clone(), + interval_charge_collateral_fees, + max_cu_when_batching + ), MangoClient::loop_check_for_context_changes_and_abort( mango_client.clone(), Duration::from_secs(interval_check_for_changes_and_abort), @@ -431,7 +436,11 @@ pub async fn loop_update_funding( } } -pub async fn loop_charge_collateral_fees(mango_client: Arc, interval: u64) { +pub async fn loop_charge_collateral_fees( + mango_client: Arc, + interval: u64, + max_cu_when_batching: u32, +) { if interval == 0 { return; } @@ -451,7 +460,14 @@ pub async fn loop_charge_collateral_fees(mango_client: Arc, interva loop { interval.tick().await; - match charge_collateral_fees_inner(&mango_client, &fetcher, collateral_fee_interval).await { + match charge_collateral_fees_inner( + &mango_client, + &fetcher, + collateral_fee_interval, + max_cu_when_batching, + ) + .await + { Ok(()) => {} Err(err) => { error!("charge_collateral_fees error: {err:?}"); @@ -464,6 +480,7 @@ async fn charge_collateral_fees_inner( client: &MangoClient, fetcher: &RpcAccountFetcher, collateral_fee_interval: u64, + max_cu_when_batching: u32, ) -> anyhow::Result<()> { let mango_accounts = fetcher .fetch_program_accounts(&mango_v4::id(), MangoAccount::DISCRIMINATOR) @@ -512,6 +529,7 @@ async fn charge_collateral_fees_inner( client.transaction_builder().await?, &client.client, &ix_to_send, + max_cu_when_batching, ) .await; info!("charge collateral fees: {:?}", txsigs); @@ -524,6 +542,7 @@ async fn send_batched_log_errors_no_confirm( mut tx_builder: TransactionBuilder, client: &mango_v4_client::Client, ixs_list: &[PreparedInstructions], + max_cu: u32, ) -> Vec { let mut txsigs = Vec::new(); @@ -533,7 +552,7 @@ async fn send_batched_log_errors_no_confirm( current_batch.append(ixs.clone()); tx_builder.instructions = current_batch.clone().to_instructions(); - if !tx_builder.transaction_size().is_ok() { + if !tx_builder.transaction_size().is_ok() || current_batch.cu > max_cu { tx_builder.instructions = previous_batch.to_instructions(); match tx_builder.send(client).await { Err(err) => error!("could not send transaction: {err:?}"), diff --git a/bin/keeper/src/main.rs b/bin/keeper/src/main.rs index 194d5a46b..ba1fc1085 100644 --- a/bin/keeper/src/main.rs +++ b/bin/keeper/src/main.rs @@ -73,6 +73,11 @@ struct Cli { /// url to the lite-rpc websocket, optional #[clap(long, env, default_value = "")] lite_rpc_url: String, + + /// When batching multiple instructions into a transaction, don't exceed + /// this compute unit limit. + #[clap(long, env, default_value_t = 1_000_000)] + max_cu_when_batching: u32, } #[derive(Subcommand, Debug, Clone)] @@ -157,6 +162,7 @@ async fn main() -> Result<(), anyhow::Error> { cli.interval_update_funding, cli.interval_check_new_listings_and_abort, cli.interval_charge_collateral_fees, + cli.max_cu_when_batching, prio_jobs, ) .await From 54674e4b204e6bbb6f5b36fbe8529f6081c6c254 Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Mon, 26 Feb 2024 09:21:53 +0100 Subject: [PATCH 37/42] keeper: fix tx size limits on charge collateral fee batching --- bin/keeper/src/crank.rs | 7 ++++++- bin/liquidator/src/liquidate.rs | 2 +- bin/liquidator/src/rebalance.rs | 2 +- lib/client/src/client.rs | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/bin/keeper/src/crank.rs b/bin/keeper/src/crank.rs index b9ddbdaab..5884c6ef5 100644 --- a/bin/keeper/src/crank.rs +++ b/bin/keeper/src/crank.rs @@ -552,7 +552,12 @@ async fn send_batched_log_errors_no_confirm( current_batch.append(ixs.clone()); tx_builder.instructions = current_batch.clone().to_instructions(); - if !tx_builder.transaction_size().is_ok() || current_batch.cu > max_cu { + if tx_builder + .transaction_size() + .map(|ts| !ts.is_within_limit()) + .unwrap_or(true) + || current_batch.cu > max_cu + { tx_builder.instructions = previous_batch.to_instructions(); match tx_builder.send(client).await { Err(err) => error!("could not send transaction: {err:?}"), diff --git a/bin/liquidator/src/liquidate.rs b/bin/liquidator/src/liquidate.rs index 82854eb3d..c355aaa19 100644 --- a/bin/liquidator/src/liquidate.rs +++ b/bin/liquidator/src/liquidate.rs @@ -92,7 +92,7 @@ impl<'a> LiquidateHelper<'a> { let exceeds_cu_limit = new_ixs.cu > self.config.max_cu_per_transaction; let exceeds_size_limit = { tx_builder.instructions = new_ixs.clone().to_instructions(); - !tx_builder.transaction_size()?.is_ok() + !tx_builder.transaction_size()?.is_within_limit() }; if exceeds_cu_limit || exceeds_size_limit { break; diff --git a/bin/liquidator/src/rebalance.rs b/bin/liquidator/src/rebalance.rs index eb982ec84..6cc431dd0 100644 --- a/bin/liquidator/src/rebalance.rs +++ b/bin/liquidator/src/rebalance.rs @@ -231,7 +231,7 @@ impl Rebalancer { .prepare_swap_transaction(full) .await?; let tx_size = builder.transaction_size()?; - if tx_size.is_ok() { + if tx_size.is_within_limit() { return Ok((builder, full.clone())); } trace!( diff --git a/lib/client/src/client.rs b/lib/client/src/client.rs index 0b4d1b646..b6e3243a8 100644 --- a/lib/client/src/client.rs +++ b/lib/client/src/client.rs @@ -2233,7 +2233,7 @@ pub struct TransactionSize { } impl TransactionSize { - pub fn is_ok(&self) -> bool { + pub fn is_within_limit(&self) -> bool { let limit = Self::limit(); self.length <= limit.length && self.accounts <= limit.accounts } From d2c55c23e12cedeb579ed8b6a865f43b21b40fde Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Tue, 27 Feb 2024 15:56:14 +0100 Subject: [PATCH 38/42] Bank: more parameter sanity checks (#895) (cherry picked from commit aa9bc8b1f1149b2f6e5c27f35e653427f965aa32) --- programs/mango-v4/src/state/bank.rs | 9 +++++++-- programs/mango-v4/tests/cases/test_basic.rs | 2 ++ programs/mango-v4/tests/cases/test_force_close.rs | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/programs/mango-v4/src/state/bank.rs b/programs/mango-v4/src/state/bank.rs index 6f378a69c..cfc6aea3d 100644 --- a/programs/mango-v4/src/state/bank.rs +++ b/programs/mango-v4/src/state/bank.rs @@ -394,14 +394,18 @@ impl Bank { require_gte!(self.rate0, I80F48::ZERO); require_gte!(self.rate1, I80F48::ZERO); require_gte!(self.max_rate, I80F48::ZERO); + require_gte!(self.adjustment_factor, 0.0); require_gte!(self.loan_fee_rate, 0.0); require_gte!(self.loan_origination_fee_rate, 0.0); - require_gte!(self.maint_asset_weight, 0.0); + require_gte!(self.stable_price_model.delay_growth_limit, 0.0); + require_gte!(self.stable_price_model.stable_growth_limit, 0.0); require_gte!(self.init_asset_weight, 0.0); + require_gte!(self.maint_asset_weight, self.init_asset_weight); require_gte!(self.maint_liab_weight, 0.0); - require_gte!(self.init_liab_weight, 0.0); + require_gte!(self.init_liab_weight, self.maint_liab_weight); require_gte!(self.liquidation_fee, 0.0); require_gte!(self.min_vault_to_deposits_ratio, 0.0); + require_gte!(1.0, self.min_vault_to_deposits_ratio); require_gte!(self.net_borrow_limit_per_window_quote, -1); require_gt!(self.borrow_weight_scale_start_quote, 0.0); require_gt!(self.deposit_weight_scale_start_quote, 0.0); @@ -411,6 +415,7 @@ impl Bank { require_gte!(self.flash_loan_swap_fee_rate, 0.0); require_gte!(self.interest_curve_scaling, 1.0); require_gte!(self.interest_target_utilization, 0.0); + require_gte!(1.0, self.interest_target_utilization); require_gte!(self.maint_weight_shift_duration_inv, 0.0); require_gte!(self.maint_weight_shift_asset_target, 0.0); require_gte!(self.maint_weight_shift_liab_target, 0.0); diff --git a/programs/mango-v4/tests/cases/test_basic.rs b/programs/mango-v4/tests/cases/test_basic.rs index 6cc9e1a6d..dbbbbfa76 100644 --- a/programs/mango-v4/tests/cases/test_basic.rs +++ b/programs/mango-v4/tests/cases/test_basic.rs @@ -462,6 +462,8 @@ async fn test_bank_maint_weight_shift() -> Result<(), TransportError> { mint: mints[0].pubkey, fallback_oracle: Pubkey::default(), options: mango_v4::instruction::TokenEdit { + init_asset_weight_opt: Some(0.0), + init_liab_weight_opt: Some(2.0), maint_weight_shift_start_opt: Some(start_time + 1000), maint_weight_shift_end_opt: Some(start_time + 2000), maint_weight_shift_asset_target_opt: Some(0.5), diff --git a/programs/mango-v4/tests/cases/test_force_close.rs b/programs/mango-v4/tests/cases/test_force_close.rs index 3deab6252..9f7e4d1fe 100644 --- a/programs/mango-v4/tests/cases/test_force_close.rs +++ b/programs/mango-v4/tests/cases/test_force_close.rs @@ -501,6 +501,7 @@ async fn test_force_withdraw_token() -> Result<(), TransportError> { mint: token.mint.pubkey, fallback_oracle: Pubkey::default(), options: mango_v4::instruction::TokenEdit { + init_asset_weight_opt: Some(0.0), maint_asset_weight_opt: Some(0.0), reduce_only_opt: Some(1), disable_asset_liquidation_opt: Some(true), From 6b13841513028145ec5528f2cd4f4729802b5899 Mon Sep 17 00:00:00 2001 From: Lou-Kamades <128186011+Lou-Kamades@users.noreply.github.com> Date: Wed, 28 Feb 2024 09:31:00 -0800 Subject: [PATCH 39/42] add TokenBalanceLog in token_charge_collateral_fees (#894) * add TokenBalanceLog in token_charge_collateral_fees * increase cu_per_charge_collateral_fees_token (cherry picked from commit e7f9af92613de84d0d9b5292c9cafeac3304c967) --- lib/client/src/context.rs | 2 +- .../instructions/token_charge_collateral_fees.rs | 15 ++++++++++++++- programs/mango-v4/src/logs.rs | 1 + 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/client/src/context.rs b/lib/client/src/context.rs index 2654c7ab8..3ad362226 100644 --- a/lib/client/src/context.rs +++ b/lib/client/src/context.rs @@ -144,7 +144,7 @@ impl Default for ComputeEstimates { // the base cost is mostly the division cu_per_charge_collateral_fees: 20_000, // per-chargable-token cost - cu_per_charge_collateral_fees_token: 12_000, + cu_per_charge_collateral_fees_token: 15_000, } } } diff --git a/programs/mango-v4/src/instructions/token_charge_collateral_fees.rs b/programs/mango-v4/src/instructions/token_charge_collateral_fees.rs index fc145ba11..f58fbf1d3 100644 --- a/programs/mango-v4/src/instructions/token_charge_collateral_fees.rs +++ b/programs/mango-v4/src/instructions/token_charge_collateral_fees.rs @@ -5,7 +5,7 @@ use anchor_lang::prelude::*; use fixed::types::I80F48; use crate::accounts_ix::*; -use crate::logs::{emit_stack, TokenCollateralFeeLog}; +use crate::logs::{emit_stack, TokenBalanceLog, TokenCollateralFeeLog}; pub fn token_charge_collateral_fees(ctx: Context) -> Result<()> { let group = ctx.accounts.group.load()?; @@ -103,12 +103,25 @@ pub fn token_charge_collateral_fees(ctx: Context) -> bank.collected_fees_native += fee; bank.collected_collateral_fees += fee; + let token_info = health_cache.token_info(bank.token_index)?; + let token_position = account.token_position(bank.token_index)?; + emit_stack(TokenCollateralFeeLog { mango_group: ctx.accounts.group.key(), mango_account: ctx.accounts.account.key(), token_index: bank.token_index, fee: fee.to_bits(), asset_usage_fraction: asset_usage_scaling.to_bits(), + price: token_info.prices.oracle.to_bits(), + }); + + emit_stack(TokenBalanceLog { + mango_group: ctx.accounts.group.key(), + mango_account: ctx.accounts.account.key(), + token_index: bank.token_index, + indexed_position: token_position.indexed_position.to_bits(), + deposit_index: bank.deposit_index.to_bits(), + borrow_index: bank.borrow_index.to_bits(), }) } diff --git a/programs/mango-v4/src/logs.rs b/programs/mango-v4/src/logs.rs index 7f7a44a3c..9032b3bc6 100644 --- a/programs/mango-v4/src/logs.rs +++ b/programs/mango-v4/src/logs.rs @@ -803,6 +803,7 @@ pub struct TokenCollateralFeeLog { pub token_index: u16, pub asset_usage_fraction: i128, pub fee: i128, + pub price: i128, } #[event] From 0fee3d69c012d95038eaa42ac8ee931e506dfd88 Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Mon, 4 Mar 2024 11:20:13 +0100 Subject: [PATCH 40/42] Changelog for v0.23.0 (#903) (cherry picked from commit df15672522f31c41d76854706fce86a339dfc88f) --- CHANGELOG.md | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5d921f3b..059ff79d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,75 @@ Update this for each program release and mainnet deployment. ## not on mainnet +### v0.23.0, 2024-3- + +- Allow disabling asset liquidations for tokens (#867) + + This allows listing tokens that have no reliable oracle. Those tokens could be + traded through mango but can't be borrowed, can't have asset weight and can't + even be liquidated. + +- Add configurable collateral fees for tokens (#868, #880, #894) + + Collateral fees allow the DAO to regularly charge users for using particular + types of collateral to back their liabilities. + +- Add force_withdraw token state (#884) + + There already is a force_close_borrows state, but for a full delisting user + deposits need to be removed too. In force_withdraw, user deposits can be + permissionlessly withdrawn to their owners' token accounts. + +- Flash loan: Add a "swap without flash loan fees" option (#882) +- Cleanup, tests and minor (#878, #875, #854, #838, #895) + ## mainnet -### v0.21.2, 2024-1- +### v0.22.0, 2024-3-3 + +Deployment: Mar 3, 2024 at 23:52:08 Central European Standard Time, https://explorer.solana.com/tx/3MpEMU12Pv7RpSnwfShoM9sbyr41KAEeJFCVx9ypkq8nuK8Q5vm7CRLkdhH3u91yQ4k44a32armZHaoYguX6NqsY + +- Perp: Allow reusing your own perp order slots immediately (#817) + + Previously users who placed a lot of perp orders and used time-in-force needed + to wait for out-event cranking if their perp order before reusing an order + slot. Now perp order slots can be reused even when the out-event is still on + the event queue. + +- Introduce fallback oracles (#790, #813) + + Fallback oracles can be used when the primary oracle is stale or not confident. + These oracles need to configured by the DAO to be usable by clients. + + Fallback oracles may be based on Orca in addition to the other supported types. + +- Add serum3_cancel_by_client_order_id instruction (#798) + + Can now cancel by client order id and not just the order id. + +- Add configurable platform liquidation fees for tokens and perps (#849, #858) +- Delegates can now withdraw small token amounts to the owner's ata (#820) +- Custom allocator to allow larger heap use if needed (#801) +- Optimize compute use in token_deposit instruction (#786) +- Disable support for v1 and v2 mango accounts (#783) +- Cleanups, logging and tests (#819, #799, #818, #823, #834, #828, #833) + +### v0.21.3, 2024-2-9 + +Deployment: Feb 9, 2024 at 11:21:58 Central European Standard Time, https://explorer.solana.com/tx/44f2wcLyLiic1aycdaPTdfwXJBMeGeuA984kvCByg4L5iGprH6xW3D35gd3bvZ6kU3SipEtoY3kDuexJghbxL89T + +- Remove deposit limit check on Openbook v1 when placing an order to sell + deposits (#869) + +### v0.21.2, 2024-1-30 + +Deployment: Jan 30, 2024 at 12:36:09 Central European Standard Time, https://explorer.solana.com/tx/2kw6XhRUpLbh1fsPyQimCgNWjhy717qnUvxNMtLcBS4VNu8i59AJK4wY7wfZV62gT3GkSRTyaDNyD7Dkrg2gUFxC - Allow fast-listing of Openbook v1 markets (#839, #841) -### v0.21.1, 2024-1- +### v0.21.1, 2024-1-3 + +Deployment: Jan 3, 2024 at 14:35:10 Central European Standard Time, https://explorer.solana.com/tx/345NMQAvvtXeuGENz8icErXjGNmgkdU84JpvAMJFWXEGYZ2BNxFFcyZsHp5ELwLNUzY4s2hLa6wxHWPBFsTBLspA - Prevent withdraw operations from bringing token utilization over 100%. - Prevent extreme interest rates for tokens with borrows but near zero deposits. From bc166ea54b6a2030e275f68d5fb1e64998ed0e57 Mon Sep 17 00:00:00 2001 From: Serge Farny Date: Fri, 1 Mar 2024 11:21:58 +0100 Subject: [PATCH 41/42] ts client: fix imports (#898) (cherry picked from commit 53517f876b3cf516d25839315836cbb0acfb6b3c) --- ts/client/src/client.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ts/client/src/client.ts b/ts/client/src/client.ts index 77896e27d..e0b9054a3 100644 --- a/ts/client/src/client.ts +++ b/ts/client/src/client.ts @@ -1,4 +1,10 @@ -import { AnchorProvider, BN, Program, Wallet } from '@coral-xyz/anchor'; +import { + AnchorProvider, + BN, + Program, + Provider, + Wallet, +} from '@coral-xyz/anchor'; import { OpenOrders, decodeEventQueue } from '@project-serum/serum'; import { createAccount, @@ -63,6 +69,7 @@ import { } from './accounts/serum3'; import { IxGateParams, + PerpEditParams, TokenEditParams, TokenRegisterParams, buildIxGate, From a30c5a9e06d44d54a657664a558d2bee846ed269 Mon Sep 17 00:00:00 2001 From: Christian Kamm Date: Mon, 4 Mar 2024 11:22:08 +0100 Subject: [PATCH 42/42] Bump program version to v0.23.0, update idl --- Cargo.lock | 2 +- mango_v4.json | 107 +++++++++++++++++- programs/mango-v4/Cargo.toml | 2 +- ts/client/src/mango_v4.ts | 214 ++++++++++++++++++++++++++++++++++- 4 files changed, 320 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a5c572ac1..8f4a9ec9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3367,7 +3367,7 @@ dependencies = [ [[package]] name = "mango-v4" -version = "0.22.0" +version = "0.23.0" dependencies = [ "anchor-lang", "anchor-spl", diff --git a/mango_v4.json b/mango_v4.json index bf1308a1d..97fce5d5d 100644 --- a/mango_v4.json +++ b/mango_v4.json @@ -1,5 +1,5 @@ { - "version": "0.22.0", + "version": "0.23.0", "name": "mango_v4", "instructions": [ { @@ -12871,6 +12871,71 @@ } ] }, + { + "name": "PerpLiqBaseOrPositivePnlLogV3", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "perpMarketIndex", + "type": "u16", + "index": false + }, + { + "name": "liqor", + "type": "publicKey", + "index": false + }, + { + "name": "liqee", + "type": "publicKey", + "index": false + }, + { + "name": "baseTransferLiqee", + "type": "i64", + "index": false + }, + { + "name": "quoteTransferLiqee", + "type": "i128", + "index": false + }, + { + "name": "quoteTransferLiqor", + "type": "i128", + "index": false + }, + { + "name": "quotePlatformFee", + "type": "i128", + "index": false + }, + { + "name": "pnlTransfer", + "type": "i128", + "index": false + }, + { + "name": "pnlSettleLimitTransferRecurring", + "type": "i64", + "index": false + }, + { + "name": "pnlSettleLimitTransferOneshot", + "type": "i64", + "index": false + }, + { + "name": "price", + "type": "i128", + "index": false + } + ] + }, { "name": "PerpLiqBankruptcyLog", "fields": [ @@ -13888,6 +13953,46 @@ "name": "fee", "type": "i128", "index": false + }, + { + "name": "price", + "type": "i128", + "index": false + } + ] + }, + { + "name": "ForceWithdrawLog", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "mangoAccount", + "type": "publicKey", + "index": false + }, + { + "name": "tokenIndex", + "type": "u16", + "index": false + }, + { + "name": "quantity", + "type": "u64", + "index": false + }, + { + "name": "price", + "type": "i128", + "index": false + }, + { + "name": "toTokenAccount", + "type": "publicKey", + "index": false } ] } diff --git a/programs/mango-v4/Cargo.toml b/programs/mango-v4/Cargo.toml index b0b3fc7b2..aadf56fdd 100644 --- a/programs/mango-v4/Cargo.toml +++ b/programs/mango-v4/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mango-v4" -version = "0.22.0" +version = "0.23.0" description = "Created with Anchor" edition = "2021" diff --git a/ts/client/src/mango_v4.ts b/ts/client/src/mango_v4.ts index 14c08749e..44fce5b9a 100644 --- a/ts/client/src/mango_v4.ts +++ b/ts/client/src/mango_v4.ts @@ -1,5 +1,5 @@ export type MangoV4 = { - "version": "0.22.0", + "version": "0.23.0", "name": "mango_v4", "instructions": [ { @@ -12871,6 +12871,71 @@ export type MangoV4 = { } ] }, + { + "name": "PerpLiqBaseOrPositivePnlLogV3", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "perpMarketIndex", + "type": "u16", + "index": false + }, + { + "name": "liqor", + "type": "publicKey", + "index": false + }, + { + "name": "liqee", + "type": "publicKey", + "index": false + }, + { + "name": "baseTransferLiqee", + "type": "i64", + "index": false + }, + { + "name": "quoteTransferLiqee", + "type": "i128", + "index": false + }, + { + "name": "quoteTransferLiqor", + "type": "i128", + "index": false + }, + { + "name": "quotePlatformFee", + "type": "i128", + "index": false + }, + { + "name": "pnlTransfer", + "type": "i128", + "index": false + }, + { + "name": "pnlSettleLimitTransferRecurring", + "type": "i64", + "index": false + }, + { + "name": "pnlSettleLimitTransferOneshot", + "type": "i64", + "index": false + }, + { + "name": "price", + "type": "i128", + "index": false + } + ] + }, { "name": "PerpLiqBankruptcyLog", "fields": [ @@ -13888,6 +13953,46 @@ export type MangoV4 = { "name": "fee", "type": "i128", "index": false + }, + { + "name": "price", + "type": "i128", + "index": false + } + ] + }, + { + "name": "ForceWithdrawLog", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "mangoAccount", + "type": "publicKey", + "index": false + }, + { + "name": "tokenIndex", + "type": "u16", + "index": false + }, + { + "name": "quantity", + "type": "u64", + "index": false + }, + { + "name": "price", + "type": "i128", + "index": false + }, + { + "name": "toTokenAccount", + "type": "publicKey", + "index": false } ] } @@ -14247,7 +14352,7 @@ export type MangoV4 = { }; export const IDL: MangoV4 = { - "version": "0.22.0", + "version": "0.23.0", "name": "mango_v4", "instructions": [ { @@ -27119,6 +27224,71 @@ export const IDL: MangoV4 = { } ] }, + { + "name": "PerpLiqBaseOrPositivePnlLogV3", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "perpMarketIndex", + "type": "u16", + "index": false + }, + { + "name": "liqor", + "type": "publicKey", + "index": false + }, + { + "name": "liqee", + "type": "publicKey", + "index": false + }, + { + "name": "baseTransferLiqee", + "type": "i64", + "index": false + }, + { + "name": "quoteTransferLiqee", + "type": "i128", + "index": false + }, + { + "name": "quoteTransferLiqor", + "type": "i128", + "index": false + }, + { + "name": "quotePlatformFee", + "type": "i128", + "index": false + }, + { + "name": "pnlTransfer", + "type": "i128", + "index": false + }, + { + "name": "pnlSettleLimitTransferRecurring", + "type": "i64", + "index": false + }, + { + "name": "pnlSettleLimitTransferOneshot", + "type": "i64", + "index": false + }, + { + "name": "price", + "type": "i128", + "index": false + } + ] + }, { "name": "PerpLiqBankruptcyLog", "fields": [ @@ -28136,6 +28306,46 @@ export const IDL: MangoV4 = { "name": "fee", "type": "i128", "index": false + }, + { + "name": "price", + "type": "i128", + "index": false + } + ] + }, + { + "name": "ForceWithdrawLog", + "fields": [ + { + "name": "mangoGroup", + "type": "publicKey", + "index": false + }, + { + "name": "mangoAccount", + "type": "publicKey", + "index": false + }, + { + "name": "tokenIndex", + "type": "u16", + "index": false + }, + { + "name": "quantity", + "type": "u64", + "index": false + }, + { + "name": "price", + "type": "i128", + "index": false + }, + { + "name": "toTokenAccount", + "type": "publicKey", + "index": false } ] }