mango-v4-service/mango-service-v4/src/mango.simple.client.ts

838 lines
25 KiB
TypeScript

import {
BookSide,
BookSideLayout,
Config,
getAllMarkets,
getMarketByBaseSymbolAndKind,
getMarketByPublicKey,
getMultipleAccounts,
getTokenBySymbol,
GroupConfig,
makeCancelPerpOrderInstruction,
makeCancelSpotOrderInstruction,
makeSettleFundsInstruction,
MangoAccount,
MangoClient,
MangoGroup,
MarketConfig,
PerpMarket,
PerpMarketLayout,
PerpOrder,
QUOTE_INDEX,
} from "@blockworks-foundation/mango-client";
import { Market, Orderbook } from "@project-serum/serum";
import { Order } from "@project-serum/serum/lib/market";
import {
Account,
AccountInfo,
Commitment,
Connection,
PublicKey,
Transaction,
TransactionSignature,
} from "@solana/web3.js";
import BN from "bn.js";
import fs from "fs";
import fetch from "node-fetch";
import os from "os";
import { OrderInfo } from "types";
import { logger, zipDict } from "./utils";
class MangoSimpleClient {
constructor(
public connection: Connection,
public client: MangoClient,
public mangoGroupConfig: GroupConfig,
public mangoGroup: MangoGroup,
public owner: Account,
public mangoAccount: MangoAccount
) {
// refresh things which might get stale over time
setInterval(
this.refresh,
3600_000,
connection,
mangoGroupConfig,
mangoAccount
);
}
static async create() {
const groupName = process.env.GROUP_NAME || "mainnet.1";
const clusterUrl =
process.env.CLUSTER_URL || "https://api.mainnet-beta.solana.com";
logger.info(`Creating mango client for ${groupName} using ${clusterUrl}`);
const mangoGroupConfig: GroupConfig = Config.ids().groups.filter(
(group) => group.name === groupName
)[0];
const connection = new Connection(clusterUrl, "processed" as Commitment);
const mangoClient = new MangoClient(
connection,
mangoGroupConfig.mangoProgramId
);
logger.info(`- fetching mango group`);
const mangoGroup = await mangoClient.getMangoGroup(
mangoGroupConfig.publicKey
);
logger.info(`- loading root banks`);
await mangoGroup.loadRootBanks(connection);
logger.info(`- loading cache`);
await mangoGroup.loadCache(connection);
const privateKeyPath =
process.env.PRIVATE_KEY_PATH || os.homedir() + "/.config/solana/id.json";
logger.info(`- loading private key at location ${privateKeyPath}`);
const owner = new Account(
JSON.parse(fs.readFileSync(privateKeyPath, "utf-8"))
);
let mangoAccount;
if (process.env.MANGO_ACCOUNT) {
logger.info(
`- MANGO_ACCOUNT explicitly specified, fetching mango account ${process.env.MANGO_ACCOUNT}`
);
mangoAccount = await mangoClient.getMangoAccount(
new PublicKey(process.env.MANGO_ACCOUNT),
mangoGroupConfig.serumProgramId
);
} else {
logger.info(
`- fetching mango accounts for ${owner.publicKey.toBase58()}`
);
let mangoAccounts;
try {
mangoAccounts = await mangoClient.getMangoAccountsForOwner(
mangoGroup,
owner.publicKey
);
} catch (error) {
logger.error(
`- error retrieving mango accounts for ${owner.publicKey.toBase58()}`
);
process.exit(1);
}
if (!mangoAccounts.length) {
logger.error(`- no mango account found ${owner.publicKey.toBase58()}`);
process.exit(1);
}
const sortedMangoAccounts = mangoAccounts
.slice()
.sort((a, b) =>
a.publicKey.toBase58() > b.publicKey.toBase58() ? 1 : -1
);
// just select first arbitrarily
mangoAccount = sortedMangoAccounts[0];
const debugAccounts = sortedMangoAccounts
.map((mangoAccount) => mangoAccount.publicKey.toBase58())
.join(", ");
logger.info(
`- found mango accounts ${debugAccounts}, using ${mangoAccount.publicKey.toBase58()}`
);
}
if (mangoAccount.owner.toBase58() !== owner.publicKey.toBase58()) {
logger.info(
`- Note: ${owner.publicKey.toBase58()} is a delegate for ${mangoAccount.publicKey.toBase58()}`
);
}
// load open orders accounts, used by e.g. getSpotOpenOrdersAccountForMarket
await mangoAccount.loadOpenOrders(
connection,
new PublicKey(mangoGroupConfig.serumProgramId)
);
return new MangoSimpleClient(
connection,
mangoClient,
mangoGroupConfig,
mangoGroup,
owner,
mangoAccount
);
}
/// public
public async fetchAllMarkets(
marketName?: string
): Promise<Partial<Record<string, Market | PerpMarket>>> {
let allMarketConfigs = getAllMarkets(this.mangoGroupConfig);
let allMarketPks = allMarketConfigs.map((m) => m.publicKey);
if (marketName !== undefined) {
allMarketConfigs = allMarketConfigs.filter(
(marketConfig) => marketConfig.name === marketName
);
allMarketPks = allMarketConfigs.map((m) => m.publicKey);
}
const allMarketAccountInfos = await getMultipleAccounts(
this.connection,
allMarketPks
);
const allMarketAccounts = allMarketConfigs.map((config, i) => {
if (config.kind === "spot") {
const decoded = Market.getLayout(
this.mangoGroupConfig.mangoProgramId
).decode(allMarketAccountInfos[i].accountInfo.data);
return new Market(
decoded,
config.baseDecimals,
config.quoteDecimals,
undefined,
this.mangoGroupConfig.serumProgramId
);
}
if (config.kind === "perp") {
const decoded = PerpMarketLayout.decode(
allMarketAccountInfos[i].accountInfo.data
);
return new PerpMarket(
config.publicKey,
config.baseDecimals,
config.quoteDecimals,
decoded
);
}
});
return zipDict(
allMarketPks.map((pk) => pk.toBase58()),
allMarketAccounts
);
}
public async fetchAllBidsAndAsks(
filterForMangoAccount: boolean = false,
marketName?: string
): Promise<OrderInfo[][]> {
this.mangoAccount.loadOpenOrders(
this.connection,
new PublicKey(this.mangoGroupConfig.serumProgramId)
);
let allMarketConfigs = getAllMarkets(this.mangoGroupConfig);
let allMarketPks = allMarketConfigs.map((m) => m.publicKey);
if (marketName !== undefined) {
allMarketConfigs = allMarketConfigs.filter(
(marketConfig) => marketConfig.name === marketName
);
allMarketPks = allMarketConfigs.map((m) => m.publicKey);
}
const allBidsAndAsksPks = allMarketConfigs
.map((m) => [m.bidsKey, m.asksKey])
.flat();
const allBidsAndAsksAccountInfos = await getMultipleAccounts(
this.connection,
allBidsAndAsksPks
);
const accountInfos: { [key: string]: AccountInfo<Buffer> } = {};
allBidsAndAsksAccountInfos.forEach(
({ publicKey, context, accountInfo }) => {
accountInfos[publicKey.toBase58()] = accountInfo;
}
);
const markets = await this.fetchAllMarkets(marketName);
return Object.entries(markets).map(([address, market]) => {
const marketConfig = getMarketByPublicKey(this.mangoGroupConfig, address);
if (market instanceof Market) {
return this.parseSpotOrders(
market,
marketConfig,
accountInfos,
filterForMangoAccount ? this.mangoAccount : undefined
);
} else if (market instanceof PerpMarket) {
return this.parsePerpOpenOrders(
market,
marketConfig,
accountInfos,
filterForMangoAccount ? this.mangoAccount : undefined
);
}
});
}
public getSpotOpenOrdersAccount(
marketConfig: MarketConfig
): PublicKey | null {
const spotOpenOrdersAccount =
this.mangoAccount.spotOpenOrdersAccounts[marketConfig.marketIndex];
return spotOpenOrdersAccount ? spotOpenOrdersAccount.publicKey : null;
}
public async fetchAllSpotFills(): Promise<any[]> {
const allMarketConfigs = getAllMarkets(this.mangoGroupConfig);
const allMarkets = await this.fetchAllMarkets();
// merge
// 1. latest fills from on-chain
let allRecentMangoAccountSpotFills: any[] = [];
// 2. historic from off-chain REST service
let allButRecentMangoAccountSpotFills: any[] = [];
for (const config of allMarketConfigs) {
if (config.kind === "spot") {
const openOrdersAccount =
this.mangoAccount.spotOpenOrdersAccounts[config.marketIndex];
if (openOrdersAccount === undefined) {
continue;
}
const response = await fetch(
`https://event-history-api.herokuapp.com/trades/open_orders/${openOrdersAccount.publicKey.toBase58()}`
);
const responseJson = await response.json();
allButRecentMangoAccountSpotFills =
allButRecentMangoAccountSpotFills.concat(
responseJson?.data ? responseJson.data : []
);
const recentMangoAccountSpotFills: any[] = await allMarkets[
config.publicKey.toBase58()
]
.loadFills(this.connection, 10000)
.then((fills) => {
fills = fills.filter((fill) => {
return openOrdersAccount?.publicKey
? fill.openOrders.equals(openOrdersAccount?.publicKey)
: false;
});
return fills.map((fill) => ({ ...fill, marketName: config.name }));
});
allRecentMangoAccountSpotFills = allRecentMangoAccountSpotFills.concat(
recentMangoAccountSpotFills
);
}
}
const newMangoAccountSpotFills = allRecentMangoAccountSpotFills.filter(
(fill: any) =>
!allButRecentMangoAccountSpotFills.flat().find((t: any) => {
if (t.orderId) {
return t.orderId === fill.orderId?.toString();
} else {
return t.seqNum === fill.seqNum?.toString();
}
})
);
return [...newMangoAccountSpotFills, ...allButRecentMangoAccountSpotFills];
}
public async fetchAllPerpFills(): Promise<any[]> {
const allMarketConfigs = getAllMarkets(this.mangoGroupConfig);
const allMarkets = await this.fetchAllMarkets();
// merge
// 1. latest fills from on-chain
let allRecentMangoAccountPerpFills: any[] = [];
// 2. historic from off-chain REST service
const response = await fetch(
`https://event-history-api.herokuapp.com/perp_trades/${this.mangoAccount.publicKey.toBase58()}`
);
const responseJson = await response.json();
const allButRecentMangoAccountPerpFills = responseJson?.data || [];
for (const config of allMarketConfigs) {
if (config.kind === "perp") {
const recentMangoAccountPerpFills: any[] = await allMarkets[
config.publicKey.toBase58()
]
.loadFills(this.connection)
.then((fills) => {
fills = fills.filter(
(fill) =>
fill.taker.equals(this.mangoAccount.publicKey) ||
fill.maker.equals(this.mangoAccount.publicKey)
);
return fills.map((fill) => ({ ...fill, marketName: config.name }));
});
allRecentMangoAccountPerpFills = allRecentMangoAccountPerpFills.concat(
recentMangoAccountPerpFills
);
}
}
const newMangoAccountPerpFills = allRecentMangoAccountPerpFills.filter(
(fill: any) =>
!allButRecentMangoAccountPerpFills.flat().find((t: any) => {
if (t.orderId) {
return t.orderId === fill.orderId?.toString();
} else {
return t.seqNum === fill.seqNum?.toString();
}
})
);
return [...newMangoAccountPerpFills, ...allButRecentMangoAccountPerpFills];
}
public async placeOrder(
market: string,
side: "buy" | "sell",
quantity: number,
price?: number,
orderType: "ioc" | "postOnly" | "market" | "limit" = "limit",
clientOrderId?: number
): Promise<TransactionSignature> {
if (market.includes("PERP")) {
const perpMarketConfig = getMarketByBaseSymbolAndKind(
this.mangoGroupConfig,
market.split("-")[0],
"perp"
);
const perpMarket = await this.mangoGroup.loadPerpMarket(
this.connection,
perpMarketConfig.marketIndex,
perpMarketConfig.baseDecimals,
perpMarketConfig.quoteDecimals
);
// TODO: this is a workaround, mango-v4 has a assertion for price>0 for all order types
// this will be removed soon hopefully
price = orderType !== "market" ? price : 1;
return await this.client.placePerpOrder(
this.mangoGroup,
this.mangoAccount,
this.mangoGroup.mangoCache,
perpMarket,
this.owner,
side,
price,
quantity,
orderType,
clientOrderId
);
} else {
// serum doesn't really support market orders, calculate a pseudo market price
price =
orderType !== "market"
? price
: await this.calculateMarketOrderPrice(market, quantity, side);
const spotMarketConfig = getMarketByBaseSymbolAndKind(
this.mangoGroupConfig,
market.split("/")[0],
"spot"
);
const spotMarket = await Market.load(
this.connection,
spotMarketConfig.publicKey,
undefined,
this.mangoGroupConfig.serumProgramId
);
return await this.client.placeSpotOrder(
this.mangoGroup,
this.mangoAccount,
this.mangoGroup.mangoCache,
spotMarket,
this.owner,
side,
price,
quantity,
orderType === "market" ? "limit" : orderType,
new BN(clientOrderId)
);
}
}
private async calculateMarketOrderPrice(
market: string,
quantity: number,
side: "buy" | "sell"
): Promise<number> {
const bidsAndAsks = await this.fetchAllBidsAndAsks(false, market);
const bids = bidsAndAsks
.flat()
.filter((orderInfo) => orderInfo.order.side === "buy")
.sort((b1, b2) => b2.order.price - b1.order.price);
const asks = bidsAndAsks
.flat()
.filter((orderInfo) => orderInfo.order.side === "sell")
.sort((a1, a2) => a1.order.price - a2.order.price);
const orders: OrderInfo[] = side === "buy" ? asks : bids;
let acc = 0;
let selectedOrder;
for (const order of orders) {
acc += order.order.size;
if (acc >= quantity) {
selectedOrder = order;
break;
}
}
if (!selectedOrder) {
throw new Error("Orderbook empty!");
}
if (side === "buy") {
return selectedOrder.order.price * 1.05;
} else {
return selectedOrder.order.price * 0.95;
}
}
public async cancelAllOrders(): Promise<void> {
const allMarkets = await this.fetchAllMarkets();
const orders = (await this.fetchAllBidsAndAsks(true)).flat();
const transactions = await Promise.all(
orders.map((orderInfo) =>
this.buildCancelOrderTransaction(
orderInfo,
allMarkets[orderInfo.market.account.publicKey.toBase58()]
)
)
);
let i;
const j = transactions.length;
// assuming we can fit 10 cancel order transactions in a solana transaction
// we could switch to computing actual transactionSize every time we add an
// instruction and use a dynamic chunk size
const chunk = 10;
const transactionsToSend: Transaction[] = [];
for (i = 0; i < j; i += chunk) {
const transactionsChunk = transactions.slice(i, i + chunk);
const transactionToSend = new Transaction();
for (const transaction of transactionsChunk) {
for (const instruction of transaction.instructions) {
transactionToSend.add(instruction);
}
}
transactionsToSend.push(transactionToSend);
}
for (const transaction of transactionsToSend) {
await this.client.sendTransaction(transaction, this.owner, []);
}
}
public async cancelOrder(
orderInfo: OrderInfo,
market?: Market | PerpMarket
): Promise<TransactionSignature> {
if (orderInfo.market.config.kind === "perp") {
const perpMarketConfig = getMarketByBaseSymbolAndKind(
this.mangoGroupConfig,
orderInfo.market.config.baseSymbol,
"perp"
);
if (market === undefined) {
market = await this.mangoGroup.loadPerpMarket(
this.connection,
perpMarketConfig.marketIndex,
perpMarketConfig.baseDecimals,
perpMarketConfig.quoteDecimals
);
}
return await this.client.cancelPerpOrder(
this.mangoGroup,
this.mangoAccount,
this.owner,
market as PerpMarket,
orderInfo.order as PerpOrder
);
} else {
const spotMarketConfig = getMarketByBaseSymbolAndKind(
this.mangoGroupConfig,
orderInfo.market.config.baseSymbol,
"spot"
);
if (market === undefined) {
market = await Market.load(
this.connection,
spotMarketConfig.publicKey,
undefined,
this.mangoGroupConfig.serumProgramId
);
}
return await this.client.cancelSpotOrder(
this.mangoGroup,
this.mangoAccount,
this.owner,
market as Market,
orderInfo.order as Order
);
}
}
public async buildCancelOrderTransaction(
orderInfo: OrderInfo,
market?: Market | PerpMarket
): Promise<Transaction> {
if (orderInfo.market.config.kind === "perp") {
const perpMarketConfig = getMarketByBaseSymbolAndKind(
this.mangoGroupConfig,
orderInfo.market.config.baseSymbol,
"perp"
);
if (market === undefined) {
market = await this.mangoGroup.loadPerpMarket(
this.connection,
perpMarketConfig.marketIndex,
perpMarketConfig.baseDecimals,
perpMarketConfig.quoteDecimals
);
}
return this.buildCancelPerpOrderInstruction(
this.mangoGroup,
this.mangoAccount,
this.owner,
market as PerpMarket,
orderInfo.order as PerpOrder
);
} else {
const spotMarketConfig = getMarketByBaseSymbolAndKind(
this.mangoGroupConfig,
orderInfo.market.config.baseSymbol,
"spot"
);
if (market === undefined) {
market = await Market.load(
this.connection,
spotMarketConfig.publicKey,
undefined,
this.mangoGroupConfig.serumProgramId
);
}
return this.buildCancelSpotOrderTransaction(
this.mangoGroup,
this.mangoAccount,
this.owner,
market as Market,
orderInfo.order as Order
);
}
}
public async getOrderByOrderId(orderId: string): Promise<OrderInfo[]> {
const orders = (await this.fetchAllBidsAndAsks(true)).flat();
const orderInfos = orders.filter(
(orderInfo) => orderInfo.order.orderId.toString() === orderId
);
return orderInfos;
}
public async getOrderByClientId(clientId: string): Promise<OrderInfo[]> {
const orders = await (await this.fetchAllBidsAndAsks(true)).flat();
const orderInfos = orders.filter(
(orderInfo) => orderInfo.order.clientId.toNumber().toString() === clientId
);
return orderInfos;
}
public async withdraw(
tokenSymbol: string,
amount: number
): Promise<TransactionSignature> {
const tokenToWithdraw = getTokenBySymbol(
this.mangoGroupConfig,
tokenSymbol
);
const tokenIndex = this.mangoGroup.getTokenIndex(tokenToWithdraw.mintKey);
return this.client.withdraw(
this.mangoGroup,
this.mangoAccount,
this.owner,
this.mangoGroup.tokens[tokenIndex].rootBank,
this.mangoGroup.rootBankAccounts[tokenIndex].nodeBankAccounts[0]
.publicKey,
this.mangoGroup.rootBankAccounts[tokenIndex].nodeBankAccounts[0].vault,
Number(amount),
false
);
}
/// private
private parseSpotOrders(
market: Market,
config: MarketConfig,
accountInfos: { [key: string]: AccountInfo<Buffer> },
mangoAccount?: MangoAccount
): OrderInfo[] {
const bidData = accountInfos[market["_decoded"].bids.toBase58()]?.data;
const askData = accountInfos[market["_decoded"].asks.toBase58()]?.data;
const bidOrderBook =
market && bidData ? Orderbook.decode(market, bidData) : ([] as Order[]);
const askOrderBook =
market && askData ? Orderbook.decode(market, askData) : ([] as Order[]);
let openOrdersForMarket = [...bidOrderBook, ...askOrderBook];
if (mangoAccount !== undefined) {
const openOrders =
mangoAccount.spotOpenOrdersAccounts[config.marketIndex];
if (!openOrders) return [];
openOrdersForMarket = openOrdersForMarket.filter((o) =>
o.openOrdersAddress.equals(openOrders.address)
);
}
return openOrdersForMarket.map<OrderInfo>((order) => ({
order,
market: { account: market, config },
}));
}
private parsePerpOpenOrders(
market: PerpMarket,
config: MarketConfig,
accountInfos: { [key: string]: AccountInfo<Buffer> },
mangoAccount?: MangoAccount
): OrderInfo[] {
const bidData = accountInfos[market.bids.toBase58()]?.data;
const askData = accountInfos[market.asks.toBase58()]?.data;
const bidOrderBook =
market && bidData
? new BookSide(market.bids, market, BookSideLayout.decode(bidData))
: ([] as PerpOrder[]);
const askOrderBook =
market && askData
? new BookSide(market.asks, market, BookSideLayout.decode(askData))
: ([] as PerpOrder[]);
let openOrdersForMarket = [...bidOrderBook, ...askOrderBook];
if (mangoAccount !== undefined) {
openOrdersForMarket = openOrdersForMarket.filter((o) =>
o.owner.equals(mangoAccount.publicKey)
);
}
return openOrdersForMarket.map<OrderInfo>((order) => ({
order,
market: { account: market, config },
}));
}
private buildCancelPerpOrderInstruction(
mangoGroup: MangoGroup,
mangoAccount: MangoAccount,
owner: Account,
perpMarket: PerpMarket,
order: PerpOrder,
invalidIdOk = false // Don't throw error if order is invalid
): Transaction {
const instruction = makeCancelPerpOrderInstruction(
this.mangoGroupConfig.mangoProgramId,
mangoGroup.publicKey,
mangoAccount.publicKey,
owner.publicKey,
perpMarket.publicKey,
perpMarket.bids,
perpMarket.asks,
order,
invalidIdOk
);
const transaction = new Transaction();
transaction.add(instruction);
return transaction;
}
private async buildCancelSpotOrderTransaction(
mangoGroup: MangoGroup,
mangoAccount: MangoAccount,
owner: Account,
spotMarket: Market,
order: Order
): Promise<Transaction> {
const transaction = new Transaction();
const instruction = makeCancelSpotOrderInstruction(
this.mangoGroupConfig.mangoProgramId,
mangoGroup.publicKey,
owner.publicKey,
mangoAccount.publicKey,
spotMarket.programId,
spotMarket.publicKey,
spotMarket["_decoded"].bids,
spotMarket["_decoded"].asks,
order.openOrdersAddress,
mangoGroup.signerKey,
spotMarket["_decoded"].eventQueue,
order
);
transaction.add(instruction);
const dexSigner = await PublicKey.createProgramAddress(
[
spotMarket.publicKey.toBuffer(),
spotMarket["_decoded"].vaultSignerNonce.toArrayLike(Buffer, "le", 8),
],
spotMarket.programId
);
const marketIndex = mangoGroup.getSpotMarketIndex(spotMarket.publicKey);
if (!mangoGroup.rootBankAccounts.length) {
await mangoGroup.loadRootBanks(this.connection);
}
const baseRootBank = mangoGroup.rootBankAccounts[marketIndex];
const quoteRootBank = mangoGroup.rootBankAccounts[QUOTE_INDEX];
const baseNodeBank = baseRootBank?.nodeBankAccounts[0];
const quoteNodeBank = quoteRootBank?.nodeBankAccounts[0];
if (!baseNodeBank || !quoteNodeBank) {
throw new Error("Invalid or missing node banks");
}
// todo what is a makeSettleFundsInstruction?
const settleFundsInstruction = makeSettleFundsInstruction(
this.mangoGroupConfig.mangoProgramId,
mangoGroup.publicKey,
mangoGroup.mangoCache,
owner.publicKey,
mangoAccount.publicKey,
spotMarket.programId,
spotMarket.publicKey,
mangoAccount.spotOpenOrders[marketIndex],
mangoGroup.signerKey,
spotMarket["_decoded"].baseVault,
spotMarket["_decoded"].quoteVault,
mangoGroup.tokens[marketIndex].rootBank,
baseNodeBank.publicKey,
mangoGroup.tokens[QUOTE_INDEX].rootBank,
quoteNodeBank.publicKey,
baseNodeBank.vault,
quoteNodeBank.vault,
dexSigner
);
transaction.add(settleFundsInstruction);
return transaction;
}
private refresh(
connection: Connection,
mangoGroupConfig: GroupConfig,
mangoAccount: MangoAccount
) {
mangoAccount.loadOpenOrders(connection, mangoGroupConfig.serumProgramId);
}
}
export default MangoSimpleClient;