import { findLargestTokenAccountForOwner, getMultipleAccounts, IDS, MangoClient, MangoGroup, MarginAccount, nativeToUi, NUM_MARKETS, NUM_TOKENS, parseTokenAccount, parseTokenAccountData, tokenToDecimals, } from '@blockworks-foundation/mango-client'; import { Account, Commitment, Connection, PublicKey, Transaction, } from '@solana/web3.js'; import { homedir } from 'os'; import fs from 'fs'; import { notify, sleep } from './utils'; import { Market, OpenOrders } from '@project-serum/serum'; import { makeForceCancelOrdersInstruction, makePartialLiquidateInstruction, } from '@blockworks-foundation/mango-client/lib/instruction'; import BN = require('bn.js'); /* After a liquidation, the amounts in each wallet become unbalanced Make sure to sell or buy quantities different from the target on base currencies Convert excess into quote currency */ async function balanceWallets( connection: Connection, mangoGroup: MangoGroup, prices: number[], markets: Market[], liqor: Account, liqorWallets: PublicKey[], liqorValuesUi: number[], liqorOpenOrdersKeys: PublicKey[], targets: number[], ) { // Retrieve orders from order book by owner const liqorOrders = await Promise.all( markets.map((m) => m.loadOrdersForOwner(connection, liqor.publicKey)), ); // Cancel all const cancelTransactions: Promise[] = []; for (let i = 0; i < NUM_MARKETS; i++) { for (let order of liqorOrders[i]) { console.log( `Cancelling liqor order on market ${i} size=${order.size} price=${order.price}`, ); cancelTransactions.push(markets[i].cancelOrder(connection, liqor, order)); } } await Promise.all(cancelTransactions); // Load open orders accounts const liqorOpenOrders = await Promise.all( liqorOpenOrdersKeys.map((pk) => OpenOrders.load(connection, pk, mangoGroup.dexProgramId), ), ); // Settle all const settleTransactions: Promise[] = []; for (let i = 0; i < NUM_MARKETS; i++) { const oo = liqorOpenOrders[i]; if ( parseFloat(oo.quoteTokenTotal.toString()) > 0 || parseFloat(oo.baseTokenTotal.toString()) > 0 ) { console.log( `Settling funds to liqor wallet ${i} quote:${oo.quoteTokenTotal.toString()} base:${oo.baseTokenTotal.toString()}`, ); settleTransactions.push( markets[i].settleFunds( connection, liqor, oo, liqorWallets[i], liqorWallets[NUM_TOKENS - 1], ), ); } } await Promise.all(settleTransactions); // Account wallets should instantly update const liqorWalletAccounts = await getMultipleAccounts( connection, liqorWallets, 'processed' as Commitment, ); liqorValuesUi = liqorWalletAccounts.map((a, i) => nativeToUi( parseTokenAccountData(a.accountInfo.data).amount, mangoGroup.mintDecimals[i], ), ); const diffs: number[] = []; const netValues: [number, number][] = []; // Go to each base currency and see if it's above or below target for (let i = 0; i < NUM_TOKENS - 1; i++) { const diff = liqorValuesUi[i] - targets[i]; diffs.push(diff); netValues.push([i, diff * prices[i]]); } // Sort in decreasing order so you sell first then buy netValues.sort((a, b) => b[1] - a[1]); for (let i = 0; i < NUM_TOKENS - 1; i++) { const marketIndex = netValues[i][0]; const market = markets[marketIndex]; const tokenDecimals = mangoGroup.getTokenDecimals(marketIndex); const tokenDecimalAdj = Math.pow(10, tokenDecimals); if (netValues[i][1] > 0) { // sell to close const price = prices[marketIndex] * 0.95; const size = Math.floor(diffs[marketIndex] * tokenDecimalAdj) / tokenDecimalAdj; // round down the size if (size === 0) { continue; } console.log(`Sell to close ${marketIndex} ${size} @ ${price}`); let txid = await market.placeOrder(connection, { owner: liqor, payer: liqorWallets[marketIndex], side: 'sell', price, size, orderType: 'ioc', openOrdersAddressKey: liqorOpenOrdersKeys[marketIndex], feeDiscountPubkey: null, // TODO find liqor's SRM fee account }); // TODO add a SettleFunds instruction to this transaction console.log(`Place order successful: ${txid}; Settling funds`); await market.settleFunds( connection, liqor, liqorOpenOrders[marketIndex], liqorWallets[marketIndex], liqorWallets[NUM_TOKENS - 1], ); } else if (netValues[i][1] < 0) { // buy to close const price = prices[marketIndex] * 1.05; // buy at up to 5% higher than oracle price const size = Math.ceil(-diffs[marketIndex] * tokenDecimalAdj) / tokenDecimalAdj; console.log(`Buy to close ${marketIndex} ${size} @ ${price}`); let txid = await market.placeOrder(connection, { owner: liqor, payer: liqorWallets[NUM_TOKENS - 1], side: 'buy', price, size, orderType: 'ioc', openOrdersAddressKey: liqorOpenOrdersKeys[marketIndex], feeDiscountPubkey: null, }); console.log(`Place order successful: ${txid}; Settling funds`); await market.settleFunds( connection, liqor, liqorOpenOrders[marketIndex], liqorWallets[marketIndex], liqorWallets[NUM_TOKENS - 1], ); } } } async function runPartialLiquidator() { const client = new MangoClient(); const cluster = process.env.CLUSTER || 'mainnet-beta'; const group_name = process.env.GROUP_NAME || 'BTC_ETH_SOL_SRM_USDC'; const clusterUrl = process.env.CLUSTER_URL || IDS.cluster_urls[cluster]; const targetsStr = process.env.TARGETS || '0.1 2.0 100.0 1000.0'; const checkInterval = parseFloat(process.env.CHECK_INTERVAL || '1000.0'); const targets = targetsStr.split(' ').map((s) => parseFloat(s)); const connection = new Connection(clusterUrl, 'singleGossip'); // The address of the Mango Program on the blockchain const programId = new PublicKey(IDS[cluster].mango_program_id); // The address of the serum dex program on the blockchain: https://github.com/project-serum/serum-dex const dexProgramId = new PublicKey(IDS[cluster].dex_program_id); // Address of the MangoGroup const mangoGroupPk = new PublicKey( IDS[cluster].mango_groups[group_name].mango_group_pk, ); // liquidator's keypair const keyPairPath = process.env.KEYPAIR || homedir() + '/.config/solana/id.json'; const payer = new Account(JSON.parse(fs.readFileSync(keyPairPath, 'utf-8'))); notify(`partial liquidator launched cluster=${cluster} group=${group_name}`); let mangoGroup = await client.getMangoGroup(connection, mangoGroupPk); const tokenWallets = await Promise.all( mangoGroup.tokens.map((mint) => findLargestTokenAccountForOwner(connection, payer.publicKey, mint).then( (response) => response.publicKey, ), ), ); // load all markets const markets = await Promise.all( mangoGroup.spotMarkets.map((pk) => Market.load( connection, pk, { skipPreflight: true, commitment: 'singleGossip' }, dexProgramId, ), ), ); // TODO handle failures in any of the steps // Find a way to get all margin accounts without querying fresh--get incremental updates to margin accounts const liqorOpenOrdersKeys: PublicKey[] = []; for (let i = 0; i < NUM_MARKETS; i++) { let openOrdersAccounts: OpenOrders[] = await markets[ i ].findOpenOrdersAccountsForOwner(connection, payer.publicKey); if (openOrdersAccounts.length) { liqorOpenOrdersKeys.push(openOrdersAccounts[0].publicKey); } else { console.log( `No OpenOrders account found for market ${markets[ i ].publicKey.toBase58()}`, ); } } if (liqorOpenOrdersKeys.length != NUM_MARKETS) { console.log( 'Warning: Missing OpenOrders accounts. Wallet balancing has been disabled.', ); } const cancelLimit = 5; while (true) { try { mangoGroup = await client.getMangoGroup(connection, mangoGroupPk); let marginAccounts = process.env.FILTER_ACCOUNTS ? await client.getAllMarginAccountsWithBorrows( connection, programId, mangoGroup, ) : await client.getAllMarginAccounts(connection, programId, mangoGroup); let [prices, vaultAccs, liqorAccs] = await Promise.all([ mangoGroup.getPrices(connection), getMultipleAccounts(connection, mangoGroup.vaults), getMultipleAccounts( connection, tokenWallets, 'processed' as Commitment, ), ]); const vaultValues = vaultAccs.map((a, i) => nativeToUi( parseTokenAccountData(a.accountInfo.data).amount, mangoGroup.mintDecimals[i], ), ); const liqorTokenValues = liqorAccs.map( (a) => parseTokenAccount(a.accountInfo.data).amount, ); const liqorTokenUi = liqorAccs.map((a, i) => nativeToUi( parseTokenAccountData(a.accountInfo.data).amount, mangoGroup.mintDecimals[i], ), ); console.log(prices); console.log(vaultValues); console.log(liqorTokenUi); // FIXME: added bias to collRatio to allow other liquidators to step in for testing let coll_bias = 0; if (process.env.COLL_BIAS) { coll_bias = parseFloat(process.env.COLL_BIAS); } let maxBorrAcc: MarginAccount | undefined = undefined; let maxBorrVal = 0; let minCollAcc: MarginAccount | undefined = undefined; let minCollVal = 99999; for (let ma of marginAccounts) { // parallelize this if possible let description = ''; try { const assetsVal = ma.getAssetsVal(mangoGroup, prices); const liabsVal = ma.getLiabsVal(mangoGroup, prices); if (liabsVal > maxBorrVal) { maxBorrVal = liabsVal; maxBorrAcc = ma; } if (liabsVal < 0.5) { // too small of an account; number precision may cause errors continue; } let collRatio = assetsVal / liabsVal; if (collRatio < minCollVal) { minCollVal = collRatio; minCollAcc = ma; } if (!ma.beingLiquidated) { const deficit = liabsVal * mangoGroup.initCollRatio - assetsVal; if (deficit < 0.1) { // too small of an account; number precision may cause errors continue; } if (collRatio + coll_bias >= mangoGroup.maintCollRatio) { continue; } } description = ma.toPrettyString(mangoGroup, prices); console.log( `Liquidatable\n${description}\nbeingLiquidated: ${ma.beingLiquidated}`, ); notify( `Liquidatable\n${description}\nbeingLiquidated: ${ma.beingLiquidated}`, ); // find the market with the most value in OpenOrdersAccount let maxMarketIndex = -1; let maxMarketVal = 0; for (let i = 0; i < NUM_MARKETS; i++) { const openOrdersAccount = ma.openOrdersAccounts[i]; if (openOrdersAccount === undefined) { continue; } const marketVal = openOrdersAccount.quoteTokenTotal.toNumber() + openOrdersAccount.baseTokenTotal.toNumber() * prices[i]; if (marketVal > maxMarketVal) { maxMarketIndex = i; maxMarketVal = marketVal; } } // Cancel orders on the market with the most value in open orders const transaction = new Transaction(); if (maxMarketIndex !== -1) { // force cancel orders on this particular market const spotMarket = markets[maxMarketIndex]; const [bids, asks] = await Promise.all([ spotMarket.loadBids(connection), spotMarket.loadAsks(connection), ]); const openOrdersAccount = ma.openOrdersAccounts[maxMarketIndex]; if (openOrdersAccount === undefined) { console.log('error state'); continue; } let numOrders = spotMarket.filterForOpenOrders(bids, asks, [ openOrdersAccount, ]).length; const dexSigner = await PublicKey.createProgramAddress( [ spotMarket.publicKey.toBuffer(), spotMarket['_decoded'].vaultSignerNonce.toArrayLike( Buffer, 'le', 8, ), ], spotMarket.programId, ); for (let i = 0; i < 10; i++) { const instruction = makeForceCancelOrdersInstruction( programId, mangoGroup.publicKey, payer.publicKey, ma.publicKey, mangoGroup.vaults[maxMarketIndex], mangoGroup.vaults[NUM_TOKENS - 1], spotMarket.publicKey, spotMarket.bidsAddress, spotMarket.asksAddress, mangoGroup.signerKey, spotMarket['_decoded'].eventQueue, spotMarket['_decoded'].baseVault, spotMarket['_decoded'].quoteVault, dexSigner, spotMarket.programId, ma.openOrders, mangoGroup.oracles, new BN(cancelLimit), ); transaction.add(instruction); numOrders -= cancelLimit; if (numOrders <= 0) { break; } } } // Find the market with the highest borrows and lowest assets const deposits = ma.getAssets(mangoGroup); const borrows = ma.getLiabs(mangoGroup); let minNet = 0; let minNetIndex = -1; let maxNet = 0; let maxNetIndex = NUM_TOKENS - 1; for (let i = 0; i < NUM_TOKENS; i++) { const netDeposit = (deposits[i] - borrows[i]) * prices[i]; if (netDeposit < minNet) { minNet = netDeposit; minNetIndex = i; } else if (netDeposit > maxNet) { maxNet = netDeposit; maxNetIndex = i; } } transaction.add( makePartialLiquidateInstruction( programId, mangoGroup.publicKey, payer.publicKey, liqorAccs[minNetIndex].publicKey, liqorAccs[maxNetIndex].publicKey, ma.publicKey, mangoGroup.vaults[minNetIndex], mangoGroup.vaults[maxNetIndex], mangoGroup.signerKey, ma.openOrders, mangoGroup.oracles, liqorTokenValues[minNetIndex], ), ); await client.sendTransaction(connection, transaction, payer, []); await sleep(2000); ma = await client.getMarginAccount( connection, ma.publicKey, dexProgramId, ); console.log( `Successful partial liquidation\n${ma.toPrettyString( mangoGroup, prices, )}\nbeingLiquidated: ${ma.beingLiquidated}`, ); notify( `Successful partial liquidation\n${ma.toPrettyString( mangoGroup, prices, )}\nbeingLiquidated: ${ma.beingLiquidated}`, ); break; // This is so wallets get balanced } catch (e) { if (!e.timeout) { throw e; } else { notify(`unknown error: ${e}`); console.error(e); } } } const maxBorrAccPk = maxBorrAcc ? maxBorrAcc.publicKey.toBase58() : ''; const maxBorrAccCr = maxBorrAcc ? maxBorrAcc.getCollateralRatio(mangoGroup, prices) : 0; const minCollAccPk = minCollAcc ? minCollAcc.publicKey.toBase58() : ''; const minCollBorrVal = minCollAcc ? minCollAcc.getLiabsVal(mangoGroup, prices) : 0; console.log( `Max Borrow Account: ${maxBorrAccPk} | Borrow Val: ${maxBorrVal} | CR: ${maxBorrAccCr}`, ); console.log( `Min Coll-Ratio Account: ${minCollAccPk} | Borrow Val: ${minCollBorrVal} | CR: ${minCollVal}`, ); if (liqorOpenOrdersKeys.length == NUM_MARKETS) { await balanceWallets( connection, mangoGroup, prices, markets, payer, tokenWallets, liqorTokenUi, liqorOpenOrdersKeys, targets, ); } else { console.log( 'Could not balance wallets due to missing OpenOrders account', ); } } catch (e) { notify(`unknown error: ${e}`); console.error(e); } finally { await sleep(checkInterval); } } } runPartialLiquidator();