diff --git a/package.json b/package.json index cab748d6f..e2e0736f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@blockworks-foundation/mango-v4", - "version": "0.17.0", + "version": "0.18.14", "description": "Typescript Client for mango-v4 program.", "repository": "https://github.com/blockworks-foundation/mango-v4", "author": { diff --git a/ts/client/scripts/archive/conditional-swaps.ts b/ts/client/scripts/archive/conditional-swaps.ts index 06fe87e86..5d70aef9b 100644 --- a/ts/client/scripts/archive/conditional-swaps.ts +++ b/ts/client/scripts/archive/conditional-swaps.ts @@ -1,107 +1,106 @@ import { AnchorProvider, Wallet } from '@coral-xyz/anchor'; -import { Connection, Keypair, PublicKey } from '@solana/web3.js'; +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 USER_KEYPAIR = + process.env.USER_KEYPAIR_OVERRIDE || process.env.MB_PAYER_KEYPAIR; +const MANGO_ACCOUNT_PK = process.env.MANGO_ACCOUNT_PK || ''; +const CLUSTER: Cluster = + (process.env.CLUSTER_OVERRIDE as Cluster) || 'mainnet-beta'; +const CLUSTER_URL = + process.env.CLUSTER_URL_OVERRIDE || process.env.MB_CLUSTER_URL; + async function main(): Promise { try { const options = AnchorProvider.defaultOptions(); - const connection = new Connection(process.env.CLUSTER_URL!, options); + const connection = new Connection(CLUSTER_URL!, options); const user = Keypair.fromSecretKey( - Buffer.from( - JSON.parse(fs.readFileSync(process.env.USER_KEYPAIR!, 'utf-8')), - ), + Buffer.from(JSON.parse(fs.readFileSync(USER_KEYPAIR!, 'utf-8'))), ); const userWallet = new Wallet(user); const userProvider = new AnchorProvider(connection, userWallet, options); - // - // mainnet - // - const client = await MangoClient.connect( userProvider, - 'mainnet-beta', - MANGO_V4_ID['mainnet-beta'], + CLUSTER, + MANGO_V4_ID[CLUSTER], { idsSource: 'get-program-accounts', }, ); + const group = await client.getGroup( new PublicKey('78b8f4cGCwmZ9ysPFMWLaLTkkaYnUjwMJYStWe5RTSSX'), ); - console.log( - await client.getMangoAccountForOwner( - group, - new PublicKey('v3mmtZ8JjXkaAbRRMBiNsjJF1rnN3qsMQqRLMk7Nz2C'), - 3, - ), - ); - console.log( - await client.getMangoAccountsForDelegate( - group, - new PublicKey('5P9rHX22jb3MDq46VgeaHZ2TxQDKezPxsxNX3MaXyHwT'), - ), + + let account = await client.getMangoAccount(new PublicKey(MANGO_ACCOUNT_PK)); + await Promise.all( + account.tokenConditionalSwaps.map((tcs, i) => { + if (!tcs.hasData) { + return Promise.resolve(); + } + client.tokenConditionalSwapCancel(group, account, tcs.id); + }), ); - // - // devnet - // - - // const client = await MangoClient.connect( - // userProvider, - // 'devnet', - // MANGO_V4_ID['devnet'], - // { - // idsSource: 'get-program-accounts', - // }, + // const sig = await client.tcsTakeProfitOnDeposit( + // group, + // account, + // group.getFirstBankByTokenIndex(4 as TokenIndex), + // group.getFirstBankByTokenIndex(0 as TokenIndex), + // group.getFirstBankByTokenIndex(4 as TokenIndex).uiPrice + 1, + // false, + // null, + // null, + // null, // ); - // const admin = Keypair.fromSecretKey( - // Buffer.from( - // JSON.parse(fs.readFileSync(process.env.ADMIN_KEYPAIR!, 'utf-8')), - // ), + // const sig = await client.tcsStopLossOnDeposit( + // group, + // account, + // group.getFirstBankByTokenIndex(4 as TokenIndex), + // group.getFirstBankByTokenIndex(0 as TokenIndex), + // group.getFirstBankByTokenIndex(4 as TokenIndex).uiPrice - 1, + // false, + // null, + // null, + // null, // ); - // const group = await client.getGroupForCreator(admin.publicKey, 23); - // const mangoAccount = (await client.getMangoAccountForOwner( - // group, - // user.publicKey, - // 0, - // )) as MangoAccount; - // console.log(mangoAccount.tokenConditionalSwaps.length); - // console.log(mangoAccount.tokenConditionalSwaps); - // console.log(mangoAccount.tokenConditionalSwaps[1]); - // console.log(mangoAccount.tokenConditionalSwaps[0]); - // let sig = await client.accountExpandV2( + // const sig = await client.tcsTakeProfitOnBorrow( // group, - // mangoAccount, - // 16, - // 8, - // 8, - // 32, - // 8, - // ); - // console.log(sig); - // mangoAccount = await client.getOrCreateMangoAccount(group); - - // let sig = await client.tokenConditionalSwapCreate( - // group, - // mangoAccount, - // 0 as TokenIndex, - // 1 as TokenIndex, - // 0, - // 73, - // 81, - // TokenConditionalSwapPriceThresholdType.priceOverThreshold, - // 99, - // 101, - // true, + // account, + // group.getFirstBankByTokenIndex(0 as TokenIndex), + // group.getFirstBankByTokenIndex(4 as TokenIndex), + // group.getFirstBankByTokenIndex(4 as TokenIndex).uiPrice - 1, // true, + // null, + // null, + // null, + // null, // ); - // console.log(sig); + + const sig = await client.tcsStopLossOnBorrow( + group, + account, + group.getFirstBankByTokenIndex(0 as TokenIndex), + group.getFirstBankByTokenIndex(4 as TokenIndex), + group.getFirstBankByTokenIndex(4 as TokenIndex).uiPrice + 1, + true, + null, + null, + null, + null, + ); + + console.log(sig); + + account = await client.getMangoAccount(new PublicKey(MANGO_ACCOUNT_PK)); + console.log(account.tokenConditionalSwaps[0].toString(group)); } catch (error) { console.log(error); } diff --git a/ts/client/scripts/archive/mb-user.ts b/ts/client/scripts/archive/mb-user.ts index bb6e55adc..cde12e652 100644 --- a/ts/client/scripts/archive/mb-user.ts +++ b/ts/client/scripts/archive/mb-user.ts @@ -1,5 +1,5 @@ import { AnchorProvider, Wallet } from '@coral-xyz/anchor'; -import { Connection, Keypair } from '@solana/web3.js'; +import { Connection, Keypair, PublicKey } from '@solana/web3.js'; import fs from 'fs'; import { HealthType, MangoAccount } from '../../src/accounts/mangoAccount'; import { @@ -33,7 +33,9 @@ async function main() { ); console.log(`Admin ${admin.publicKey.toBase58()}`); - const group = await client.getGroupForCreator(admin.publicKey, 2); + const group = await client.getGroup( + new PublicKey('78b8f4cGCwmZ9ysPFMWLaLTkkaYnUjwMJYStWe5RTSSX'), + ); console.log(`${group.toString()}`); // create + fetch account diff --git a/ts/client/scripts/archive/token-approve-test.ts b/ts/client/scripts/archive/token-approve-test.ts new file mode 100644 index 000000000..9854f339f --- /dev/null +++ b/ts/client/scripts/archive/token-approve-test.ts @@ -0,0 +1,115 @@ +import { + createApproveInstruction, + createCloseAccountInstruction, + createSyncNativeInstruction, + createTransferInstruction, + getAccount, + getAssociatedTokenAddress, + NATIVE_MINT, +} from '@solana/spl-token'; +import { + Connection, + Keypair, + sendAndConfirmTransaction, + SystemProgram, + Transaction, +} from '@solana/web3.js'; +import fs from 'fs'; + +async function main(): Promise { + try { + let sig; + const conn = new Connection(process.env.MB_CLUSTER_URL!); + + // load wallet 1 + const w1 = Keypair.fromSecretKey( + Buffer.from(JSON.parse(fs.readFileSync(process.env.wallet1!, 'utf-8'))), + ); + + // load wallet 2 + const w2 = Keypair.fromSecretKey( + Buffer.from(JSON.parse(fs.readFileSync(process.env.wallet2!, 'utf-8'))), + ); + + const w1WsolTA = await getAssociatedTokenAddress(NATIVE_MINT, w1.publicKey); + // const ataTransaction1 = new Transaction().add( + // createAssociatedTokenAccountInstruction( + // w1.publicKey, + // w1WsolTA, + // w1.publicKey, + // NATIVE_MINT, + // ), + // ); + // await sendAndConfirmTransaction(conn, ataTransaction1, [w1]); + + const w2WsolTA = await getAssociatedTokenAddress(NATIVE_MINT, w2.publicKey); + // const ataTransaction2 = new Transaction().add( + // createAssociatedTokenAccountInstruction( + // w2.publicKey, + // w2WsolTA, + // w2.publicKey, + // NATIVE_MINT, + // ), + // ); + // await sendAndConfirmTransaction(conn, ataTransaction2, [w2]); + + // wallet 1 wrap sol to wsol + const solTransferTransaction = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: w1.publicKey, + toPubkey: w1WsolTA, + lamports: 1, + }), + createSyncNativeInstruction(w1WsolTA), + ); + sig = await sendAndConfirmTransaction(conn, solTransferTransaction, [w1]); + console.log( + `sig w1 wrapped some sol https://explorer.solana.com/tx/${sig}`, + ); + + // wallet 1 approve wallet 2 for some wsol + const tokenApproveTx = new Transaction().add( + createApproveInstruction(w1WsolTA, w2.publicKey, w1.publicKey, 1), + ); + sig = await sendAndConfirmTransaction(conn, tokenApproveTx, [w1]); + console.log( + `sig w1 token approve w2 https://explorer.solana.com/tx/${sig}`, + ); + + // log delegate amount + let w2WsolAtaInfo = await getAccount(conn, w1WsolTA); + console.log( + `- delegate ${w2WsolAtaInfo.delegate}, amount ${w2WsolAtaInfo.delegatedAmount}`, + ); + + // wallet 2 transfer wsol from wallet 1 to wallet 2 + const tokenTransferTx = new Transaction().add( + createTransferInstruction(w1WsolTA, w2WsolTA, w2.publicKey, 1), + ); + sig = await sendAndConfirmTransaction(conn, tokenTransferTx, [w2], { + skipPreflight: true, + }); + console.log( + `sig w1 transfer wsol to w2 https://explorer.solana.com/tx/${sig}`, + ); + + // log delegate amount + w2WsolAtaInfo = await getAccount(conn, w1WsolTA, 'finalized'); + console.log( + `- delegate ${w2WsolAtaInfo.delegate}, amount ${w2WsolAtaInfo.delegatedAmount}`, + ); + + // wallet 2 unwrap all wsol + const closeAtaIx = new Transaction().add( + createCloseAccountInstruction(w2WsolTA, w2.publicKey, w2.publicKey), + ); + sig = await sendAndConfirmTransaction(conn, closeAtaIx, [w2], { + skipPreflight: true, + }); + console.log(`sig w2 unwrap wsol https://explorer.solana.com/tx/${sig}`); + } catch (error) { + console.log(error); + } +} + +main(); diff --git a/ts/client/scripts/liqtest/liqtest-make-candidates.ts b/ts/client/scripts/liqtest/liqtest-make-candidates.ts index 4efa8d3be..b64220520 100644 --- a/ts/client/scripts/liqtest/liqtest-make-candidates.ts +++ b/ts/client/scripts/liqtest/liqtest-make-candidates.ts @@ -1,6 +1,5 @@ import { AnchorProvider, BN, Wallet } from '@coral-xyz/anchor'; import { Cluster, Connection, Keypair, PublicKey } from '@solana/web3.js'; -import { assert } from 'console'; import fs from 'fs'; import { Bank } from '../../src/accounts/bank'; import { MangoAccount } from '../../src/accounts/mangoAccount'; diff --git a/ts/client/scripts/liqtest/liqtest-make-tcs-candidates.ts b/ts/client/scripts/liqtest/liqtest-make-tcs-candidates.ts index ae2217183..5a81f6e7d 100644 --- a/ts/client/scripts/liqtest/liqtest-make-tcs-candidates.ts +++ b/ts/client/scripts/liqtest/liqtest-make-tcs-candidates.ts @@ -175,7 +175,7 @@ async function main() { accounts2.find((account) => account.name == 'LIQTEST, LIQEE1'), ); await client.accountExpandV2(group, account, 4, 4, 4, 4, 4); - await client.tokenConditionalSwapCreate( + await client.tokenConditionalSwapCreateRaw( group, account, MINTS.get('SOL')!, @@ -199,7 +199,7 @@ async function main() { accounts2.find((account) => account.name == 'LIQTEST, LIQEE2'), ); await client.accountExpandV2(group, account, 4, 4, 4, 4, 4); - await client.tokenConditionalSwapCreate( + await client.tokenConditionalSwapCreateRaw( group, account, MINTS.get('SOL')!, @@ -223,7 +223,7 @@ async function main() { accounts2.find((account) => account.name == 'LIQTEST, LIQEE3'), ); await client.accountExpandV2(group, account, 4, 4, 4, 4, 4); - await client.tokenConditionalSwapCreate( + await client.tokenConditionalSwapCreateRaw( group, account, MINTS.get('SOL')!, diff --git a/ts/client/src/accounts/bank.ts b/ts/client/src/accounts/bank.ts index 21464ac0d..46d92b222 100644 --- a/ts/client/src/accounts/bank.ts +++ b/ts/client/src/accounts/bank.ts @@ -1,7 +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 { I80F48, I80F48Dto, ZERO_I80F48 } from '../numbers/I80F48'; +import { I80F48, I80F48Dto, ONE_I80F48, ZERO_I80F48 } from '../numbers/I80F48'; import { As, toUiDecimals } from '../utils'; import { OracleProvider } from './oracle'; @@ -210,8 +210,8 @@ export class Bank implements BankForHealth { initLiabWeight: I80F48Dto, liquidationFee: I80F48Dto, dust: I80F48Dto, - flashLoanTokenAccountInitial: BN, - flashLoanApprovedAmount: BN, + public flashLoanTokenAccountInitial: BN, + public flashLoanApprovedAmount: BN, public tokenIndex: TokenIndex, public mintDecimals: number, public bankNum: number, @@ -364,6 +364,14 @@ export class Bank implements BankForHealth { ); } + getAssetPrice(): I80F48 { + return this.price.min(I80F48.fromNumber(this.stablePriceModel.stablePrice)); + } + + getLiabPrice(): I80F48 { + return this.price.max(I80F48.fromNumber(this.stablePriceModel.stablePrice)); + } + get price(): I80F48 { if (this._price === undefined) { throw new Error( @@ -409,17 +417,11 @@ export class Bank implements BankForHealth { } uiDeposits(): number { - return toUiDecimals( - this.indexedDeposits.mul(this.depositIndex), - this.mintDecimals, - ); + return toUiDecimals(this.nativeDeposits(), this.mintDecimals); } uiBorrows(): number { - return toUiDecimals( - this.indexedBorrows.mul(this.borrowIndex), - this.mintDecimals, - ); + return toUiDecimals(this.nativeBorrows(), this.mintDecimals); } /** @@ -493,6 +495,48 @@ export class Bank implements BankForHealth { getDepositRateUi(): number { return this.getDepositRate().toNumber() * 100; } + + getNetBorrowLimitPerWindow(): I80F48 { + return I80F48.fromI64(this.netBorrowLimitPerWindowQuote).div(this.price); + } + + getBorrowLimitLeftInWindow(): I80F48 { + return this.getNetBorrowLimitPerWindow() + .sub(I80F48.fromI64(this.netBorrowsInWindow)) + .max(ZERO_I80F48()); + } + + getNetBorrowLimitPerWindowUi(): number { + return toUiDecimals(this.getNetBorrowLimitPerWindow(), this.mintDecimals); + } + + getMaxWithdraw(vaultBalance: BN, userDeposits = ZERO_I80F48()): I80F48 { + userDeposits = userDeposits.max(ZERO_I80F48()); + + // any borrow must respect the minVaultToDepositsRatio + const minVaultBalanceRequired = this.nativeDeposits().mul( + I80F48.fromNumber(this.minVaultToDepositsRatio), + ); + const maxBorrowFromVault = I80F48.fromI64(vaultBalance) + .sub(minVaultBalanceRequired) + .max(ZERO_I80F48()); + // User deposits can exceed maxWithdrawFromVault + let maxBorrow = maxBorrowFromVault.sub(userDeposits).max(ZERO_I80F48()); + // any borrow must respect the limit left in window + maxBorrow = maxBorrow.min(this.getBorrowLimitLeftInWindow()); + // borrows would be applied a fee + maxBorrow = maxBorrow.div(ONE_I80F48().add(this.loanOriginationFeeRate)); + + // user deposits can always be withdrawn + // even if vaults can be depleted + return maxBorrow.add(userDeposits).min(I80F48.fromI64(vaultBalance)); + } + + getTimeToNextBorrowLimitWindowStartsTs(): number { + return this.netBorrowLimitWindowSizeTs + .sub(new BN(Date.now() / 1000).sub(this.lastNetBorrowsWindowStartTs)) + .toNumber(); + } } export class MintInfo { diff --git a/ts/client/src/accounts/group.ts b/ts/client/src/accounts/group.ts index f1cab82c3..a1f180cfc 100644 --- a/ts/client/src/accounts/group.ts +++ b/ts/client/src/accounts/group.ts @@ -14,7 +14,8 @@ import { MangoClient } from '../client'; import { OPENBOOK_PROGRAM_ID } from '../constants'; import { Id } from '../ids'; import { I80F48, ONE_I80F48 } from '../numbers/I80F48'; -import { toNative, toNativeI80F48, toUiDecimals } from '../utils'; +import { PriceImpact, computePriceImpactOnJup } from '../risk'; +import { buildFetch, toNative, toNativeI80F48, toUiDecimals } from '../utils'; import { Bank, MintInfo, TokenIndex } from './bank'; import { OracleProvider, @@ -80,6 +81,7 @@ export class Group { new Map(), // mintInfosMapByTokenIndex new Map(), // mintInfosMapByMint new Map(), // vaultAmountsMap + [], ); } @@ -115,6 +117,7 @@ export class Group { public mintInfosMapByTokenIndex: Map, public mintInfosMapByMint: Map, public vaultAmountsMap: Map, + public pis: PriceImpact[], ) {} public async reloadAll(client: MangoClient): Promise { @@ -122,6 +125,7 @@ export class Group { // console.time('group.reload'); await Promise.all([ + this.reloadPriceImpactData(), this.reloadAlts(client), this.reloadBanks(client, ids).then(() => Promise.all([ @@ -140,6 +144,27 @@ export class Group { // console.timeEnd('group.reload'); } + public async reloadPriceImpactData(): Promise { + try { + this.pis = await ( + await ( + await buildFetch() + )( + `https://api.mngo.cloud/data/v4/risk/listed-tokens-one-week-price-impacts`, + { + mode: 'cors', + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + }, + ) + ).json(); + } catch (error) { + console.log(`Error while loading price impact: ${error}`); + } + } + public async reloadAlts(client: MangoClient): Promise { const alts = await Promise.all( this.addressLookupTables @@ -480,6 +505,19 @@ export class Group { return banks[0]; } + /** + * Returns a price impact in percentage, between 0 to 100 for a token, + * returns -1 if data is bad + */ + public getPriceImpactByTokenIndex( + tokenIndex: TokenIndex, + usdcAmountUi: number, + ): number { + const bank = this.getFirstBankByTokenIndex(tokenIndex); + const pisBps = computePriceImpactOnJup(this.pis, usdcAmountUi, bank.name); + return (pisBps * 100) / 10000; + } + public getFirstBankForMngo(): Bank { return this.getFirstBankByTokenIndex(this.mngoTokenIndex); } @@ -488,12 +526,7 @@ export class Group { return this.getFirstBankByTokenIndex(0 as TokenIndex); } - /** - * - * @param mintPk - * @returns sum of ui balances of vaults for all banks for a token - */ - public getTokenVaultBalanceByMintUi(mintPk: PublicKey): number { + public getTokenVaultBalanceByMint(mintPk: PublicKey): BN { const banks = this.banksMapByMint.get(mintPk.toBase58()); if (!banks) { throw new Error(`No bank found for mint ${mintPk}!`); @@ -509,7 +542,19 @@ export class Group { totalAmount.iadd(amount); } - return toUiDecimals(totalAmount, this.getMintDecimals(mintPk)); + return totalAmount; + } + + /** + * + * @param mintPk + * @returns sum of ui balances of vaults for all banks for a token + */ + public getTokenVaultBalanceByMintUi(mintPk: PublicKey): number { + return toUiDecimals( + this.getTokenVaultBalanceByMint(mintPk), + this.getMintDecimals(mintPk), + ); } public getSerum3MarketByMarketIndex(marketIndex: MarketIndex): Serum3Market { diff --git a/ts/client/src/accounts/healthCache.ts b/ts/client/src/accounts/healthCache.ts index 9552dac4f..eb56795bb 100644 --- a/ts/client/src/accounts/healthCache.ts +++ b/ts/client/src/accounts/healthCache.ts @@ -9,12 +9,16 @@ import { ONE_I80F48, ZERO_I80F48, } from '../numbers/I80F48'; -import { toNativeI80F48ForQuote } from '../utils'; +import { + toNativeI80F48ForQuote, + toUiDecimals, + toUiDecimalsForQuote, +} from '../utils'; import { Bank, BankForHealth, TokenIndex } from './bank'; import { Group } from './group'; import { HealthType, MangoAccount, PerpPosition } from './mangoAccount'; -import { PerpMarket, PerpOrderSide } from './perp'; +import { PerpMarket, PerpMarketIndex, PerpOrderSide } from './perp'; import { MarketIndex, Serum3Market, Serum3Side } from './serum3'; // ░░░░ @@ -235,6 +239,48 @@ export class HealthCache { return tokenBalances; } + effectiveTokenBalancesInternalDisplay( + group: Group, + healthType: HealthType | undefined, + ignoreNegativePerp: boolean, + ): TokenBalanceDisplay[] { + const tokenBalances = new Array(this.tokenInfos.length) + .fill(null) + .map((ignored) => new TokenBalanceDisplay(ZERO_I80F48(), 0, [])); + + for (const perpInfo of this.perpInfos) { + const settleTokenIndex = this.findTokenInfoIndex( + perpInfo.settleTokenIndex, + ); + const perpSettleToken = tokenBalances[settleTokenIndex]; + const healthUnsettled = perpInfo.healthUnsettledPnl(healthType); + perpSettleToken.perpMarketContributions.push({ + market: group.getPerpMarketByMarketIndex( + perpInfo.perpMarketIndex as PerpMarketIndex, + ).name, + contributionUi: toUiDecimals( + healthUnsettled, + group.getMintDecimalsByTokenIndex(perpInfo.settleTokenIndex), + ), + }); + if (!ignoreNegativePerp || healthUnsettled.gt(ZERO_I80F48())) { + perpSettleToken.spotAndPerp.iadd(healthUnsettled); + } + } + + for (const index of this.tokenInfos.keys()) { + const tokenInfo = this.tokenInfos[index]; + const tokenBalance = tokenBalances[index]; + tokenBalance.spotAndPerp.iadd(tokenInfo.balanceSpot); + tokenBalance.spotUi += toUiDecimals( + tokenInfo.balanceSpot, + group.getMintDecimalsByTokenIndex(tokenInfo.tokenIndex), + ); + } + + return tokenBalances; + } + healthSum(healthType: HealthType, tokenBalances: TokenBalance[]): I80F48 { const health = ZERO_I80F48(); for (const index of this.tokenInfos.keys()) { @@ -262,6 +308,70 @@ export class HealthCache { return health; } + healthContributionPerAssetUi( + group: Group, + healthType: HealthType, + ): { + asset: string; + contribution: number; + contributionDetails: + | { + spotUi: number; + perpMarketContributions: { market: string; contributionUi: number }[]; + } + | undefined; + }[] { + const tokenBalancesDisplay: TokenBalanceDisplay[] = + this.effectiveTokenBalancesInternalDisplay(group, healthType, false); + + const ret = new Array<{ + asset: string; + contribution: number; + contributionDetails: + | { + spotUi: number; + perpMarketContributions: { + market: string; + contributionUi: number; + }[]; + } + | undefined; + }>(); + for (const index of this.tokenInfos.keys()) { + const tokenInfo = this.tokenInfos[index]; + const tokenBalance = tokenBalancesDisplay[index]; + const contrib = tokenInfo.healthContribution( + healthType, + tokenBalance.spotAndPerp, + ); + ret.push({ + asset: group.getFirstBankByTokenIndex(tokenInfo.tokenIndex).name, + contribution: toUiDecimalsForQuote(contrib), + contributionDetails: { + spotUi: tokenBalance.spotUi, + perpMarketContributions: tokenBalance.perpMarketContributions, + }, + }); + } + const res = this.computeSerum3Reservations(healthType); + for (const [index, serum3Info] of this.serum3Infos.entries()) { + const contrib = serum3Info.healthContribution( + healthType, + this.tokenInfos, + tokenBalancesDisplay, + res.tokenMaxReserved, + res.serum3Reserved[index], + ); + ret.push({ + asset: group.getSerum3MarketByMarketIndex(serum3Info.marketIndex).name, + contribution: toUiDecimalsForQuote(contrib), + contributionDetails: undefined, + }); + } + + return ret; + } + public health(healthType: HealthType): I80F48 { const tokenBalances = this.effectiveTokenBalancesInternal( healthType, @@ -1389,6 +1499,17 @@ class TokenBalance { constructor(public spotAndPerp: I80F48) {} } +class TokenBalanceDisplay { + constructor( + public spotAndPerp: I80F48, + public spotUi: number, + public perpMarketContributions: { + market: string; + contributionUi: number; + }[], + ) {} +} + class TokenMaxReserved { constructor(public maxSerumReserved: I80F48) {} } diff --git a/ts/client/src/accounts/mangoAccount.ts b/ts/client/src/accounts/mangoAccount.ts index 9d99d959c..f4ca5da8c 100644 --- a/ts/client/src/accounts/mangoAccount.ts +++ b/ts/client/src/accounts/mangoAccount.ts @@ -5,7 +5,14 @@ import { AccountInfo, PublicKey, TransactionSignature } from '@solana/web3.js'; import { MangoClient } from '../client'; import { OPENBOOK_PROGRAM_ID, RUST_I64_MAX, RUST_I64_MIN } from '../constants'; import { I80F48, I80F48Dto, ONE_I80F48, ZERO_I80F48 } from '../numbers/I80F48'; -import { toNativeI80F48, toUiDecimals, toUiDecimalsForQuote } from '../utils'; +import { + U64_MAX_BN, + roundTo5, + toNativeI80F48, + toUiDecimals, + toUiDecimalsForQuote, + toUiSellPerBuyTokenPrice, +} from '../utils'; import { Bank, TokenIndex } from './bank'; import { Group } from './group'; import { HealthCache } from './healthCache'; @@ -182,6 +189,10 @@ export class MangoAccount { return this.serum3.filter((serum3) => serum3.isActive()); } + public tokenConditionalSwapsActive(): TokenConditionalSwap[] { + return this.tokenConditionalSwaps.filter((tcs) => tcs.hasData); + } + public perpPositionExistsForMarket(perpMarket: PerpMarket): boolean { return this.perps.some( (pp) => pp.isActive() && pp.marketIndex == perpMarket.perpMarketIndex, @@ -198,10 +209,6 @@ export class MangoAccount { return this.perps.filter((perp) => perp.isActive()); } - public tokenConditionalSwapsActive(): TokenConditionalSwap[] { - return this.tokenConditionalSwaps.filter((tcs) => tcs.hasData); - } - public perpOrdersActive(): PerpOo[] { return this.perpOpenOrders.filter( (oo) => oo.orderMarket !== PerpOo.OrderMarketUnset, @@ -344,6 +351,23 @@ export class MangoAccount { return hc.health(healthType); } + public getHealthContributionPerAssetUi( + group: Group, + healthType: HealthType, + ): { + asset: string; + contribution: number; + contributionDetails: + | { + spotUi: number; + perpMarketContributions: { market: string; contributionUi: number }[]; + } + | undefined; + }[] { + const hc = HealthCache.fromMangoAccount(group, this); + return hc.healthContributionPerAssetUi(group, healthType); + } + public perpMaxSettle( group: Group, perpMarketSettleTokenIndex: TokenIndex, @@ -422,9 +446,9 @@ export class MangoAccount { * Sum of all positive assets. * @returns assets, in native quote */ - public getAssetsValue(group: Group): I80F48 { + public getAssetsValue(group: Group, healthType?: HealthType): I80F48 { const hc = HealthCache.fromMangoAccount(group, this); - return hc.healthAssetsAndLiabs(undefined, false).assets; + return hc.healthAssetsAndLiabs(healthType, false).assets; } /** @@ -513,8 +537,8 @@ export class MangoAccount { let existingPositionHealthContrib = ZERO_I80F48(); if (existingTokenDeposits.gt(ZERO_I80F48())) { existingPositionHealthContrib = existingTokenDeposits - .mul(tokenBank.price) - .imul(tokenBank.initAssetWeight); + .mul(tokenBank.getAssetPrice()) + .imul(tokenBank.scaledInitAssetWeight(tokenBank.getAssetPrice())); } // Case 2: token deposits have higher contribution than initHealth, @@ -522,8 +546,8 @@ export class MangoAccount { if (existingPositionHealthContrib.gt(initHealth)) { const withdrawAbleExistingPositionHealthContrib = initHealth; return withdrawAbleExistingPositionHealthContrib - .div(tokenBank.initAssetWeight) - .div(tokenBank.price); + .div(tokenBank.scaledInitAssetWeight(tokenBank.getAssetPrice())) + .div(tokenBank.getAssetPrice()); } // Case 3: withdraw = withdraw existing deposits + borrows until initHealth reaches 0 @@ -597,12 +621,11 @@ export class MangoAccount { ), ); const sourceBalance = this.getEffectiveTokenBalance(group, sourceBank); - if (maxSource.gt(sourceBalance)) { - const sourceBorrow = maxSource.sub(sourceBalance); - maxSource = sourceBalance.add( - sourceBorrow.div(ONE_I80F48().add(sourceBank.loanOriginationFeeRate)), - ); - } + const maxWithdrawNative = sourceBank.getMaxWithdraw( + group.getTokenVaultBalanceByMint(sourceBank.mint), + sourceBalance, + ); + maxSource = maxSource.min(maxWithdrawNative); return toUiDecimals(maxSource, group.getMintDecimals(sourceMintPk)); } @@ -733,12 +756,11 @@ export class MangoAccount { // If its a bid then the reserved fund and potential loan is in base // also keep some buffer for fees, use taker fees for worst case simulation. const quoteBalance = this.getEffectiveTokenBalance(group, quoteBank); - if (quoteAmount.gt(quoteBalance)) { - const quoteBorrow = quoteAmount.sub(quoteBalance); - quoteAmount = quoteBalance.add( - quoteBorrow.div(ONE_I80F48().add(quoteBank.loanOriginationFeeRate)), - ); - } + const maxWithdrawNative = quoteBank.getMaxWithdraw( + group.getTokenVaultBalanceByMint(quoteBank.mint), + quoteBalance, + ); + quoteAmount = quoteAmount.min(maxWithdrawNative); quoteAmount = quoteAmount.div( ONE_I80F48().add(I80F48.fromNumber(serum3Market.getFeeRates(true))), ); @@ -775,12 +797,11 @@ export class MangoAccount { // If its a ask then the reserved fund and potential loan is in base // also keep some buffer for fees, use taker fees for worst case simulation. const baseBalance = this.getEffectiveTokenBalance(group, baseBank); - if (baseAmount.gt(baseBalance)) { - const baseBorrow = baseAmount.sub(baseBalance); - baseAmount = baseBalance.add( - baseBorrow.div(ONE_I80F48().add(baseBank.loanOriginationFeeRate)), - ); - } + const maxWithdrawNative = baseBank.getMaxWithdraw( + group.getTokenVaultBalanceByMint(baseBank.mint), + baseBalance, + ); + baseAmount = baseAmount.min(maxWithdrawNative); baseAmount = baseAmount.div( ONE_I80F48().add(I80F48.fromNumber(serum3Market.getFeeRates(true))), ); @@ -954,6 +975,7 @@ export class MangoAccount { group: Group, perpMarketIndex: PerpMarketIndex, size: number, + healthType: HealthType = HealthType.init, ): number { const perpMarket = group.getPerpMarketByMarketIndex(perpMarketIndex); const pp = this.getPerpPosition(perpMarket.perpMarketIndex); @@ -967,7 +989,7 @@ export class MangoAccount { PerpOrderSide.bid, perpMarket.uiBaseToLots(size), perpMarket.price, - HealthType.init, + healthType, ) .toNumber(); } @@ -976,6 +998,7 @@ export class MangoAccount { group: Group, perpMarketIndex: PerpMarketIndex, size: number, + healthType: HealthType = HealthType.init, ): number { const perpMarket = group.getPerpMarketByMarketIndex(perpMarketIndex); const pp = this.getPerpPosition(perpMarket.perpMarketIndex); @@ -989,7 +1012,7 @@ export class MangoAccount { PerpOrderSide.ask, perpMarket.uiBaseToLots(size), perpMarket.price, - HealthType.init, + healthType, ) .toNumber(); } @@ -1826,6 +1849,116 @@ export class TokenConditionalSwap { public priceDisplayStyle: TokenConditionalSwapDisplayPriceStyle, public intention: TokenConditionalSwapIntention, ) {} + + getMaxBuyUi(group: Group): number { + const buyBank = this.getBuyToken(group); + return toUiDecimals(this.maxBuy, buyBank.mintDecimals); + } + + getMaxSellUi(group: Group): number { + const sellBank = this.getSellToken(group); + return toUiDecimals(this.maxSell, sellBank.mintDecimals); + } + + getBoughtUi(group: Group): number { + const buyBank = this.getBuyToken(group); + return toUiDecimals(this.bought, buyBank.mintDecimals); + } + + getSoldUi(group: Group): number { + const sellBank = this.getSellToken(group); + return toUiDecimals(this.sold, sellBank.mintDecimals); + } + + getExpiryTimestampInEpochSeconds(): number { + return this.expiryTimestamp.toNumber(); + } + + private priceLimitToUi( + group: Group, + sellTokenPerBuyTokenNative: number, + ): number { + const buyBank = this.getBuyToken(group); + const sellBank = this.getSellToken(group); + const sellTokenPerBuyTokenUi = toUiSellPerBuyTokenPrice( + sellTokenPerBuyTokenNative, + sellBank, + buyBank, + ); + + // Below are workarounds to know when to show an inverted price in ui + // We want to identify if the pair user is wanting to trade is + // buytoken/selltoken or selltoken/buytoken + + // Buy limit / close short + if (this.maxSell.eq(U64_MAX_BN)) { + return roundTo5(sellTokenPerBuyTokenUi); + } + + // Stop loss / take profit + const buyTokenPerSellTokenUi = 1 / sellTokenPerBuyTokenUi; + return roundTo5(buyTokenPerSellTokenUi); + } + + getPriceLowerLimitUi(group: Group): number { + return this.priceLimitToUi(group, this.priceLowerLimit); + } + + getPriceUpperLimitUi(group: Group): number { + return this.priceLimitToUi(group, this.priceUpperLimit); + } + + getThresholdPriceUi(group: Group): number { + const a = I80F48.fromNumber(this.priceLowerLimit); + const b = I80F48.fromNumber(this.priceUpperLimit); + + const buyBank = this.getBuyToken(group); + const sellBank = this.getSellToken(group); + const o = buyBank.price.div(sellBank.price); + + // Choose the price closest to oracle + if (o.sub(a).abs().lt(o.sub(b).abs())) { + return this.getPriceLowerLimitUi(group); + } + return this.getPriceUpperLimitUi(group); + } + + // in percent + getPricePremium(): number { + return this.pricePremiumRate * 100; + } + + getBuyToken(group: Group): Bank { + return group.getFirstBankByTokenIndex(this.buyTokenIndex); + } + + getSellToken(group: Group): Bank { + return group.getFirstBankByTokenIndex(this.sellTokenIndex); + } + + getAllowCreatingDeposits(): boolean { + return this.allowCreatingDeposits; + } + + getAllowCreatingBorrows(): boolean { + return this.allowCreatingBorrows; + } + + toString(group: Group): string { + return `getMaxBuy ${this.getMaxBuyUi( + group, + )}, getMaxSell ${this.getMaxSellUi(group)}, bought ${this.getBoughtUi( + group, + )}, sold ${this.getSoldUi( + group, + )}, getPriceLowerLimitUi ${this.getPriceLowerLimitUi( + group, + )}, getPriceUpperLimitUi ${this.getPriceUpperLimitUi( + group, + )}, getThresholdPriceUi ${this.getThresholdPriceUi( + group, + )}, getPricePremium ${this.getPricePremium()}, expiry ${this.expiryTimestamp.toString()}`; + } } export class TokenConditionalSwapDto { diff --git a/ts/client/src/client.ts b/ts/client/src/client.ts index 094febe12..3ce5dd99e 100644 --- a/ts/client/src/client.ts +++ b/ts/client/src/client.ts @@ -79,10 +79,13 @@ import { createAssociatedTokenAccountIdempotentInstruction, getAssociatedTokenAddress, toNative, + toNativeSellPerBuyTokenPrice, } from './utils'; import { sendTransaction } from './utils/rpc'; import { NATIVE_MINT, TOKEN_PROGRAM_ID } from './utils/spl'; +export const DEFAULT_TOKEN_CONDITIONAL_SWAP_COUNT = 8; + export enum AccountRetriever { Scanning, Fixed, @@ -161,6 +164,46 @@ export class MangoClient { }); } + public async adminTokenWithdrawFees( + group: Group, + bank: Bank, + tokenAccountPk: PublicKey, + ): Promise { + const admin = (this.program.provider as AnchorProvider).wallet.publicKey; + const ix = await this.program.methods + .adminTokenWithdrawFees() + .accounts({ + group: group.publicKey, + bank: bank.publicKey, + vault: bank.vault, + tokenAccount: tokenAccountPk, + admin, + }) + .instruction(); + return await this.sendAndConfirmTransaction([ix]); + } + + public async adminPerpWithdrawFees( + group: Group, + perpMarket: PerpMarket, + tokenAccountPk: PublicKey, + ): Promise { + const bank = group.getFirstBankByTokenIndex(perpMarket.settleTokenIndex); + const admin = (this.program.provider as AnchorProvider).wallet.publicKey; + const ix = await this.program.methods + .adminPerpWithdrawFees() + .accounts({ + group: group.publicKey, + perpMarket: perpMarket.publicKey, + bank: bank.publicKey, + vault: bank.vault, + tokenAccount: tokenAccountPk, + admin, + }) + .instruction(); + return await this.sendAndConfirmTransaction([ix]); + } + // Group public async groupCreate( groupNum: number, @@ -720,7 +763,28 @@ export class MangoClient { perpOoCount: number, tokenConditionalSwapCount: number, ): Promise { - const ix = await this.program.methods + const ix = await this.accountExpandV2Ix( + group, + account, + tokenCount, + serum3Count, + perpCount, + perpOoCount, + tokenConditionalSwapCount, + ); + return await this.sendAndConfirmTransactionForGroup(group, [ix]); + } + + public async accountExpandV2Ix( + group: Group, + account: MangoAccount, + tokenCount: number, + serum3Count: number, + perpCount: number, + perpOoCount: number, + tokenConditionalSwapCount: number, + ): Promise { + return await this.program.methods .accountExpandV2( tokenCount, serum3Count, @@ -735,7 +799,6 @@ export class MangoClient { payer: (this.program.provider as AnchorProvider).wallet.publicKey, }) .instruction(); - return await this.sendAndConfirmTransactionForGroup(group, [ix]); } public async editMangoAccount( @@ -825,7 +888,7 @@ export class MangoClient { ); } - private async getMangoAccountFromAi( + public async getMangoAccountFromAi( mangoAccountPk: PublicKey, ai: AccountInfo, ): Promise { @@ -1249,6 +1312,7 @@ export class MangoClient { const tokenAccountPk = await getAssociatedTokenAddress( mintPk, mangoAccount.owner, + true, ); let wrappedSolAccount: Keypair | undefined; @@ -1352,6 +1416,7 @@ export class MangoClient { const tokenAccountPk = await getAssociatedTokenAddress( bank.mint, mangoAccount.owner, + true, ); // ensure withdraws don't fail with missing ATAs @@ -2335,6 +2400,56 @@ export class MangoClient { return await this.sendAndConfirmTransactionForGroup(group, [ix]); } + public async perpCloseAll( + group: Group, + mangoAccount: MangoAccount, + slippage = 0.01, // 1%, 100bps + ): Promise { + if (mangoAccount.perpActive().length == 0) { + throw new Error(`No perp positions found.`); + } + + if (mangoAccount.perpActive().length > 8) { + // Technically we can fit in 16, 1.6M CU, 100k CU per ix, but lets be conservative + throw new Error( + `Can't close more than 8 positions in one tx, due to compute usage limit.`, + ); + } + + const hrix1 = await this.healthRegionBeginIx(group, mangoAccount); + const ixs = await Promise.all( + mangoAccount.perpActive().map(async (pa) => { + const pm = group.getPerpMarketByMarketIndex(pa.marketIndex); + const isLong = pa.basePositionLots.gt(new BN(0)); + + return await this.perpPlaceOrderV2Ix( + group, + mangoAccount, + pa.marketIndex, + isLong ? PerpOrderSide.ask : PerpOrderSide.bid, + pm.uiPrice * (isLong ? 1 - slippage : 1 + slippage), // Try to cross the spread to guarantee matching + pa.getBasePositionUi(pm) * 1.01, // Send a larger size to ensure full order is closed + undefined, + Date.now(), + PerpOrderType.immediateOrCancel, + PerpSelfTradeBehavior.decrementTake, + true, // Reduce only + undefined, + undefined, + ); + }), + ); + const hrix2 = await this.healthRegionEndIx(group, mangoAccount); + + return await this.sendAndConfirmTransactionForGroup( + group, + [hrix1, ...ixs, hrix2], + { + prioritizationFee: true, + }, + ); + } + // perpPlaceOrder ix returns an optional, custom order id, // but, since we use a customer tx sender, this method // doesn't return it @@ -2712,6 +2827,84 @@ export class MangoClient { .instruction(); } + async settleAll( + client: MangoClient, + group: Group, + mangoAccount: MangoAccount, + allMangoAccounts?: MangoAccount[], + ): Promise { + if (!allMangoAccounts) { + allMangoAccounts = await client.getAllMangoAccounts(group, true); + } + + const ixs1 = new Array(); + // This is optimistic, since we might find the same opponent candidate for all markets, + // and they have might not be able to settle at some point due to safety limits + // Future: correct way to do is, to apply the settlement on a copy and then move to next position + for (const pa of mangoAccount.perpActive()) { + const pm = group.getPerpMarketByMarketIndex(pa.marketIndex); + const candidates = await pm.getSettlePnlCandidates( + client, + group, + allMangoAccounts, + pa.getUnsettledPnlUi(pm) > 0 ? 'negative' : 'positive', + 2, + ); + if (candidates.length == 0) { + continue; + } + ixs1.push( + // Takes ~130k CU + await this.perpSettlePnlIx( + group, + pa.getUnsettledPnlUi(pm) > 0 ? mangoAccount : candidates[0].account, + pa.getUnsettledPnlUi(pm) < 0 ? candidates[0].account : mangoAccount, + mangoAccount, + pm.perpMarketIndex, + ), + ); + ixs1.push( + // Takes ~20k CU + await this.perpSettleFeesIx( + group, + mangoAccount, + pm.perpMarketIndex, + undefined, + ), + ); + } + + const ixs2 = await Promise.all( + mangoAccount.serum3Active().map((s) => { + const serum3Market = group.getSerum3MarketByMarketIndex(s.marketIndex); + // Takes ~65k CU + return this.serum3SettleFundsV2Ix( + group, + mangoAccount, + serum3Market.serumMarketExternal, + ); + }), + ); + + if ( + mangoAccount.perpActive().length * 150 + + mangoAccount.serum3Active().length * 65 > + 1600 + ) { + throw new Error( + `Too many perp positions and serum open orders to settle in one tx! Please try settling individually!`, + ); + } + + return await this.sendAndConfirmTransactionForGroup( + group, + [...ixs1, ...ixs2], + { + prioritizationFee: true, + }, + ); + } + async perpSettlePnlAndFees( group: Group, profitableAccount: MangoAccount, @@ -2983,6 +3176,7 @@ export class MangoClient { const inputTokenAccountPk = await getAssociatedTokenAddress( inputBank.mint, swapExecutingWallet, + true, ); const inputTokenAccExists = await this.program.provider.connection.getAccountInfo( @@ -3002,6 +3196,7 @@ export class MangoClient { const outputTokenAccountPk = await getAssociatedTokenAddress( outputBank.mint, swapExecutingWallet, + true, ); const outputTokenAccExists = await this.program.provider.connection.getAccountInfo( @@ -3191,7 +3386,266 @@ export class MangoClient { return await this.sendAndConfirmTransactionForGroup(group, [ix]); } + public async tcsTakeProfitOnDeposit( + group: Group, + account: MangoAccount, + sellBank: Bank, + buyBank: Bank, + thresholdPriceUi: number, + thresholdPriceInSellPerBuyToken: boolean, + maxSellUi: number | null, + pricePremium: number | null, + expiryTimestamp: number | null, + ): Promise { + if (account.getTokenBalanceUi(sellBank) < 0) { + throw new Error( + `Only allowed to take profits on deposits! Current balance ${account.getTokenBalanceUi( + sellBank, + )}`, + ); + } + + return await this.tokenConditionalSwapCreate( + group, + account, + sellBank, + buyBank, + thresholdPriceUi, + thresholdPriceInSellPerBuyToken, + Number.MAX_SAFE_INTEGER, + maxSellUi ?? account.getTokenBalanceUi(sellBank), + 'TakeProfitOnDeposit', + pricePremium, + true, + false, + expiryTimestamp, + ); + } + + public async tcsStopLossOnDeposit( + group: Group, + account: MangoAccount, + sellBank: Bank, + buyBank: Bank, + thresholdPriceUi: number, + thresholdPriceInSellPerBuyToken: boolean, + maxSellUi: number | null, + pricePremium: number | null, + expiryTimestamp: number | null, + ): Promise { + if (account.getTokenBalanceUi(sellBank) < 0) { + throw new Error( + `Only allowed to set a stop loss on deposits! Current balance ${account.getTokenBalanceUi( + sellBank, + )}`, + ); + } + + return await this.tokenConditionalSwapCreate( + group, + account, + sellBank, + buyBank, + thresholdPriceUi, + thresholdPriceInSellPerBuyToken, + Number.MAX_SAFE_INTEGER, + maxSellUi ?? account.getTokenBalanceUi(sellBank), + 'StopLossOnDeposit', + pricePremium, + true, + false, + expiryTimestamp, + ); + } + + public async tcsTakeProfitOnBorrow( + group: Group, + account: MangoAccount, + sellBank: Bank, + buyBank: Bank, + thresholdPriceUi: number, + thresholdPriceInSellPerBuyToken: boolean, + maxBuyUi: number | null, + pricePremium: number | null, + allowMargin: boolean | null, + expiryTimestamp: number | null, + ): Promise { + if (account.getTokenBalanceUi(buyBank) > 0) { + throw new Error( + `Only allowed to take profits on borrows! Current balance ${account.getTokenBalanceUi( + buyBank, + )}`, + ); + } + + return await this.tokenConditionalSwapCreate( + group, + account, + sellBank, + buyBank, + thresholdPriceUi, + thresholdPriceInSellPerBuyToken, + maxBuyUi ?? -account.getTokenBalanceUi(buyBank), + Number.MAX_SAFE_INTEGER, + 'TakeProfitOnBorrow', + pricePremium, + false, + allowMargin ?? false, + expiryTimestamp, + ); + } + + public async tcsStopLossOnBorrow( + group: Group, + account: MangoAccount, + sellBank: Bank, + buyBank: Bank, + thresholdPriceUi: number, + thresholdPriceInSellPerBuyToken: boolean, + maxBuyUi: number | null, + pricePremium: number | null, + allowMargin: boolean | null, + expiryTimestamp: number | null, + ): Promise { + if (account.getTokenBalanceUi(buyBank) > 0) { + throw new Error( + `Only allowed to set stop loss on borrows! Current balance ${account.getTokenBalanceUi( + buyBank, + )}`, + ); + } + + return await this.tokenConditionalSwapCreate( + group, + account, + sellBank, + buyBank, + thresholdPriceUi, + thresholdPriceInSellPerBuyToken, + maxBuyUi ?? -account.getTokenBalanceUi(buyBank), + Number.MAX_SAFE_INTEGER, + 'StopLossOnBorrow', + pricePremium, + false, + allowMargin ?? false, + expiryTimestamp, + ); + } + public async tokenConditionalSwapCreate( + group: Group, + account: MangoAccount, + sellBank: Bank, + buyBank: Bank, + thresholdPriceUi: number, + thresholdPriceInSellPerBuyToken: boolean, + maxBuyUi: number, + maxSellUi: number, + tcsIntention: + | 'TakeProfitOnDeposit' + | 'StopLossOnDeposit' + | 'TakeProfitOnBorrow' + | 'StopLossOnBorrow' + | null, + pricePremium: number | null, + allowCreatingDeposits: boolean, + allowCreatingBorrows: boolean, + expiryTimestamp: number | null, + ): Promise { + const maxBuy = + maxBuyUi == Number.MAX_SAFE_INTEGER + ? U64_MAX_BN + : toNative(maxBuyUi, buyBank.mintDecimals); + const maxSell = + maxSellUi == Number.MAX_SAFE_INTEGER + ? U64_MAX_BN + : toNative(maxSellUi, sellBank.mintDecimals); + + if (!thresholdPriceInSellPerBuyToken) { + thresholdPriceUi = 1 / thresholdPriceUi; + } + + let lowerLimit, upperLimit; + const thresholdPrice = toNativeSellPerBuyTokenPrice( + thresholdPriceUi, + sellBank, + buyBank, + ); + const sellTokenPerBuyTokenPrice = buyBank.price + .div(sellBank.price) + .toNumber(); + + if ( + tcsIntention == 'TakeProfitOnDeposit' || + tcsIntention == 'StopLossOnBorrow' || + (tcsIntention == null && thresholdPrice > sellTokenPerBuyTokenPrice) + ) { + lowerLimit = thresholdPrice; + upperLimit = Number.MAX_SAFE_INTEGER; + } else { + lowerLimit = 0; + upperLimit = thresholdPrice; + } + + const expiryTimestampBn = + expiryTimestamp !== null ? new BN(expiryTimestamp) : U64_MAX_BN; + + if (!pricePremium) { + const buyTokenPriceImpact = group.getPriceImpactByTokenIndex( + buyBank.tokenIndex, + 5000, + ); + const sellTokenPriceImpact = group.getPriceImpactByTokenIndex( + sellBank.tokenIndex, + 5000, + ); + pricePremium = + ((1 + buyTokenPriceImpact / 100) * (1 + sellTokenPriceImpact / 100) - + 1) * + 100; + } + const pricePremiumRate = pricePremium > 0 ? pricePremium / 100 : 0.03; + + const tcsIx = await this.program.methods + .tokenConditionalSwapCreate( + maxBuy, + maxSell, + expiryTimestampBn, + lowerLimit, + upperLimit, + pricePremiumRate, + allowCreatingDeposits, + allowCreatingBorrows, + ) + .accounts({ + group: group.publicKey, + account: account.publicKey, + authority: (this.program.provider as AnchorProvider).wallet.publicKey, + buyBank: buyBank.publicKey, + sellBank: sellBank.publicKey, + }) + .instruction(); + + const ixs: TransactionInstruction[] = []; + if (account.tokenConditionalSwaps.length == 0) { + ixs.push( + await this.accountExpandV2Ix( + group, + account, + account.tokens.length, + account.serum3.length, + account.perps.length, + account.perpOpenOrders.length, + DEFAULT_TOKEN_CONDITIONAL_SWAP_COUNT, + ), + ); + } + ixs.push(tcsIx); + + return await this.sendAndConfirmTransactionForGroup(group, ixs); + } + + public async tokenConditionalSwapCreateRaw( group: Group, account: MangoAccount, buyMintPk: PublicKey, @@ -3231,21 +3685,36 @@ export class MangoClient { }) .instruction(); - return await this.sendAndConfirmTransactionForGroup(group, [ix]); + const ixs = [ix]; + if (account.tokenConditionalSwaps.length == 0) { + ixs.push( + await this.accountExpandV2Ix( + group, + account, + account.tokens.length, + account.serum3.length, + account.perps.length, + account.perpOpenOrders.length, + 8, + ), + ); + } + + return await this.sendAndConfirmTransactionForGroup(group, ixs); } public async tokenConditionalSwapCancel( group: Group, account: MangoAccount, - tokenConditionalSwapIndex: number, tokenConditionalSwapId: BN, ): Promise { - const tcs = account - .tokenConditionalSwapsActive() - .find((tcs) => tcs.id.eq(tokenConditionalSwapId)); - if (!tcs) { + const tokenConditionalSwapIndex = account.tokenConditionalSwaps.findIndex( + (tcs) => tcs.id.eq(tokenConditionalSwapId), + ); + if (tokenConditionalSwapIndex == -1) { throw new Error('tcs with id not found'); } + const tcs = account.tokenConditionalSwaps[tokenConditionalSwapIndex]; const buyBank = group.banksMapByTokenIndex.get(tcs.buyTokenIndex)![0]; const sellBank = group.banksMapByTokenIndex.get(tcs.sellTokenIndex)![0]; @@ -3267,21 +3736,50 @@ export class MangoClient { return await this.sendAndConfirmTransactionForGroup(group, [ix]); } + public async tokenConditionalSwapCancelAll( + group: Group, + account: MangoAccount, + ): Promise { + const ixs = await Promise.all( + account.tokenConditionalSwaps + .filter((tcs) => tcs.hasData) + .map(async (tcs, i) => { + const buyBank = group.banksMapByTokenIndex.get(tcs.buyTokenIndex)![0]; + const sellBank = group.banksMapByTokenIndex.get( + tcs.sellTokenIndex, + )![0]; + return await this.program.methods + .tokenConditionalSwapCancel(i, new BN(tcs.id)) + .accounts({ + group: group.publicKey, + account: account.publicKey, + authority: (this.program.provider as AnchorProvider).wallet + .publicKey, + buyBank: buyBank.publicKey, + sellBank: sellBank.publicKey, + }) + .instruction(); + }), + ); + + return await this.sendAndConfirmTransactionForGroup(group, ixs); + } + public async tokenConditionalSwapTrigger( group: Group, liqee: MangoAccount, liqor: MangoAccount, - tokenConditionalSwapIndex: number, tokenConditionalSwapId: BN, maxBuyTokenToLiqee: number, maxSellTokenToLiqor: number, ): Promise { - const tcs = liqee - .tokenConditionalSwapsActive() - .find((tcs) => tcs.id.eq(tokenConditionalSwapId)); - if (!tcs) { + const tokenConditionalSwapIndex = liqee.tokenConditionalSwaps.findIndex( + (tcs) => tcs.id.eq(tokenConditionalSwapId), + ); + if (tokenConditionalSwapIndex == -1) { throw new Error('tcs with id not found'); } + const tcs = liqee.tokenConditionalSwaps[tokenConditionalSwapIndex]; const buyBank = group.banksMapByTokenIndex.get(tcs.buyTokenIndex)![0]; const sellBank = group.banksMapByTokenIndex.get(tcs.sellTokenIndex)![0]; diff --git a/ts/client/src/constants/index.ts b/ts/client/src/constants/index.ts index e0a96c385..1d8f39ea4 100644 --- a/ts/client/src/constants/index.ts +++ b/ts/client/src/constants/index.ts @@ -21,3 +21,7 @@ export const MANGO_V4_ID = { devnet: new PublicKey('4MangoMjqJ2firMokCjjGgoK8d4MXcrgL7XJaL3w6fVg'), 'mainnet-beta': new PublicKey('4MangoMjqJ2firMokCjjGgoK8d4MXcrgL7XJaL3w6fVg'), }; + +export const USDC_MINT = new PublicKey( + 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', +); diff --git a/ts/client/src/index.ts b/ts/client/src/index.ts index 04b10883c..a70a20bec 100644 --- a/ts/client/src/index.ts +++ b/ts/client/src/index.ts @@ -19,6 +19,7 @@ export { buildIxGate, } from './clientIxParamBuilder'; export * from './constants'; +export * from './mango_v4'; export * from './numbers/I80F48'; export * from './risk'; export * from './router'; diff --git a/ts/client/src/numbers/numbers.spec.ts b/ts/client/src/numbers/numbers.spec.ts index ecb2d25fa..62abf6568 100644 --- a/ts/client/src/numbers/numbers.spec.ts +++ b/ts/client/src/numbers/numbers.spec.ts @@ -1,9 +1,24 @@ import BN from 'bn.js'; import { expect } from 'chai'; -import { U64_MAX_BN } from '../utils'; +import { U64_MAX_BN, roundTo5 } from '../utils'; import { I80F48 } from './I80F48'; describe('Math', () => { + it('round to accuracy 5', () => { + expect(roundTo5(0.012)).equals(0.012); + expect(roundTo5(0.0123456789)).equals(0.012345); + expect(roundTo5(0.123456789)).equals(0.12345); + expect(roundTo5(1.23456789)).equals(1.2345); + expect(roundTo5(12.3456789)).equals(12.345); + expect(roundTo5(123.456789)).equals(123.45); + expect(roundTo5(1234.56789)).equals(1234.5); + expect(roundTo5(12345.6789)).equals(12346); + expect(roundTo5(123456.789)).equals(123457); + + expect(roundTo5(1.23)).equals(1.2299); + expect(roundTo5(1.2)).equals(1.1999); + }); + it('js number to BN and I80F48', () => { // BN can be only be created from js numbers which are <=2^53 expect(function () { diff --git a/ts/client/src/risk.ts b/ts/client/src/risk.ts index 33ad34478..c11de3791 100644 --- a/ts/client/src/risk.ts +++ b/ts/client/src/risk.ts @@ -6,20 +6,7 @@ import { Group } from './accounts/group'; import { HealthType, MangoAccount } from './accounts/mangoAccount'; import { MangoClient } from './client'; import { I80F48, ONE_I80F48, ZERO_I80F48 } from './numbers/I80F48'; -import { toUiDecimals, toUiDecimalsForQuote } from './utils'; - -async function buildFetch(): Promise< - ( - input: RequestInfo | URL, - init?: RequestInit | undefined, - ) => Promise -> { - let fetch = globalThis?.fetch; - if (!fetch && process?.versions?.node) { - fetch = (await import('node-fetch')).default; - } - return fetch; -} +import { buildFetch, toUiDecimals, toUiDecimalsForQuote } from './utils'; export interface LiqorPriceImpact { Coin: { val: string; highlight: boolean }; @@ -56,33 +43,42 @@ export interface Risk { liqorEquity: { title: string; data: AccountEquity[] }; } -export async function computePriceImpactOnJup( - amount: string, - inputMint: string, - outputMint: string, -): Promise<{ outAmount: number; priceImpactPct: number }> { - const url = `https://quote-api.jup.ag/v4/quote?inputMint=${inputMint}&outputMint=${outputMint}&amount=${amount}&swapMode=ExactIn&slippageBps=10000&onlyDirectRoutes=false&asLegacyTransaction=false`; - const response = await (await buildFetch())(url, { mode: 'no-cors' }); +export type PriceImpact = { + symbol: string; + side: 'bid' | 'ask'; + target_amount: number; + avg_price_impact_percent: number; + min_price_impact_percent: number; + max_price_impact_percent: number; +}; +/** + * Returns price impact in bps i.e. 0 to 10,000 + * returns -1 if data is missing + */ +export function computePriceImpactOnJup( + pis: PriceImpact[], + usdcAmount: number, + tokenName: string, +): number { try { - const res = await response.json(); - if (res['data'] && res.data.length > 0 && res.data[0].outAmount) { - return { - outAmount: parseFloat(res.data[0].outAmount), - priceImpactPct: parseFloat(res.data[0].priceImpactPct), - }; + const closestTo = [1000, 5000, 20000, 100000].reduce((prev, curr) => + Math.abs(curr - usdcAmount) < Math.abs(prev - usdcAmount) ? curr : prev, + ); + // Workaround api + if (tokenName == 'ETH (Portal)') { + tokenName = 'ETH'; + } + const filteredPis: PriceImpact[] = pis.filter( + (pi) => pi.symbol == tokenName && pi.target_amount == closestTo, + ); + if (filteredPis.length > 0) { + return (filteredPis[0].max_price_impact_percent * 10000) / 100; } else { - return { - outAmount: -1 / 10000, - priceImpactPct: -1 / 10000, - }; + return -1; } } catch (e) { - console.log(e); - return { - outAmount: -1 / 10000, - priceImpactPct: -1 / 10000, - }; + return -1; } } @@ -107,6 +103,7 @@ export async function getOnChainPriceForMints( export async function getPriceImpactForLiqor( group: Group, + pis: PriceImpact[], mangoAccounts: MangoAccount[], ): Promise { const mangoAccountsWithHealth = mangoAccounts.map((a: MangoAccount) => { @@ -232,25 +229,24 @@ export async function getPriceImpactForLiqor( return sum.add(maxAsset); }, ZERO_I80F48()); - const [pi1, pi2] = await Promise.all([ + const pi1 = !liabsInUsdc.eq(ZERO_I80F48()) && usdcMint.toBase58() !== bank.mint.toBase58() ? computePriceImpactOnJup( - liabsInUsdc.toString(), - usdcMint.toBase58(), - bank.mint.toBase58(), + pis, + toUiDecimalsForQuote(liabsInUsdc), + bank.name, ) - : Promise.resolve({ priceImpactPct: 0, outAmount: 0 }), - + : 0; + const pi2 = !assets.eq(ZERO_I80F48()) && usdcMint.toBase58() !== bank.mint.toBase58() ? computePriceImpactOnJup( - assets.floor().toString(), - bank.mint.toBase58(), - usdcMint.toBase58(), + pis, + toUiDecimals(assets.mul(bank.price), bank.mintDecimals), + bank.name, ) - : Promise.resolve({ priceImpactPct: 0, outAmount: 0 }), - ]); + : 0; return { Coin: { val: bank.name, highlight: false }, @@ -277,9 +273,9 @@ export async function getPriceImpactForLiqor( highlight: Math.round(toUiDecimalsForQuote(liabsInUsdc)) > 5000, }, 'Liabs Slippage': { - val: Math.round(pi1.priceImpactPct * 10000), + val: Math.round(pi1), highlight: - Math.round(pi1.priceImpactPct * 10000) > + Math.round(pi1) > Math.round(bank.liquidationFee.toNumber() * 10000), }, Assets: { @@ -292,9 +288,9 @@ export async function getPriceImpactForLiqor( ) > 5000, }, 'Assets Slippage': { - val: Math.round(pi2.priceImpactPct * 10000), + val: Math.round(pi2), highlight: - Math.round(pi2.priceImpactPct * 10000) > + Math.round(pi2) > Math.round(bank.liquidationFee.toNumber() * 10000), }, }; @@ -374,23 +370,14 @@ export async function getPerpPositionsToBeLiquidated( export async function getEquityForMangoAccounts( client: MangoClient, group: Group, - mangoAccounts: PublicKey[], + mangoAccountPks: PublicKey[], + allMangoAccounts: MangoAccount[], ): Promise { - // Filter mango accounts which might be closed - const liqors = ( - await client.connection.getMultipleAccountsInfo(mangoAccounts) - ) - .map((ai, i) => { - return { ai: ai, pk: mangoAccounts[i] }; - }) - .filter((val) => val.ai) - .map((val) => val.pk); - - const liqorMangoAccounts = await Promise.all( - liqors.map((liqor) => client.getMangoAccount(liqor, true)), + const mangoAccounts = allMangoAccounts.filter((a) => + mangoAccountPks.find((pk) => pk.equals(a.publicKey)), ); - const accountsWithEquity = liqorMangoAccounts.map((a: MangoAccount) => { + const accountsWithEquity = mangoAccounts.map((a: MangoAccount) => { return { Account: { val: a.publicKey, highlight: false }, Equity: { @@ -408,6 +395,26 @@ export async function getRiskStats( group: Group, change = 0.4, // simulates 40% price rally and price drop on tokens and markets ): Promise { + let pis; + try { + pis = await ( + await ( + await buildFetch() + )( + `https://api.mngo.cloud/data/v4/risk/listed-tokens-one-week-price-impacts`, + { + mode: 'cors', + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + }, + ) + ).json(); + } catch (error) { + pis = []; + } + // Get known liqors let liqors: PublicKey[]; try { @@ -417,7 +424,13 @@ export async function getRiskStats( await buildFetch() )( `https://api.mngo.cloud/data/v4/stats/liqors-over_period?over_period=1MONTH`, - { mode: 'no-cors' }, + { + mode: 'cors', + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + }, ) ).json() ).map((data) => new PublicKey(data['liqor'])); @@ -435,12 +448,6 @@ export async function getRiskStats( // Get all mango accounts const mangoAccounts = await client.getAllMangoAccounts(group, true); - // const mangoAccounts = [ - // await client.getMangoAccount( - // new PublicKey('5G9XriaoqQy1V4s9RmnbczWAozzbv6h2RuEeAHk4R6Lb'), // https://app.mango.markets/stats?token=SOL - // true, - // ), - // ]; // Get on chain prices const mints = [ @@ -532,14 +539,14 @@ export async function getRiskStats( liqorEquity, marketMakerEquity, ] = await Promise.all([ - getPriceImpactForLiqor(groupDrop, mangoAccounts), - getPriceImpactForLiqor(groupRally, mangoAccounts), - getPriceImpactForLiqor(groupUsdcDepeg, mangoAccounts), - getPriceImpactForLiqor(groupUsdtDepeg, mangoAccounts), + getPriceImpactForLiqor(groupDrop, pis, mangoAccounts), + getPriceImpactForLiqor(groupRally, pis, mangoAccounts), + getPriceImpactForLiqor(groupUsdcDepeg, pis, mangoAccounts), + getPriceImpactForLiqor(groupUsdtDepeg, pis, mangoAccounts), getPerpPositionsToBeLiquidated(groupDrop, mangoAccounts), getPerpPositionsToBeLiquidated(groupRally, mangoAccounts), - getEquityForMangoAccounts(client, group, liqors), - getEquityForMangoAccounts(client, group, mms), + getEquityForMangoAccounts(client, group, liqors, mangoAccounts), + getEquityForMangoAccounts(client, group, mms, mangoAccounts), ]); return { diff --git a/ts/client/src/stats.ts b/ts/client/src/stats.ts index 97bf3ab1a..41c462738 100644 --- a/ts/client/src/stats.ts +++ b/ts/client/src/stats.ts @@ -39,8 +39,12 @@ export async function getLargestPerpPositions( allPps.sort( (a, b) => - b.getNotionalValueUi(group.getPerpMarketByMarketIndex(b.marketIndex)) - - a.getNotionalValueUi(group.getPerpMarketByMarketIndex(a.marketIndex)), + Math.abs( + b.getNotionalValueUi(group.getPerpMarketByMarketIndex(b.marketIndex)), + ) - + Math.abs( + a.getNotionalValueUi(group.getPerpMarketByMarketIndex(a.marketIndex)), + ), ); return allPps.map((pp) => ({ diff --git a/ts/client/src/utils.ts b/ts/client/src/utils.ts index f59fb728a..b5e219806 100644 --- a/ts/client/src/utils.ts +++ b/ts/client/src/utils.ts @@ -9,6 +9,7 @@ import { VersionedTransaction, } from '@solana/web3.js'; import BN from 'bn.js'; +import { Bank } from './accounts/bank'; import { I80F48 } from './numbers/I80F48'; import { ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID } from './utils/spl'; @@ -38,6 +39,22 @@ export function toNative(uiAmount: number, decimals: number): BN { return new BN((uiAmount * Math.pow(10, decimals)).toFixed(0)); } +export function toNativeSellPerBuyTokenPrice( + price: number, + sellBank: Bank, + buyBank: Bank, +): number { + return price * Math.pow(10, sellBank.mintDecimals - buyBank.mintDecimals); +} + +export function toUiSellPerBuyTokenPrice( + price: number, + sellBank: Bank, + buyBank: Bank, +): number { + return toUiDecimals(price, sellBank.mintDecimals - buyBank.mintDecimals); +} + export function toUiDecimals( nativeAmount: BN | I80F48 | number, decimals: number, @@ -66,6 +83,55 @@ export function toUiI80F48(nativeAmount: I80F48, decimals: number): I80F48 { return nativeAmount.div(I80F48.fromNumber(Math.pow(10, decimals))); } +export function roundTo5(number): number { + if (number < 1) { + const numString = number.toString(); + const nonZeroIndex = numString.search(/[1-9]/); + if (nonZeroIndex === -1 || nonZeroIndex >= numString.length - 5) { + return number; + } + return Number(numString.slice(0, nonZeroIndex + 5)); + } else if (number < 10) { + return ( + Math.floor(number) + + Number((number % 1).toString().padEnd(10, '0').slice(0, 6)) + ); + } else if (number < 100) { + return ( + Math.floor(number) + + Number((number % 1).toString().padEnd(10, '0').slice(0, 5)) + ); + } else if (number < 1000) { + return ( + Math.floor(number) + + Number((number % 1).toString().padEnd(10, '0').slice(0, 4)) + ); + } else if (number < 10000) { + return ( + Math.floor(number) + + Number((number % 1).toString().padEnd(10, '0').slice(0, 3)) + ); + } + return Math.round(number); +} + +/// + +export async function buildFetch(): Promise< + ( + input: RequestInfo | URL, + init?: RequestInit | undefined, + ) => Promise +> { + let fetch = globalThis?.fetch; + if (!fetch && process?.versions?.node) { + fetch = (await import('node-fetch')).default; + } + return fetch; +} + +/// + /// /// web3js extensions /// @@ -84,7 +150,7 @@ export function toUiI80F48(nativeAmount: I80F48, decimals: number): I80F48 { export async function getAssociatedTokenAddress( mint: PublicKey, owner: PublicKey, - allowOwnerOffCurve = false, + allowOwnerOffCurve = true, programId = TOKEN_PROGRAM_ID, associatedTokenProgramId = ASSOCIATED_TOKEN_PROGRAM_ID, ): Promise {