mango-client-ts/tests/test_utils.ts

411 lines
16 KiB
TypeScript

import { MangoClient, MangoGroup } from '../src/client';
import { Account, Connection, PublicKey, SystemProgram, Transaction, TransactionInstruction, TransactionSignature } from '@solana/web3.js';
import { Market, TokenInstructions, OpenOrders, Orderbook } from '@project-serum/serum';
import { Order } from '@project-serum/serum/lib/market';
import { token } from '@project-serum/common';
import { u64, NATIVE_MINT } from "@solana/spl-token";
import { sleep, getDecimalCount } from '../src/utils';
console.log = function () {}; // NOTE: Disable all unnecessary logging
const FAUCET_PROGRAM_ID = new PublicKey(
"4bXpkKSV8swHSnwqtzuboGPaPDeEgAn4Vt8GfarV5rZt"
);
const getPDA = () => {
return PublicKey.findProgramAddress([Buffer.from("faucet")], FAUCET_PROGRAM_ID);
}
export async function _sendTransaction (
connection: Connection,
transaction: Transaction,
signers: Account[],
): Promise<TransactionSignature> {
await sleep(1000)
const signature = await connection.sendTransaction(transaction, signers);
try {
await connection.confirmTransaction(signature);
} catch (e) {
console.info("Error while confirming, trying again");
await connection.confirmTransaction(signature);
}
return signature;
}
export async function createTokenAccountInstrs (
connection: Connection,
newAccountPubkey: PublicKey,
mint: PublicKey,
ownerPk: PublicKey,
lamports?: number,
): Promise<TransactionInstruction[]> {
if (lamports === undefined) lamports = await connection.getMinimumBalanceForRentExemption(165);
return [
SystemProgram.createAccount({
fromPubkey: ownerPk,
newAccountPubkey,
space: 165,
lamports,
programId: TokenInstructions.TOKEN_PROGRAM_ID,
}),
TokenInstructions.initializeAccount({
account: newAccountPubkey,
mint,
owner: ownerPk,
}),
];
}
export async function createWrappedNativeAccount (
connection: Connection,
owner: Account,
amount: number
): Promise<PublicKey> {
// Allocate memory for the account
const balanceNeeded = await connection.getMinimumBalanceForRentExemption(165);
const newAccount = new Account();
const tx = new Transaction();
tx.add(SystemProgram.createAccount({
fromPubkey: owner.publicKey,
newAccountPubkey: newAccount.publicKey,
lamports: balanceNeeded,
space: 165,
programId: TokenInstructions.TOKEN_PROGRAM_ID,
})); // Send lamports to it (these will be wrapped into native tokens by the token program)
tx.add(SystemProgram.transfer({
fromPubkey: owner.publicKey,
toPubkey: newAccount.publicKey,
lamports: amount
})); // Assign the new account to the native token mint.
// the account will be initialized with a balance equal to the native token balance.
// (i.e. amount)
tx.add(TokenInstructions.initializeAccount({
account: newAccount.publicKey,
mint: NATIVE_MINT,
owner: owner.publicKey,
}));
const signers = [owner, newAccount];
const signerPks = signers.map(x => x.publicKey);
tx.setSigners(...signerPks);
await _sendTransaction(connection, tx, signers);
return newAccount.publicKey;
}
export async function createTokenAccount (
connection: Connection,
mint: PublicKey,
owner: Account
): Promise<PublicKey> {
const newAccount = new Account();
const tx = new Transaction();
const signers = [owner, newAccount];
const signerPks = signers.map(x => x.publicKey);
tx.add(...(await createTokenAccountInstrs(connection, newAccount.publicKey, mint, owner.publicKey)));
tx.setSigners(...signerPks);
await _sendTransaction(connection, tx, signers);
return newAccount.publicKey;
}
export async function createWalletAndRequestAirdrop(
connection: Connection,
amount: number
): Promise<Account> {
console.info("Creating a new wallet");
const owner = new Account();
if (amount < 1) throw new Error("SOL is needed for gas fees so at least 1 SOL is required");
await airdropSol(connection, owner, amount);
return owner;
}
export async function getSpotMarketDetails(
connection: Connection,
mangoGroupSpotMarket: any,
dexProgramId: PublicKey
): Promise<{spotMarket: Market, baseSymbol: string, quoteSymbol: string, minSize: number, minPrice: number}> {
const [spotMarketSymbol, spotMarketAddress] = mangoGroupSpotMarket;
const [baseSymbol, quoteSymbol] = spotMarketSymbol.split('/');
const spotMarket = await Market.load(connection, new PublicKey(spotMarketAddress), { skipPreflight: true, commitment: 'singleGossip'}, dexProgramId);
const { minSize, minPrice } = getMinSizeAndPriceForMarket(spotMarket);
return { spotMarket, baseSymbol, quoteSymbol, minSize: minSize as number, minPrice: minPrice as number };
}
export async function createMangoGroupSymbolMappings (
connection: Connection,
mangoGroupIds: any,
): Promise<any> {
const mangoGroupTokenMappings = {};
const mangoGroupSymbols: [string, string][] = Object.entries(mangoGroupIds.symbols);
for (let [tokenName, tokenMint] of mangoGroupSymbols) {
const tokenSupply = await connection.getTokenSupply(new PublicKey(tokenMint));
mangoGroupTokenMappings[tokenMint] = { tokenMint: new PublicKey(tokenMint), tokenName, decimals: tokenSupply.value.decimals };
}
return mangoGroupTokenMappings;
}
export async function buildAirdropTokensIx(
amount: u64,
tokenMintPublicKey: PublicKey,
destinationAccountPubkey: PublicKey,
faucetPubkey: PublicKey
) {
const pubkeyNonce = await getPDA();
const keys = [
{ pubkey: pubkeyNonce[0], isSigner: false, isWritable: false },
{ pubkey: tokenMintPublicKey, isSigner: false, isWritable: true },
{ pubkey: destinationAccountPubkey, isSigner: false, isWritable: true },
{ pubkey: TokenInstructions.TOKEN_PROGRAM_ID, isSigner: false, isWritable: false },
{ pubkey: faucetPubkey, isSigner: false, isWritable: false }
];
return new TransactionInstruction({
programId: FAUCET_PROGRAM_ID,
data: Buffer.from([1, ...amount.toArray("le", 8)]),
keys
});
};
export async function airdropTokens(
connection: Connection,
feePayerAccount: Account,
faucetAddress: string,
tokenDestinationPublicKey: PublicKey,
mint: PublicKey,
amount: u64
) {
const faucetPubkey = new PublicKey(faucetAddress);
const ix = await buildAirdropTokensIx(amount, mint, tokenDestinationPublicKey, faucetPubkey);
const tx = new Transaction();
tx.add(ix);
const signers = [feePayerAccount];
await _sendTransaction(connection, tx, signers);
return tokenDestinationPublicKey.toBase58();
};
export async function airdropSol(
connection: Connection,
owner: Account,
amount: number
): Promise<void> {
const roundedSolAmount = Math.round(amount);
console.info(`Requesting ${roundedSolAmount} SOL`);
const generousAccount = [115,98,128,18,66,112,147,244,46,244,118,106,91,202,56,83,58,71,89,226,32,177,177,240,189,23,209,176,138,119,130,140,6,149,55,70,215,34,108,133,225,117,38,141,74,246,232,76,176,10,207,221,68,179,115,158,106,133,35,30,4,177,124,5];
const backupAcc = new Account(generousAccount);
const tx = new Transaction();
tx.add(SystemProgram.transfer({fromPubkey: backupAcc.publicKey, lamports: roundedSolAmount * 1e9, toPubkey: owner.publicKey}));
const signers = [backupAcc];
const signerPks = signers.map(x => x.publicKey);
tx.setSigners(...signerPks);
await _sendTransaction(connection, tx, signers);
}
export function getMinSizeAndPriceForMarket(
spotMarket: Market
): { minSize : string | number, minPrice: string | number} {
const minSize = spotMarket?.minOrderSize?.toFixed(getDecimalCount(spotMarket.minOrderSize)) || spotMarket?.minOrderSize;
const minPrice = spotMarket?.tickSize?.toFixed(getDecimalCount(spotMarket.tickSize)) || spotMarket?.tickSize;
return { minSize, minPrice };
}
export async function placeOrderUsingSerumDex(
connection: Connection,
owner: Account,
spotMarket: Market,
baseCurrencyAccount: PublicKey,
quoteCurrencyAccount: PublicKey,
orderParams: any
): Promise<string> {
const { side, price, size, orderType = 'limit', feeDiscountPubkey = undefined } = orderParams;
const { minSize, minPrice } = getMinSizeAndPriceForMarket(spotMarket);
const isIncrement = (num: number, step: number) => Math.abs((num / step) % 1) < 1e-5 || Math.abs(((num / step) % 1) - 1) < 1e-5;
if (isNaN(price)) throw Error('Invalid price');
if (isNaN(size)) throw Error('Invalid size');
if (!spotMarket) throw Error('Invalid market');
if (!isIncrement(size, spotMarket.minOrderSize)) throw Error(`Size must be an increment of ${minSize}`);
if (size < spotMarket.minOrderSize) throw Error('Size too small');
if (!isIncrement(price, spotMarket.tickSize)) throw Error(`Price must be an increment of ${minPrice}`);
if (price < spotMarket.tickSize) throw Error('Price too small');
const transaction = new Transaction();
const signers: Account[] = [owner];
const payer = side === 'sell' ? baseCurrencyAccount : quoteCurrencyAccount;
if (!payer) throw Error('Associated account for spend currency is missing');
const params = { owner: owner.publicKey, payer, side, price, size, orderType, feeDiscountPubkey: feeDiscountPubkey || null};
let { transaction: placeOrderTx, signers: placeOrderSigners } = await spotMarket.makePlaceOrderTransaction( connection, params, 120_000, 120_000 );
transaction.add(placeOrderTx);
signers.push(...placeOrderSigners);
return await _sendTransaction(connection, transaction, signers);
}
export async function cancelOrdersUsingSerumDex(
connection: Connection,
owner: Account,
spotMarket: Market,
orders: Order[]
): Promise<string>{
const transaction = new Transaction();
orders.forEach((order) => {
transaction.add(
spotMarket.makeCancelOrderInstruction(connection, owner.publicKey, order),
);
});
return await _sendTransaction(connection, transaction, [owner]);
}
export async function createTokenAccountWithBalance(
connection: Connection,
owner: Account,
tokenName: string,
mangoGroupTokenMappings: any,
faucetIds: any,
amount: number,
wrappedSol: boolean = true,
) {
const tokenMapping: any = Object.values(mangoGroupTokenMappings).find((x: any) => x.tokenName === tokenName);
const { tokenMint, decimals } = tokenMapping;
const multiplier = Math.pow(10, decimals);
const processedAmount = amount * multiplier;
let ownedTokenAccountPk: PublicKey | null = null;
if (tokenName === 'SOL') {
await airdropSol(connection, owner, amount);
if (wrappedSol) {
ownedTokenAccountPk = await createWrappedNativeAccount(connection, owner, processedAmount);
}
} else {
ownedTokenAccountPk = await createTokenAccount(connection, tokenMint, owner);
if (amount > 0) {
await airdropTokens(connection, owner, faucetIds[tokenName], ownedTokenAccountPk, tokenMint, new u64(processedAmount));
}
}
return ownedTokenAccountPk;
}
export async function performSingleDepositOrWithdrawal (
connection: Connection,
owner: Account,
client: MangoClient,
mangoGroup: MangoGroup,
mangoProgramId: PublicKey,
tokenName: string,
mangoGroupTokenMappings: any,
marginAccount: any,
type: string,
amount: number
) {
const tokenMapping: any = Object.values(mangoGroupTokenMappings).find((x: any) => x.tokenName === tokenName);
const { tokenMint } = tokenMapping;
const ownedTokenAccounts = await token.getOwnedTokenAccounts(connection, owner.publicKey);
const ownedTokenAccount = ownedTokenAccounts.find((x: any) => x.account.mint.equals(tokenMint));
if (!ownedTokenAccount) throw new Error(`Token account doesn't exist for ${tokenName}`);
if (type === 'deposit') {
// TODO: Add wrapped SOL functionality here instead of creating an account for Wrapped SOL in default
await client.deposit(connection, mangoProgramId, mangoGroup, marginAccount, owner, tokenMint, ownedTokenAccount.publicKey, Number(amount));
} else if (type === 'withdraw') {
await client.withdraw(connection, mangoProgramId, mangoGroup, marginAccount, owner, tokenMint, ownedTokenAccount.publicKey, Number(amount));
}
}
export async function getAndDecodeBidsAndAsks (
connection: Connection,
spotMarket: Market
): Promise<any> {
const bidData = (await connection.getAccountInfo(spotMarket['_decoded'].bids))?.data;
const bidOrderBook = bidData ? Orderbook.decode(spotMarket, Buffer.from(bidData)): [];
const askData = (await connection.getAccountInfo(spotMarket['_decoded'].asks))?.data;
const askOrderBook = askData ? Orderbook.decode(spotMarket, Buffer.from(askData)): [];
return {bidOrderBook, askOrderBook};
}
export async function getAndDecodeBidsAndAsksForOwner (
connection: Connection,
spotMarket: Market,
openOrdersAccount: OpenOrders | undefined,
): Promise<any> {
if (!openOrdersAccount) throw new Error(`openOrdersAccount not found`);
const { bidOrderBook, askOrderBook } = await getAndDecodeBidsAndAsks(connection, spotMarket);
const openOrdersForOwner = [...bidOrderBook, ...askOrderBook].filter((o) =>
o.openOrdersAddress.equals(openOrdersAccount.address)
)
return openOrdersForOwner;
}
export async function getBidOrAskPriceEdge(
connection: Connection,
spotMarket: Market,
bidOrAsk: string,
maxOrMin: string
): Promise<number>{
// TODO: Refactor this function to use minSize and minPrice prices
const { bidOrderBook, askOrderBook } = await getAndDecodeBidsAndAsks(connection, spotMarket);
const [orderBookSide, orderBookOtherSide] = (bidOrAsk === 'bid' ? [bidOrderBook, askOrderBook] : [askOrderBook, bidOrderBook]);
const orderBookSidePrices: number[] = [...orderBookSide].map(x => x.price);
if (!orderBookSidePrices.length) {
// NOTE: This is a very arbitrary error prevention mechanism if one or both sides of the order book are empty
const orderBookOtherSidePrices: number[] = [...orderBookOtherSide].map(x => x.price);
if (bidOrAsk === 'bid') {
orderBookSidePrices.push(orderBookOtherSidePrices.length ? Math.min(...orderBookOtherSidePrices) / 2 : 10); // TODO: Maybe have a default value
} else {
orderBookSidePrices.push(orderBookOtherSidePrices.length ? Math.max(...orderBookOtherSidePrices) + 10 : 10); // TODO: Maybe have a default value
}
}
if (maxOrMin === 'min') {
return Math.min(...orderBookSidePrices);
} else {
return Math.max(...orderBookSidePrices);
}
}
export async function getOrderSizeAndPrice(
connection: Connection,
spotMarket: Market,
mangoGroupTokenMappings: any,
baseSymbol: string,
quoteSymbol: string,
side: string
): Promise<number[]>{
// NOTE: Always use minOrderSize
const tokenMapping: any = Object.values(mangoGroupTokenMappings).find((x: any) => x.tokenName === baseSymbol);
const { decimals } = tokenMapping;
const [stepSize, orderSize] = (decimals === 6) ? [0.1, 1] : [1, 0.1];
const edge = (side === 'buy') ? ['bid', 'max'] : ['ask', 'min'];
const orderPrice: number = Math.max(await getBidOrAskPriceEdge(connection, spotMarket, edge[0], edge[1]), stepSize);
return [orderSize, orderPrice, stepSize];
}
export function extractInfoFromLogs(
confirmedTx: any
): any {
if (!confirmedTx) throw new Error(`Couldn't find confirmed transaction`);
let invocationCount: number = 0;
let invocationComputeUnits: any[] = [];
const logMessages = confirmedTx.meta.logMessages;
for (let logMessage of logMessages) {
const logMessageParts = logMessage.split(' ');
if (logMessageParts.length === 4) {
if (logMessageParts[2] === 'invoke' && (/(\[[0-9]*\])/g).test(logMessageParts[3])) {
invocationCount += 1;
}
} else if (logMessageParts.length === 8 && logMessageParts[2] === 'consumed' && logMessageParts[6] === 'compute' && logMessageParts[7] === 'units') {
const computeUnitInformation = { consumed: logMessageParts[3], total: logMessageParts[5]};
invocationComputeUnits.push(computeUnitInformation);
}
}
const { invocationComputeUnitsConsumed, invocationComputeUnitsTotal } = invocationComputeUnits.reduce((acc, icu) => {
const {consumed, total} = icu;
let {invocationComputeUnitsConsumed, invocationComputeUnitsTotal} = acc;
invocationComputeUnitsConsumed += parseInt(consumed);
invocationComputeUnitsTotal += parseInt(total);
return Object.assign(acc, { invocationComputeUnitsConsumed, invocationComputeUnitsTotal });
}, {invocationComputeUnitsConsumed: 0, invocationComputeUnitsTotal: 0});
return { invocationCount, invocationComputeUnitsConsumed, invocationComputeUnitsTotal, invocationComputeUnits };
}
export function prettyPrintOwnerKeys(
owner: Account,
name: string
): void {
console.info("============");
console.info(`${name}'s wallet's public key: ${owner.publicKey.toString()}`);
console.info("============");
console.info(`${name}'s wallet's secret, to import in Sollet: \n [${owner.secretKey.toString()}]`);
console.info("============");
}