From 2305a160d01d5ae2e712a82c4aaa2b544fe6ba9c Mon Sep 17 00:00:00 2001 From: microwavedcola1 <89031858+microwavedcola1@users.noreply.github.com> Date: Tue, 25 Apr 2023 08:12:42 +0200 Subject: [PATCH] Various force close bots (#554) * wip: force close perp positions Signed-off-by: microwavedcola1 * wip: force close cancel serum3 orders Signed-off-by: microwavedcola1 * wip: force close token borrows Signed-off-by: microwavedcola1 * Fixes from review Signed-off-by: microwavedcola1 * Fixes from review Signed-off-by: microwavedcola1 * Fixes from review Signed-off-by: microwavedcola1 * Fixes from review Signed-off-by: microwavedcola1 * Fixes from review Signed-off-by: microwavedcola1 * Fixes from review Signed-off-by: microwavedcola1 * Fixes from review Signed-off-by: microwavedcola1 * Fixes from review Signed-off-by: microwavedcola1 * Fixes from review Signed-off-by: microwavedcola1 --------- Signed-off-by: microwavedcola1 --- release-to-devnet.sh | 4 - ts/client/scripts/archive/devnet-admin.ts | 77 ++-- ts/client/scripts/archive/devnet-user.ts | 18 +- .../scripts/force-close-perp-positions.ts | 105 +++++ .../scripts/force-close-serum3-market.ts | 79 ++++ .../scripts/force-close-token-borrows.ts | 151 +++++++ ts/client/scripts/param-check.ts | 5 +- ts/client/src/client.ts | 154 +++++++ ts/client/src/index.ts | 5 +- ts/client/src/mango_v4.ts | 62 ++- ts/client/src/router.ts | 410 ++++++++++++++++++ 11 files changed, 1009 insertions(+), 61 deletions(-) create mode 100644 ts/client/scripts/force-close-perp-positions.ts create mode 100644 ts/client/scripts/force-close-serum3-market.ts create mode 100644 ts/client/scripts/force-close-token-borrows.ts create mode 100644 ts/client/src/router.ts diff --git a/release-to-devnet.sh b/release-to-devnet.sh index 47b06320b..1a2f63689 100755 --- a/release-to-devnet.sh +++ b/release-to-devnet.sh @@ -23,7 +23,3 @@ solana --url https://mango.devnet.rpcpool.com program deploy --program-id $PROGR # publish idl cargo run -p anchor-cli -- idl upgrade --provider.cluster https://mango.devnet.rpcpool.com --provider.wallet $WALLET_WITH_FUNDS \ --filepath target/idl/mango_v4_no_docs.json $PROGRAM_ID - - -# build npm package -(cd ./ts/client && tsc) diff --git a/ts/client/scripts/archive/devnet-admin.ts b/ts/client/scripts/archive/devnet-admin.ts index 0210b2163..608ea38b1 100644 --- a/ts/client/scripts/archive/devnet-admin.ts +++ b/ts/client/scripts/archive/devnet-admin.ts @@ -23,16 +23,14 @@ import { buildVersionedTx } from '../../src/utils'; // https://github.com/blockworks-foundation/mango-client-v3/blob/main/src/serum.json#L70 const DEVNET_SERUM3_MARKETS = new Map([ - ['SOL/USDC', '82iPEvGiTceyxYpeLK3DhSwga3R5m4Yfyoydd13CukQ9'], + ['SOL/USDC', '6xYbSQyhajUqyatJDdkonpj7v41bKeEBWpf7kwRh5X7A'], ]); const DEVNET_MINTS = new Map([ ['USDC', '8FRFC6MoGGkMFQwngccyu69VnYbzykGeez7ignHVAFSN'], // use devnet usdc ['SOL', 'So11111111111111111111111111111111111111112'], - ['MNGO', 'Bb9bsTQa1bGEtQ5KagGkvSHyuLqDWumFUcRqFusFNJWC'], ]); const DEVNET_ORACLES = new Map([ ['SOL', 'J83w4HKfqxwcq3BEMMkPFSppX3gqekLyLJBexebFVkix'], - ['MNGO', '8k7F9Xb36oFJsjpCKpsXvg4cgBRoZtwNTc3EzG5Ttd2o'], ['BTC', 'HovQMDrbAgAYPCmHVSrezcSmkMtXSSUsLDFANExrZh2J'], ['ETH', 'EdVCmQ9FSPcVe5YySXDPCRmc8aDQLKJ9xvYBMZPie1Vw'], ]); @@ -180,54 +178,32 @@ async function main() { console.log( `...edited group, https://explorer.solana.com/tx/${sig}?cluster=devnet`, ); - console.log(`Registering MNGO...`); - const mngoDevnetMint = new PublicKey(DEVNET_MINTS.get('MNGO')!); - const mngoDevnetOracle = new PublicKey(DEVNET_ORACLES.get('MNGO')!); + + // register serum market + console.log(`Registering serum3 market...`); + const serumMarketExternalPk = new PublicKey( + DEVNET_SERUM3_MARKETS.get('SOL/USDC')!, + ); try { - sig = await client.tokenRegisterTrustless( + sig = await client.serum3RegisterMarket( group, - mngoDevnetMint, - mngoDevnetOracle, - 2, - 'MNGO', + serumMarketExternalPk, + group.getFirstBankByMint(solDevnetMint), + group.getFirstBankByMint(usdcDevnetMint), + 0, + 'SOL/USDC', ); await group.reloadAll(client); - const bank = group.getFirstBankByMint(mngoDevnetMint); + const serum3Market = group.getSerum3MarketByExternalMarket( + serumMarketExternalPk, + ); console.log( - `...registered token bank ${bank.publicKey}, https://explorer.solana.com/tx/${sig}?cluster=devnet`, + `...registered serum market ${serum3Market.publicKey}, https://explorer.solana.com/tx/${sig}?cluster=devnet`, ); } catch (error) { console.log(error); } - // DEBUGGING - // log tokens/banks - // group.consoleLogBanks(); - - // // register serum market - // const serumMarketExternalPk = new PublicKey( - // DEVNET_SERUM3_MARKETS.get('SOL/USDC')!, - // ); - // try { - // sig = await client.serum3RegisterMarket( - // group, - // serumMarketExternalPk, - // group.getFirstBankByMint(solDevnetMint), - // group.getFirstBankByMint(usdcDevnetMint), - // 0, - // 'SOL/USDC', - // ); - // await group.reloadAll(client); - // const serum3Market = group.getSerum3MarketByExternalMarket( - // serumMarketExternalPk, - // ); - // console.log( - // `...registered serum market ${serum3Market.publicKey}, https://explorer.solana.com/tx/${sig}?cluster=devnet`, - // ); - // } catch (error) { - // console.log(error); - // } - // register perp market console.log(`Registering perp market...`); try { @@ -321,6 +297,25 @@ async function main() { } } + // await client.serum3EditMarket(group, 0 as MarketIndex, false, false); + + // const perpMarket = group.getPerpMarketByMarketIndex(0 as PerpMarketIndex); + // const params = Builder(NullPerpEditParams) + // .reduceOnly(true) + // .forceClose(true) + // .build(); + // await client.perpEditMarket(group, 0 as PerpMarketIndex, params); + + // const params = Builder(NullTokenEditParams) + // .reduceOnly(2) + // .forceClose(true) + // .build(); + // await client.tokenEdit( + // group, + // group.banksMapByName.get('SOL')![0].mint, + // params, + // ); + process.exit(); } diff --git a/ts/client/scripts/archive/devnet-user.ts b/ts/client/scripts/archive/devnet-user.ts index a175e6076..51ff96869 100644 --- a/ts/client/scripts/archive/devnet-user.ts +++ b/ts/client/scripts/archive/devnet-user.ts @@ -4,7 +4,16 @@ import { expect } from 'chai'; import fs from 'fs'; import { Group } from '../../src/accounts/group'; import { HealthType } from '../../src/accounts/mangoAccount'; -import { PerpOrderSide, PerpOrderType } from '../../src/accounts/perp'; +import { + PerpMarketIndex, + PerpOrderSide, + PerpOrderType, +} from '../../src/accounts/perp'; +import { + Serum3OrderType, + Serum3SelfTradeBehavior, + Serum3Side, +} from '../../src/accounts/serum3'; import { MangoClient } from '../../src/client'; import { MANGO_V4_ID } from '../../src/constants'; import { toUiDecimalsForQuote } from '../../src/utils'; @@ -22,17 +31,14 @@ const DEVNET_MINTS = new Map([ ['USDC', '8FRFC6MoGGkMFQwngccyu69VnYbzykGeez7ignHVAFSN'], // use devnet usdc ['BTC', '3UNBZ6o52WTWwjac2kPUb4FyodhU1vFkRJheu1Sh2TvU'], ['SOL', 'So11111111111111111111111111111111111111112'], - ['ORCA', 'orcarKHSqC5CDDsGbho8GKvwExejWHxTqGzXgcewB9L'], - ['MNGO', 'Bb9bsTQa1bGEtQ5KagGkvSHyuLqDWumFUcRqFusFNJWC'], ]); export const DEVNET_SERUM3_MARKETS = new Map([ - ['BTC/USDC', new PublicKey('DW83EpHFywBxCHmyARxwj3nzxJd7MUdSeznmrdzZKNZB')], - ['SOL/USDC', new PublicKey('5xWpt56U1NCuHoAEtpLeUrQcxDkEpNfScjfLFaRzLPgR')], + ['SOL/USDC', new PublicKey('6xYbSQyhajUqyatJDdkonpj7v41bKeEBWpf7kwRh5X7A')], ]); const GROUP_NUM = Number(process.env.GROUP_NUM || 0); -async function main() { +async function main(): Promise { const options = AnchorProvider.defaultOptions(); const connection = new Connection( 'https://mango.devnet.rpcpool.com', diff --git a/ts/client/scripts/force-close-perp-positions.ts b/ts/client/scripts/force-close-perp-positions.ts new file mode 100644 index 000000000..514d64154 --- /dev/null +++ b/ts/client/scripts/force-close-perp-positions.ts @@ -0,0 +1,105 @@ +import { AnchorProvider, Wallet } from '@coral-xyz/anchor'; +import { Cluster, Connection, Keypair, PublicKey } from '@solana/web3.js'; +import fs from 'fs'; +import { MangoAccount } from '../src/accounts/mangoAccount'; +import { PerpMarketIndex } from '../src/accounts/perp'; +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 PERP_MARKET_INDEX = Number( + process.env.PERP_MARKET_INDEX, +) as PerpMarketIndex; + +async function forceClosePerpPositions(): 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 pm = group.getPerpMarketByMarketIndex(PERP_MARKET_INDEX); + if (!pm.reduceOnly) { + throw new Error(`Unexpected reduce only state ${pm.reduceOnly}`); + } + if (!pm.forceClose) { + throw new Error(`Unexpected force close state ${pm.forceClose}`); + } + + // Get all mango accounts who have a position in the given market + const mangoAccounts = (await client.getAllMangoAccounts(group)).filter( + (a) => + a.getPerpPosition(PERP_MARKET_INDEX) !== undefined && + a.getPerpPositionUi(group, PERP_MARKET_INDEX) !== 0, + ); + // Sort descending + mangoAccounts.sort( + (a, b) => + b.getPerpPositionUi(group, PERP_MARKET_INDEX) - + a.getPerpPositionUi(group, PERP_MARKET_INDEX), + ); + + let a: MangoAccount; + let b: MangoAccount; + let i = 0, + j = mangoAccounts.length - 1; + + // i iterates forward to 2nd last account, and b iterates backward till 2nd account + while (i < mangoAccounts.length - 1 && j > 0) { + if (i === j) { + break; + } + a = mangoAccounts[i]; + b = mangoAccounts[j]; + // PerpForceClosePosition ix expects a to be long, and b to short + const sig = await client.perpForceClosePosition( + group, + PERP_MARKET_INDEX, + a, + b, + ); + console.log( + `PerpForceClosePosition ${a.publicKey} and ${ + b.publicKey + } , sig https://explorer.solana.com/tx/${sig}?cluster=${ + CLUSTER == 'devnet' ? 'devnet' : '' + }`, + ); + a = await a.reload(client); + b = await b.reload(client); + // Move to previous account once b's position is completely reduced + if (b.getPerpPositionUi(group, PERP_MARKET_INDEX) === 0) { + console.log(`Fully reduced position for ${b.publicKey}`); + j--; + } + // Move to next account once a's position is completely reduced + if (a.getPerpPositionUi(group, PERP_MARKET_INDEX) === 0) { + console.log(`Fully reduced position for ${a.publicKey}`); + i++; + } + } +} + +forceClosePerpPositions(); diff --git a/ts/client/scripts/force-close-serum3-market.ts b/ts/client/scripts/force-close-serum3-market.ts new file mode 100644 index 000000000..7cd4a8221 --- /dev/null +++ b/ts/client/scripts/force-close-serum3-market.ts @@ -0,0 +1,79 @@ +import { AnchorProvider, Wallet } from '@coral-xyz/anchor'; +import { Cluster, Connection, Keypair, PublicKey } from '@solana/web3.js'; +import fs from 'fs'; +import range from 'lodash/range'; +import { MarketIndex } from '../src/accounts/serum3'; +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 MARKET_INDEX = Number(process.env.MARKET_INDEX) as MarketIndex; + +async function forceCloseSerum3Market(): 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 serum3Market = group.serum3MarketsMapByMarketIndex.get(MARKET_INDEX)!; + if (!serum3Market.reduceOnly) { + throw new Error(`Unexpected reduce only state ${serum3Market.reduceOnly}`); + } + if (!serum3Market.forceClose) { + throw new Error(`Unexpected force close state ${serum3Market.forceClose}`); + } + + // Get all mango accounts who have a serum oo account for the given market + const mangoAccounts = (await client.getAllMangoAccounts(group, true)).filter( + (a) => a.serum3OosMapByMarketIndex.get(MARKET_INDEX) !== undefined, + ); + + for (let a of mangoAccounts) { + // Cancel all orders and confirm that all have been cancelled + for (const _ of range(0, 10)) { + console.log(a.getSerum3OoAccount(MARKET_INDEX).freeSlotBits.zeroBits()); + const sig = await client.serum3LiqForceCancelOrders( + group, + a, + serum3Market.serumMarketExternal, + 10, + ); + console.log( + ` serum3LiqForceCancelOrders for ${ + a.publicKey + }, sig https://explorer.solana.com/tx/${sig}?cluster=${ + CLUSTER == 'devnet' ? 'devnet' : '' + }`, + ); + a = await a.reload(client); + if (a.getSerum3OoAccount(MARKET_INDEX).freeSlotBits.zeroBits() === 0) { + break; + } + } + } +} + +forceCloseSerum3Market(); diff --git a/ts/client/scripts/force-close-token-borrows.ts b/ts/client/scripts/force-close-token-borrows.ts new file mode 100644 index 000000000..bdbec4ba1 --- /dev/null +++ b/ts/client/scripts/force-close-token-borrows.ts @@ -0,0 +1,151 @@ +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'; +import { + fetchJupiterTransaction, + fetchRoutes, + prepareMangoRouterInstructions, +} from '../src/router'; +import { toNative, toUiDecimals } from '../src/utils'; + +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 MANGO_ACCOUNT_PK = process.env.MANGO_ACCOUNT_PK; +const TOKEN_INDEX = Number(process.env.TOKEN_INDEX) as TokenIndex; +const MAX_LIAB_TRANSFER = Number(process.env.MAX_LIAB_TRANSFER); + +async function forceCloseTokenBorrows(): 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', + }, + ); + + let liqor = await client.getMangoAccount(new PublicKey(MANGO_ACCOUNT_PK!)); + const group = await client.getGroup(liqor.group); + const forceCloseTokenBank = group.getFirstBankByTokenIndex(TOKEN_INDEX); + if (forceCloseTokenBank.reduceOnly != 2) { + throw new Error( + `Unexpected reduce only state ${forceCloseTokenBank.reduceOnly}`, + ); + } + if (!forceCloseTokenBank.forceClose) { + throw new Error( + `Unexpected force close state ${forceCloseTokenBank.forceClose}`, + ); + } + + const usdcBank = group.getFirstBankByTokenIndex(0 as TokenIndex); + // Get all mango accounts with borrows for given token + const mangoAccountsWithBorrows = ( + await client.getAllMangoAccounts(group) + ).filter((a) => a.getTokenBalanceUi(forceCloseTokenBank) < 0); + + console.log(`${liqor.toString(group, true)}`); + + for (const liqee of mangoAccountsWithBorrows) { + liqor = await liqor.reload(client); + // Liqor can only liquidate borrow using deposits, since borrows are in reduce only + // Swap usdc worth token borrow (sub existing position), account for slippage using liquidation fee + // MAX_LIAB_TRANSFER guards against trying to swap to a very large amount + const amount = + Math.min( + liqee.getTokenBorrowsUi(forceCloseTokenBank) - + liqor.getTokenBalanceUi(forceCloseTokenBank), + MAX_LIAB_TRANSFER, + ) * + forceCloseTokenBank.uiPrice * + (1 + forceCloseTokenBank.liquidationFee.toNumber()); + + console.log( + `liqor balance ${liqor.getTokenBalanceUi( + forceCloseTokenBank, + )}, liqee balance ${liqee.getTokenBalanceUi( + forceCloseTokenBank, + )}, liqor will swap further amount of $${toUiDecimals( + amount, + usdcBank.mintDecimals, + )} to ${forceCloseTokenBank.name}`, + ); + + const amountBn = toNative( + Math.min(amount, 99999999999), // Jupiter API can't handle amounts larger than 99999999999 + usdcBank.mintDecimals, + ); + const { bestRoute } = await fetchRoutes( + usdcBank.mint, + forceCloseTokenBank.mint, + amountBn.toString(), + forceCloseTokenBank.liquidationFee.toNumber() * 100, + 'ExactIn', + '0', + liqor.owner, + ); + if (!bestRoute) { + await new Promise((r) => setTimeout(r, 500)); + continue; + } + const [ixs, alts] = + bestRoute.routerName === 'Mango' + ? await prepareMangoRouterInstructions( + bestRoute, + usdcBank.mint, + forceCloseTokenBank.mint, + user.publicKey, + ) + : await fetchJupiterTransaction( + client.connection, + bestRoute, + user.publicKey, + 0, + usdcBank.mint, + forceCloseTokenBank.mint, + ); + const sig = await client.marginTrade({ + group: group, + mangoAccount: liqor, + inputMintPk: usdcBank.mint, + amountIn: amount, + outputMintPk: forceCloseTokenBank.mint, + userDefinedInstructions: ixs, + userDefinedAlts: alts, + flashLoanType: { swap: {} }, + }); + console.log( + ` - marginTrade, sig https://explorer.solana.com/tx/${sig}?cluster=${ + CLUSTER == 'devnet' ? 'devnet' : '' + }`, + ); + + await client.tokenForceCloseBorrowsWithToken( + group, + liqor, + liqee, + usdcBank.tokenIndex, + forceCloseTokenBank.tokenIndex, + ); + } +} + +forceCloseTokenBorrows(); diff --git a/ts/client/scripts/param-check.ts b/ts/client/scripts/param-check.ts index a457f502f..cafd73f04 100644 --- a/ts/client/scripts/param-check.ts +++ b/ts/client/scripts/param-check.ts @@ -45,7 +45,7 @@ async function computePriceImpact( }; } -async function main() { +async function main(): Promise { const client = await buildClient(); const group = await client.getGroup(new PublicKey(GROUP_PK)); await group.reloadAll(client); @@ -57,9 +57,6 @@ async function main() { ); for (const bank of Array.from(group.banksMapByMint.values())) { - if (bank[0].name === 'USDC' || bank[0].reduceOnly === true) { - continue; - } const usdcMint = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; const pi1 = await computePriceImpact( diff --git a/ts/client/src/client.ts b/ts/client/src/client.ts index de11c5ffb..320bf5fd2 100644 --- a/ts/client/src/client.ts +++ b/ts/client/src/client.ts @@ -420,6 +420,54 @@ export class MangoClient { return await this.sendAndConfirmTransactionForGroup(group, [ix]); } + public async tokenForceCloseBorrowsWithToken( + group: Group, + liqor: MangoAccount, + liqee: MangoAccount, + assetTokenIndex: TokenIndex, + liabTokenIndex: TokenIndex, + maxLiabTransfer?: number, + ): Promise { + const assetBank = group.getFirstBankByTokenIndex(assetTokenIndex); + const liabBank = group.getFirstBankByTokenIndex(liabTokenIndex); + const healthRemainingAccounts: PublicKey[] = + this.buildHealthRemainingAccounts( + AccountRetriever.Scanning, + group, + [liqor, liqee], + [assetBank, liabBank], + [], + ); + const parsedHealthAccounts = healthRemainingAccounts.map( + (pk) => + ({ + pubkey: pk, + isWritable: + pk.equals(assetBank.publicKey) || pk.equals(liabBank.publicKey) + ? true + : false, + isSigner: false, + } as AccountMeta), + ); + const ix = await this.program.methods + .tokenForceCloseBorrowsWithToken( + assetTokenIndex, + liabTokenIndex, + maxLiabTransfer + ? toNative(maxLiabTransfer, liabBank.mintDecimals) + : U64_MAX_BN, + ) + .accounts({ + group: group.publicKey, + liqor: liqor.publicKey, + liqorOwner: (this.program.provider as AnchorProvider).wallet.publicKey, + liqee: liqee.publicKey, + }) + .remainingAccounts(parsedHealthAccounts) + .instruction(); + return await this.sendAndConfirmTransactionForGroup(group, [ix]); + } + public async tokenDeregister( group: Group, mintPk: PublicKey, @@ -1247,6 +1295,25 @@ export class MangoClient { return await this.sendAndConfirmTransactionForGroup(group, [ix]); } + public async serum3EditMarket( + group: Group, + serum3MarketIndex: MarketIndex, + reduceOnly: boolean | null, + forceClose: boolean | null, + ): Promise { + const serum3Market = + group.serum3MarketsMapByMarketIndex.get(serum3MarketIndex); + const ix = await this.program.methods + .serum3EditMarket(reduceOnly, forceClose) + .accounts({ + group: group.publicKey, + admin: (this.program.provider as AnchorProvider).wallet.publicKey, + market: serum3Market?.publicKey, + }) + .instruction(); + return await this.sendAndConfirmTransactionForGroup(group, [ix]); + } + public async serum3deregisterMarket( group: Group, externalMarketPk: PublicKey, @@ -1416,6 +1483,72 @@ export class MangoClient { ); } + public async serum3LiqForceCancelOrders( + group: Group, + mangoAccount: MangoAccount, + externalMarketPk: PublicKey, + limit?: number, + ): Promise { + const serum3Market = group.serum3MarketsMapByExternal.get( + externalMarketPk.toBase58(), + )!; + const serum3MarketExternal = group.serum3ExternalMarketsMap.get( + externalMarketPk.toBase58(), + )!; + const openOrders = await serum3Market.findOoPda( + this.programId, + mangoAccount.publicKey, + ); + + const healthRemainingAccounts: PublicKey[] = + this.buildHealthRemainingAccounts( + AccountRetriever.Fixed, + group, + [mangoAccount], + [], + [], + [[serum3Market, openOrders]], + ); + + const ix = await this.program.methods + .serum3LiqForceCancelOrders(limit ?? 10) + .accounts({ + group: group.publicKey, + account: mangoAccount.publicKey, + openOrders, + serumMarket: serum3Market.publicKey, + serumProgram: OPENBOOK_PROGRAM_ID[this.cluster], + serumMarketExternal: serum3Market.serumMarketExternal, + marketBids: serum3MarketExternal.bidsAddress, + marketAsks: serum3MarketExternal.asksAddress, + marketEventQueue: serum3MarketExternal.decoded.eventQueue, + marketBaseVault: serum3MarketExternal.decoded.baseVault, + marketQuoteVault: serum3MarketExternal.decoded.quoteVault, + marketVaultSigner: await generateSerum3MarketExternalVaultSignerAddress( + this.cluster, + serum3Market, + serum3MarketExternal, + ), + quoteBank: group.getFirstBankByTokenIndex(serum3Market.quoteTokenIndex) + .publicKey, + quoteVault: group.getFirstBankByTokenIndex(serum3Market.quoteTokenIndex) + .vault, + baseBank: group.getFirstBankByTokenIndex(serum3Market.baseTokenIndex) + .publicKey, + baseVault: group.getFirstBankByTokenIndex(serum3Market.baseTokenIndex) + .vault, + }) + .remainingAccounts( + healthRemainingAccounts.map( + (pk) => + ({ pubkey: pk, isWritable: false, isSigner: false } as AccountMeta), + ), + ) + .instruction(); + + return await this.sendAndConfirmTransactionForGroup(group, [ix]); + } + public async serum3PlaceOrderIx( group: Group, mangoAccount: MangoAccount, @@ -1992,6 +2125,27 @@ export class MangoClient { return await this.sendAndConfirmTransactionForGroup(group, [ix]); } + public async perpForceClosePosition( + group: Group, + perpMarketIndex: PerpMarketIndex, + accountA: MangoAccount, + accountB: MangoAccount, + ): Promise { + const perpMarket = group.getPerpMarketByMarketIndex(perpMarketIndex); + + const ix = await this.program.methods + .perpForceClosePosition() + .accounts({ + group: group.publicKey, + perpMarket: perpMarket.publicKey, + accountA: accountA.publicKey, + accountB: accountB.publicKey, + oracle: perpMarket.oracle, + }) + .instruction(); + return await this.sendAndConfirmTransactionForGroup(group, [ix]); + } + public async perpCloseMarket( group: Group, perpMarketIndex: PerpMarketIndex, diff --git a/ts/client/src/index.ts b/ts/client/src/index.ts index 5b8994b84..4edf8816e 100644 --- a/ts/client/src/index.ts +++ b/ts/client/src/index.ts @@ -20,6 +20,7 @@ export { } from './clientIxParamBuilder'; export * from './constants'; export * from './numbers/I80F48'; -export * from './utils'; +export * from './router'; export * from './types'; -export { Group, OracleProvider, StubOracle, MangoClient, MANGO_V4_ID }; +export * from './utils'; +export { Group, MANGO_V4_ID, MangoClient, OracleProvider, StubOracle }; diff --git a/ts/client/src/mango_v4.ts b/ts/client/src/mango_v4.ts index 136bc8ef4..7ae82d007 100644 --- a/ts/client/src/mango_v4.ts +++ b/ts/client/src/mango_v4.ts @@ -1,5 +1,5 @@ export type MangoV4 = { - "version": "0.13.0", + "version": "0.14.0", "name": "mango_v4", "instructions": [ { @@ -1743,6 +1743,12 @@ export type MangoV4 = { "type": { "option": "bool" } + }, + { + "name": "forceCloseOpt", + "type": { + "option": "bool" + } } ] }, @@ -5152,12 +5158,16 @@ export type MangoV4 = { "name": "reduceOnly", "type": "u8" }, + { + "name": "forceClose", + "type": "u8" + }, { "name": "padding1", "type": { "array": [ "u8", - 3 + 2 ] } }, @@ -7113,6 +7123,23 @@ export type MangoV4 = { ] } }, + { + "name": "CheckLiquidatable", + "type": { + "kind": "enum", + "variants": [ + { + "name": "NotLiquidatable" + }, + { + "name": "Liquidatable" + }, + { + "name": "BecameNotLiquidatable" + } + ] + } + }, { "name": "OracleType", "type": { @@ -8847,7 +8874,7 @@ export type MangoV4 = { }; export const IDL: MangoV4 = { - "version": "0.13.0", + "version": "0.14.0", "name": "mango_v4", "instructions": [ { @@ -10591,6 +10618,12 @@ export const IDL: MangoV4 = { "type": { "option": "bool" } + }, + { + "name": "forceCloseOpt", + "type": { + "option": "bool" + } } ] }, @@ -14000,12 +14033,16 @@ export const IDL: MangoV4 = { "name": "reduceOnly", "type": "u8" }, + { + "name": "forceClose", + "type": "u8" + }, { "name": "padding1", "type": { "array": [ "u8", - 3 + 2 ] } }, @@ -15961,6 +15998,23 @@ export const IDL: MangoV4 = { ] } }, + { + "name": "CheckLiquidatable", + "type": { + "kind": "enum", + "variants": [ + { + "name": "NotLiquidatable" + }, + { + "name": "Liquidatable" + }, + { + "name": "BecameNotLiquidatable" + } + ] + } + }, { "name": "OracleType", "type": { diff --git a/ts/client/src/router.ts b/ts/client/src/router.ts new file mode 100644 index 000000000..fc925da86 --- /dev/null +++ b/ts/client/src/router.ts @@ -0,0 +1,410 @@ +import { + AccountInfo, + AddressLookupTableAccount, + Connection, + PublicKey, + TransactionInstruction, + TransactionMessage, + VersionedTransaction, +} from '@solana/web3.js'; +import fetch from 'node-fetch'; +import { createAssociatedTokenAccountIdempotentInstruction } from './utils'; + +export const MANGO_ROUTER_API_URL = 'https://api.mngo.cloud/router/v1'; + +export interface QuoteParams { + sourceMint: string; + destinationMint: string; + amount: number; + swapMode: SwapMode; +} +export declare type TokenMintAddress = string; +export interface Quote { + notEnoughLiquidity: boolean; + minInAmount?: number; + minOutAmount?: number; + inAmount: number; + outAmount: number; + feeAmount: number; + feeMint: TokenMintAddress; + feePct: number; + priceImpactPct: number; +} +export declare type QuoteMintToReferrer = Map; +export interface SwapParams { + sourceMint: string; + destinationMint: string; + userSourceTokenAccount: string; + userDestinationTokenAccount: string; + userTransferAuthority: string; + /** + * amount is used for instruction and can be null when it is an intermediate swap, only the first swap has an amount + */ + amount: number; + swapMode: SwapMode; + openOrdersAddress?: string; + quoteMintToReferrer?: QuoteMintToReferrer; +} +export declare type PlatformFee = { + feeBps: number; + feeAccount: string; +}; +export interface ExactOutSwapParams extends SwapParams { + inAmount: number; + slippageBps: number; + platformFee?: PlatformFee; + overflowFeeAccount?: string; +} +export declare type AccountInfoMap = Map | null>; + +export declare type AmmLabel = + | 'Aldrin' + | 'Crema' + | 'Cropper' + | 'Cykura' + | 'DeltaFi' + | 'GooseFX' + | 'Invariant' + | 'Lifinity' + | 'Lifinity V2' + | 'Marinade' + | 'Mercurial' + | 'Meteora' + | 'Raydium' + | 'Raydium CLMM' + | 'Saber' + | 'Serum' + | 'Orca' + | 'Step' + | 'Penguin' + | 'Saros' + | 'Stepn' + | 'Orca (Whirlpools)' + | 'Sencha' + | 'Saber (Decimals)' + | 'Dradex' + | 'Balansol' + | 'Openbook' + | 'Unknown'; + +export interface TransactionFeeInfo { + signatureFee: number; + openOrdersDeposits: number[]; + ataDeposits: number[]; + totalFeeAndDeposits: number; + minimumSOLForTransaction: number; +} + +export declare enum SwapMode { + ExactIn = 'ExactIn', + ExactOut = 'ExactOut', +} + +export interface Fee { + amount: number; + mint: string; + pct: number; +} +export interface MarketInfo { + id: string; + inAmount: number; + inputMint: string; + label: string; + lpFee: Fee; + notEnoughLiquidity: boolean; + outAmount: number; + outputMint: string; + platformFee: Fee; + priceImpactPct: number; +} + +export interface RouteInfo { + amount: number; + inAmount: number; + marketInfos: MarketInfo[]; + otherAmountThreshold: number; + outAmount: number; + priceImpactPct: number; + slippageBps: number; + swapMode: SwapMode; + instructions?: TransactionInstruction[]; + mints?: PublicKey[]; + routerName?: 'Mango'; +} + +export type Routes = { + routes: RouteInfo[]; + bestRoute: RouteInfo | null; +}; + +export type Token = { + address: string; + chainId: number; + decimals: number; + name: string; + symbol: string; + logoURI: string; + extensions: { + coingeckoId?: string; + }; + tags: string[]; +}; + +const fetchJupiterRoutes = async ( + inputMint, + outputMint, + amount = '0', + slippage = 50, + swapMode = 'ExactIn', + feeBps = '0', +): Promise => { + { + const paramsString = new URLSearchParams({ + inputMint: inputMint.toString(), + outputMint: outputMint.toString(), + amount: amount.toString(), + slippageBps: Math.ceil(slippage * 100).toString(), + feeBps: feeBps.toString(), + swapMode, + }).toString(); + + const response = await fetch( + `https://quote-api.jup.ag/v4/quote?${paramsString}`, + ); + + const res = await response.json(); + const data = res.data; + + return { + routes: res.data as RouteInfo[], + bestRoute: (data.length ? data[0] : null) as RouteInfo | null, + }; + } +}; + +const fetchMangoRoutes = async ( + inputMint, + outputMint, + amount = '0', + slippage = 50, + swapMode = 'ExactIn', + feeBps = '0', + wallet = PublicKey.default, +): Promise => { + { + const defaultOtherAmount = + swapMode === 'ExactIn' ? 0 : Number.MAX_SAFE_INTEGER; + + const paramsString = new URLSearchParams({ + inputMint: inputMint.toString(), + outputMint: outputMint.toString(), + amount: amount.toString(), + slippage: ((slippage * 1) / 100).toString(), + feeBps: feeBps.toString(), + mode: swapMode, + wallet: wallet.toString(), + otherAmountThreshold: defaultOtherAmount.toString(), + }).toString(); + + const response = await fetch( + `${MANGO_ROUTER_API_URL}/swap?${paramsString}`, + ); + + const res = await response.json(); + const data: RouteInfo[] = res.map((route: any) => ({ + ...route, + priceImpactPct: route.priceImpact, + slippageBps: slippage, + marketInfos: route.marketInfos.map((mInfo: any) => ({ + ...mInfo, + lpFee: { + ...mInfo.fee, + pct: mInfo.fee.rate, + }, + })), + mints: route.mints.map((x: string) => new PublicKey(x)), + instructions: route.instructions.map((ix: any) => ({ + ...ix, + programId: new PublicKey(ix.programId), + data: Buffer.from(ix.data, 'base64'), + keys: ix.keys.map((key: any) => ({ + ...key, + pubkey: new PublicKey(key.pubkey), + })), + })), + routerName: 'Mango', + })); + return { + routes: data, + bestRoute: (data.length ? data[0] : null) as RouteInfo | null, + }; + } +}; + +export const fetchRoutes = async ( + inputMint, + outputMint, + amount = '0', + slippage = 50, + swapMode = 'ExactIn', + feeBps = '0', + wallet = PublicKey.default, +): Promise => { + try { + const responses = await Promise.allSettled([ + fetchMangoRoutes( + inputMint, + outputMint, + amount, + slippage, + swapMode, + feeBps, + wallet, + ), + fetchJupiterRoutes( + inputMint, + outputMint, + amount, + slippage, + swapMode, + feeBps, + ), + ]); + + const routes: RouteInfo[] = responses + .filter((x) => x.status === 'fulfilled' && x.value.bestRoute !== null) + .map((x) => (x as any).value.routes) + .flat(); + + const sortedBestQuoteFirst = routes.sort( + (a, b) => + swapMode == 'ExactIn' + ? Number(b.outAmount) - Number(a.outAmount) // biggest out + : Number(a.inAmount) - Number(b.inAmount), // smallest in + ); + + return { + routes: sortedBestQuoteFirst, + bestRoute: sortedBestQuoteFirst[0], + }; + } catch (e) { + return { + routes: [], + bestRoute: null, + }; + } +}; + +export const prepareMangoRouterInstructions = async ( + selectedRoute: RouteInfo, + inputMint: PublicKey, + outputMint: PublicKey, + userPublicKey: PublicKey, +): Promise<[TransactionInstruction[], AddressLookupTableAccount[]]> => { + if (!selectedRoute || !selectedRoute.mints || !selectedRoute.instructions) { + return [[], []]; + } + const mintsToFilterOut = [inputMint, outputMint]; + const filteredOutMints = [ + ...selectedRoute.mints.filter( + (routeMint) => + !mintsToFilterOut.find((filterOutMint) => + filterOutMint.equals(routeMint), + ), + ), + ]; + const additionalInstructions: TransactionInstruction[] = []; + for (const mint of filteredOutMints) { + const ix = await createAssociatedTokenAccountIdempotentInstruction( + userPublicKey, + userPublicKey, + mint, + ); + additionalInstructions.push(ix); + } + const instructions = [ + ...additionalInstructions, + ...selectedRoute.instructions, + ]; + return [instructions, []]; +}; + +const deserializeJupiterIxAndAlt = async ( + connection: Connection, + swapTransaction: string, +): Promise<[TransactionInstruction[], AddressLookupTableAccount[]]> => { + const parsedSwapTransaction = VersionedTransaction.deserialize( + Buffer.from(swapTransaction, 'base64'), + ); + const message = parsedSwapTransaction.message; + // const lookups = message.addressTableLookups + const addressLookupTablesResponses = await Promise.all( + message.addressTableLookups.map((alt) => + connection.getAddressLookupTable(alt.accountKey), + ), + ); + const addressLookupTables: AddressLookupTableAccount[] = + addressLookupTablesResponses + .map((alt) => alt.value) + .filter((x): x is AddressLookupTableAccount => x !== null); + + const decompiledMessage = TransactionMessage.decompile(message, { + addressLookupTableAccounts: addressLookupTables, + }); + + return [decompiledMessage.instructions, addressLookupTables]; +}; + +export const fetchJupiterTransaction = async ( + connection: Connection, + selectedRoute: RouteInfo, + userPublicKey: PublicKey, + slippage: number, + inputMint: PublicKey, + outputMint: PublicKey, +): Promise<[TransactionInstruction[], AddressLookupTableAccount[]]> => { + const transactions = await ( + await fetch('https://quote-api.jup.ag/v4/swap', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + // route from /quote api + route: selectedRoute, + // user public key to be used for the swap + userPublicKey, + // feeAccount is optional. Use if you want to charge a fee. feeBps must have been passed in /quote API. + // This is the ATA account for the output token where the fee will be sent to. If you are swapping from SOL->USDC then this would be the USDC ATA you want to collect the fee. + // feeAccount: 'fee_account_public_key', + slippageBps: Math.ceil(slippage * 100), + }), + }) + ).json(); + + const { swapTransaction } = transactions; + + const [ixs, alts] = await deserializeJupiterIxAndAlt( + connection, + swapTransaction, + ); + + const isSetupIx = (pk: PublicKey): boolean => + pk.toString() === 'ComputeBudget111111111111111111111111111111' || + pk.toString() === 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; + + const isDuplicateAta = (ix: TransactionInstruction): boolean => { + return ( + ix.programId.toString() === + 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL' && + (ix.keys[3].pubkey.toString() === inputMint.toString() || + ix.keys[3].pubkey.toString() === outputMint.toString()) + ); + }; + + const filtered_jup_ixs = ixs + .filter((ix) => !isSetupIx(ix.programId)) + .filter((ix) => !isDuplicateAta(ix)); + + return [filtered_jup_ixs, alts]; +};