Various force close bots (#554)

* wip: force close perp positions

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* wip: force close cancel serum3 orders

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* wip: force close token borrows

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* Fixes from review

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* Fixes from review

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* Fixes from review

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* Fixes from review

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* Fixes from review

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* Fixes from review

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* Fixes from review

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* Fixes from review

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

* Fixes from review

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>

---------

Signed-off-by: microwavedcola1 <microwavedcola@gmail.com>
This commit is contained in:
microwavedcola1 2023-04-25 08:12:42 +02:00 committed by GitHub
parent 1bf1a8deb5
commit 2305a160d0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 1009 additions and 61 deletions

View File

@ -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)

View File

@ -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();
}

View File

@ -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<void> {
const options = AnchorProvider.defaultOptions();
const connection = new Connection(
'https://mango.devnet.rpcpool.com',

View File

@ -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<void> {
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();

View File

@ -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<void> {
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();

View File

@ -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<void> {
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();

View File

@ -45,7 +45,7 @@ async function computePriceImpact(
};
}
async function main() {
async function main(): Promise<void> {
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(

View File

@ -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<string> {
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<TransactionSignature> {
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<string> {
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<string> {
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,

View File

@ -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 };

View File

@ -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": {

410
ts/client/src/router.ts Normal file
View File

@ -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<TokenMintAddress, string>;
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<string, AccountInfo<Buffer> | 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<Routes> => {
{
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<Routes> => {
{
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<Routes> => {
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];
};