UNPKG

@cks-systems/manifest-sdk

Version:
755 lines (701 loc) 22.4 kB
import { PublicKey, Connection, TransactionInstruction, Keypair, Signer, SystemProgram, RpcResponseAndContext, AccountInfo, } from '@solana/web3.js'; import { bignum } from '@metaplex-foundation/beet'; import { publicKeyBeet } from './utils/beet'; import { publicKey as beetPublicKey } from '@metaplex-foundation/beet-solana'; import { deserializeRedBlackTree } from './utils/redBlackTree'; import { convertU128, toNum } from './utils/numbers'; import { FIXED_MANIFEST_HEADER_SIZE, NIL, NO_EXPIRATION_LAST_VALID_SLOT, } from './constants'; import { claimedSeatBeet, ClaimedSeat as ClaimedSeatRaw, createCreateMarketInstruction, OrderType, PROGRAM_ID, restingOrderBeet, RestingOrder as RestingOrderRaw, } from './manifest'; import { getVaultAddress } from './utils/market'; import { TOKEN_2022_PROGRAM_ID } from '@solana/spl-token'; import BN from 'bn.js'; /** * RestingOrder on the market. */ export type RestingOrder = { /** Trader public key. */ trader: PublicKey; /** Number of base tokens remaining in the order. */ numBaseTokens: bignum; /** Last slot before this order is invalid and will be removed. */ lastValidSlot: bignum; /** Exchange defined sequenceNumber for this order, guaranteed to be unique. */ sequenceNumber: bignum; /** Price as float in tokens of quote per tokens of base. */ tokenPrice: number; /** OrderType: 🌎 or Limit or PostOnly */ orderType: OrderType; }; /** * ClaimedSeat on the market. */ export type ClaimedSeat = { /** Public key of the trader. */ publicKey: PublicKey; /** Balance of base atoms that are withdrawable (excluding in open orders). */ baseBalance: bignum; /** Balance of quote atoms that are withdrawable (excluding in open orders). */ quoteBalance: bignum; }; /** * MarketData is all information stored on a market account. */ export interface MarketData { /** Version of the struct, included in case features are added later which use the padding. */ version: number; /** Number of decimals for the baseMint (i.e. baseMintDecimals = 6 -> 1 baseAtom = .000001 baseToken). */ baseMintDecimals: number; /** Number of decimals for the quoteMint (i.e. quoteMintDecimals = 6 -> 1 quoteAtom = .000001 quoteToken). */ quoteMintDecimals: number; /** Public key for the base mint. */ baseMint: PublicKey; /** Public key for the quote mint. */ quoteMint: PublicKey; /** Current next order sequence number. */ orderSequenceNumber: bigint; /** Number of bytes used in the dynamic portion of the market account. */ numBytesAllocated: number; /** Sorted array of resting orders for bids currently on the orderbook. */ bids: RestingOrder[]; /** Sorted array of resting orders for asks currently on the orderbook. */ asks: RestingOrder[]; /** Array of all claimed seats. */ claimedSeats: ClaimedSeat[]; /** Quote volume in atoms. */ quoteVolumeAtoms: bigint; } /** * Market object used for reading data from a manifest market. */ export class Market { /** Public key for the market account. */ address: PublicKey; /** Deserialized data. */ private data: MarketData; /** Last updated slot. */ private slot: number; /** * Constructs a Market object. * * @param address The `PublicKey` of the market account * @param data Deserialized market data */ private constructor({ address, data, slot, }: { address: PublicKey; data: MarketData; slot: number; }) { this.address = address; this.data = data; this.slot = slot; } /** * Returns a `Market` for a given address, a data buffer * * @param marketAddress The `PublicKey` of the market account * @param buffer The buffer holding the market account data */ static loadFromBuffer({ address, buffer, slot, }: { address: PublicKey; buffer: Buffer; slot?: number; }): Market { const marketData = Market.deserializeMarketBuffer( buffer, slot ?? NO_EXPIRATION_LAST_VALID_SLOT, ); // When we are not given a slot, pretend it is time zero to show everything. return new Market({ address, data: marketData, slot: slot ?? NO_EXPIRATION_LAST_VALID_SLOT, }); } /** * Returns a `Market` for a given address, a data buffer * * @param connection The Solana `Connection` object * @param address The `PublicKey` of the market account */ static async loadFromAddress({ connection, address, }: { connection: Connection; address: PublicKey; }): Promise<Market> { const [buffer, slot]: [Buffer | undefined, number] = await connection .getAccountInfoAndContext(address) .then( ( getAccountInfoAndContext: RpcResponseAndContext<AccountInfo<Buffer> | null>, ) => { return [ getAccountInfoAndContext.value?.data, getAccountInfoAndContext.context.slot, ]; }, ); if (buffer === undefined) { throw new Error(`Failed to load ${address}`); } return Market.loadFromBuffer({ address, buffer, slot }); } /** * Updates the data in a Market. * * @param connection The Solana `Connection` object */ public async reload(connection: Connection): Promise<void> { const [buffer, slot]: [Buffer | undefined, number] = await connection .getAccountInfoAndContext(this.address) .then( ( getAccountInfoAndContext: RpcResponseAndContext<AccountInfo<Buffer> | null>, ) => { return [ getAccountInfoAndContext.value?.data, getAccountInfoAndContext.context.slot, ]; }, ); if (buffer === undefined) { throw new Error(`Failed to load ${this.address}`); } this.slot = slot; this.data = Market.deserializeMarketBuffer(buffer, slot); } /** * Get the amount in tokens of balance that is deposited on this market, does * not include tokens currently in open orders. * * @param trader PublicKey of the trader to check balance of * @param isBase boolean for whether this is checking base or quote * * @returns number in tokens */ public getWithdrawableBalanceTokens( trader: PublicKey, isBase: boolean, ): number { const filteredSeats = this.data.claimedSeats.filter((claimedSeat) => { return claimedSeat.publicKey.equals(trader); }); // No seat claimed. if (filteredSeats.length == 0) { return 0; } const seat: ClaimedSeat = filteredSeats[0]; const withdrawableBalance = isBase ? toNum(seat.baseBalance) / 10 ** this.baseDecimals() : toNum(seat.quoteBalance) / 10 ** this.quoteDecimals(); return withdrawableBalance; } /** * Get the amount in tokens of balance that is deposited on this market, split * by base, quote, and whether in orders or not for the whole market. * * @returns { * baseWithdrawableBalanceAtoms: number, * quoteWithdrawableBalanceAtoms: number, * baseOpenOrdersBalanceAtoms: number, * quoteOpenOrdersBalanceAtoms: number * } */ public getMarketBalances(): { baseWithdrawableBalanceAtoms: number; quoteWithdrawableBalanceAtoms: number; baseOpenOrdersBalanceAtoms: number; quoteOpenOrdersBalanceAtoms: number; } { const asks: RestingOrder[] = this.asks(); const bids: RestingOrder[] = this.bids(); const quoteOpenOrdersBalanceAtoms: number = bids .filter((restingOrder: RestingOrder) => { return restingOrder.orderType != OrderType.Global; }) .map((restingOrder: RestingOrder) => { return Math.ceil( Number(restingOrder.numBaseTokens) * restingOrder.tokenPrice * 10 ** this.data.quoteMintDecimals - // Force float precision to not round up on an integer. 0.00001, ); }) .reduce((sum, current) => sum + current, 0); const baseOpenOrdersBalanceAtoms: number = asks .filter((restingOrder: RestingOrder) => { return restingOrder.orderType != OrderType.Global; }) .map((restingOrder: RestingOrder) => { return ( Number(restingOrder.numBaseTokens) * 10 ** this.data.baseMintDecimals ); }) .reduce((sum, current) => sum + current, 0); const quoteWithdrawableBalanceAtoms: number = this.data.claimedSeats .map((claimedSeat: ClaimedSeat) => { return Number(claimedSeat.quoteBalance); }) .reduce((sum, current) => sum + current, 0); const baseWithdrawableBalanceAtoms: number = this.data.claimedSeats .map((claimedSeat: ClaimedSeat) => { return Number(claimedSeat.baseBalance); }) .reduce((sum, current) => sum + current, 0); return { baseWithdrawableBalanceAtoms, quoteWithdrawableBalanceAtoms, baseOpenOrdersBalanceAtoms, quoteOpenOrdersBalanceAtoms, }; } /** * Get the amount in tokens of balance that is deposited on this market, split * by base, quote, and whether in orders or not. * * @param trader PublicKey of the trader to check balance of * * @returns { * baseWithdrawableBalanceTokens: number, * quoteWithdrawableBalanceTokens: number, * baseOpenOrdersBalanceTokens: number, * quoteOpenOrdersBalanceTokens: number * } */ public getBalances(trader: PublicKey): { baseWithdrawableBalanceTokens: number; quoteWithdrawableBalanceTokens: number; baseOpenOrdersBalanceTokens: number; quoteOpenOrdersBalanceTokens: number; } { const filteredSeats = this.data.claimedSeats.filter((claimedSeat) => { return claimedSeat.publicKey.equals(trader); }); // No seat claimed. if (filteredSeats.length == 0) { return { baseWithdrawableBalanceTokens: 0, quoteWithdrawableBalanceTokens: 0, baseOpenOrdersBalanceTokens: 0, quoteOpenOrdersBalanceTokens: 0, }; } const seat: ClaimedSeat = filteredSeats[0]; const asks: RestingOrder[] = this.asks(); const bids: RestingOrder[] = this.bids(); const baseOpenOrdersBalanceTokens: number = asks .filter((ask) => ask.trader.equals(trader)) .reduce((sum, ask) => sum + Number(ask.numBaseTokens), 0); const quoteOpenOrdersBalanceTokens: number = bids .filter((bid) => bid.trader.equals(trader)) .reduce( (sum, bid) => sum + Number(bid.numBaseTokens) * Number(bid.tokenPrice), 0, ); const quoteWithdrawableBalanceTokens: number = toNum(seat.quoteBalance) / 10 ** this.quoteDecimals(); const baseWithdrawableBalanceTokens: number = toNum(seat.baseBalance) / 10 ** this.baseDecimals(); return { baseWithdrawableBalanceTokens, quoteWithdrawableBalanceTokens, baseOpenOrdersBalanceTokens, quoteOpenOrdersBalanceTokens, }; } /** * Gets the base mint of the market * * @returns PublicKey */ public baseMint(): PublicKey { return this.data.baseMint; } /** * Gets the quote mint of the market * * @returns PublicKey */ public quoteMint(): PublicKey { return this.data.quoteMint; } /** * Gets the base decimals of the market * * @returns number */ public baseDecimals(): number { return this.data.baseMintDecimals; } /** * Gets the base decimals of the market * * @returns number */ public quoteDecimals(): number { return this.data.quoteMintDecimals; } /** * Check whether a given public key has a claimed seat on the market * * @param trader PublicKey of the trader * * @returns boolean */ public hasSeat(trader: PublicKey): boolean { const filteredSeats = this.data.claimedSeats.filter((claimedSeat) => { return claimedSeat.publicKey.equals(trader); }); return filteredSeats.length > 0; } /** * Get all open bids on the market. * * @returns RestingOrder[] */ public bids(): RestingOrder[] { return this.data.bids; } /** * Get all open asks on the market. * * @returns RestingOrder[] */ public asks(): RestingOrder[] { return this.data.asks; } /** * Get the most competitive bid price * * @returns number | undefined */ public bestBidPrice(): number | undefined { return this.data.bids[this.data.bids.length - 1]?.tokenPrice; } /** * Get the most competitive ask price. * * @returns number | undefined */ public bestAskPrice(): number | undefined { return this.data.asks[this.data.asks.length - 1]?.tokenPrice; } /** * Get all open bids on the market ordered from most competitive to least. * * @returns RestingOrder[] */ public bidsL2(): RestingOrder[] { return this.data.bids.slice().reverse(); } /** * Get all open asks on the market ordered from most competitive to least. * * @returns RestingOrder[] */ public asksL2(): RestingOrder[] { return this.data.asks.slice().reverse(); } /** * Get all open orders on the market. * * @returns RestingOrder[] */ public openOrders(): RestingOrder[] { return [...this.data.bids, ...this.data.asks]; } /** * Gets the quote volume traded over the lifetime of the market. * * @returns bigint */ public quoteVolume(): bigint { return this.data.quoteVolumeAtoms; } /** * Print all information loaded about the market in a human readable format. */ public prettyPrint(): void { console.log(''); console.log(`Market: ${this.address}`); console.log(`========================`); console.log(`Version: ${this.data.version}`); console.log(`BaseMint: ${this.data.baseMint.toBase58()}`); console.log(`QuoteMint: ${this.data.quoteMint.toBase58()}`); console.log(`OrderSequenceNumber: ${this.data.orderSequenceNumber}`); console.log(`NumBytesAllocated: ${this.data.numBytesAllocated}`); console.log('Bids:'); this.data.bids.forEach((bid) => { console.log( `trader: ${bid.trader} numBaseTokens: ${bid.numBaseTokens} token price: ${bid.tokenPrice} lastValidSlot: ${bid.lastValidSlot} sequenceNumber: ${bid.sequenceNumber}`, ); }); console.log('Asks:'); this.data.asks.forEach((ask) => { console.log( `trader: ${ask.trader} numBaseTokens: ${ask.numBaseTokens} token price: ${ask.tokenPrice} lastValidSlot: ${ask.lastValidSlot} sequenceNumber: ${ask.sequenceNumber}`, ); }); console.log('ClaimedSeats:'); this.data.claimedSeats.forEach((claimedSeat) => { console.log( `publicKey: ${claimedSeat.publicKey.toBase58()} baseBalance: ${claimedSeat.baseBalance} quoteBalance: ${claimedSeat.quoteBalance}`, ); }); console.log(`========================`); } /** * Deserializes market data from a given buffer and returns a `Market` object * * This includes both the fixed and dynamic parts of the market. * https://github.com/CKS-Systems/manifest/blob/main/programs/manifest/src/state/market.rs * * @param data The data buffer to deserialize * @param currentSlot Number that is the cutoff for order expiration. */ static deserializeMarketBuffer( data: Buffer, currentSlot: number, ): MarketData { let offset = 0; // Deserialize the market header const _discriminant = data.readBigUInt64LE(0); offset += 8; const version = data.readUInt8(offset); offset += 1; const baseMintDecimals = data.readUInt8(offset); offset += 1; const quoteMintDecimals = data.readUInt8(offset); offset += 1; const _baseVaultBump = data.readUInt8(offset); offset += 1; const _quoteVaultBump = data.readUInt8(offset); offset += 1; // 3 bytes of unused padding. offset += 3; const baseMint = beetPublicKey.read(data, offset); offset += beetPublicKey.byteSize; const quoteMint = beetPublicKey.read(data, offset); offset += beetPublicKey.byteSize; const _baseVault = beetPublicKey.read(data, offset); offset += beetPublicKey.byteSize; const _quoteVault = beetPublicKey.read(data, offset); offset += beetPublicKey.byteSize; const orderSequenceNumber = data.readBigUInt64LE(offset); offset += 8; const numBytesAllocated = data.readUInt32LE(offset); offset += 4; const bidsRootIndex = data.readUInt32LE(offset); offset += 4; const _bidsBestIndex = data.readUInt32LE(offset); offset += 4; const asksRootIndex = data.readUInt32LE(offset); offset += 4; const _askBestIndex = data.readUInt32LE(offset); offset += 4; const claimedSeatsRootIndex = data.readUInt32LE(offset); offset += 4; const _freeListHeadIndex = data.readUInt32LE(offset); offset += 4; const _padding2 = data.readUInt32LE(offset); offset += 4; const quoteVolumeAtoms: bigint = data.readBigUInt64LE(offset); offset += 8; // _padding3: [u64; 8], const bids: RestingOrder[] = bidsRootIndex != NIL ? deserializeRedBlackTree( data.subarray(FIXED_MANIFEST_HEADER_SIZE), bidsRootIndex, restingOrderBeet, ) .map((restingOrderInternal: RestingOrderRaw) => { return { trader: publicKeyBeet.deserialize( data.subarray( Number(restingOrderInternal.traderIndex) + 16 + FIXED_MANIFEST_HEADER_SIZE, Number(restingOrderInternal.traderIndex) + 48 + FIXED_MANIFEST_HEADER_SIZE, ), )[0].publicKey, numBaseTokens: toNum(restingOrderInternal.numBaseAtoms) / 10 ** baseMintDecimals, tokenPrice: convertU128(restingOrderInternal.price) * 10 ** (baseMintDecimals - quoteMintDecimals), ...restingOrderInternal, }; }) .filter((bid: RestingOrder) => { return ( bid.lastValidSlot == NO_EXPIRATION_LAST_VALID_SLOT || Number(bid.lastValidSlot) > currentSlot ); }) : []; const asks: RestingOrder[] = asksRootIndex != NIL ? deserializeRedBlackTree( data.subarray(FIXED_MANIFEST_HEADER_SIZE), asksRootIndex, restingOrderBeet, ) .map((restingOrderInternal: RestingOrderRaw) => { return { trader: publicKeyBeet.deserialize( data.subarray( Number(restingOrderInternal.traderIndex) + 16 + FIXED_MANIFEST_HEADER_SIZE, Number(restingOrderInternal.traderIndex) + 48 + FIXED_MANIFEST_HEADER_SIZE, ), )[0].publicKey, numBaseTokens: toNum(restingOrderInternal.numBaseAtoms) / 10 ** baseMintDecimals, tokenPrice: convertU128(restingOrderInternal.price) * 10 ** (baseMintDecimals - quoteMintDecimals), ...restingOrderInternal, }; }) .filter((ask: RestingOrder) => { return ( ask.lastValidSlot == NO_EXPIRATION_LAST_VALID_SLOT || Number(ask.lastValidSlot) > currentSlot ); }) : []; const claimedSeats: ClaimedSeat[] = claimedSeatsRootIndex != NIL ? deserializeRedBlackTree( data.subarray(FIXED_MANIFEST_HEADER_SIZE), claimedSeatsRootIndex, claimedSeatBeet, ).map((claimedSeatInternal: ClaimedSeatRaw) => { return { publicKey: claimedSeatInternal.trader, baseBalance: claimedSeatInternal.baseWithdrawableBalance, quoteBalance: claimedSeatInternal.quoteWithdrawableBalance, }; }) : []; return { version, baseMintDecimals, quoteMintDecimals, baseMint, quoteMint, orderSequenceNumber, numBytesAllocated, bids, asks, claimedSeats, quoteVolumeAtoms, }; } static async findByMints( connection: Connection, baseMint: PublicKey, quoteMint: PublicKey, ): Promise<Market[]> { // Based on the MarketFixed struct const baseMintOffset = 16; const quoteMintOffset = 48; const filters = [ { memcmp: { offset: baseMintOffset, bytes: baseMint.toBase58(), }, }, { memcmp: { offset: quoteMintOffset, bytes: quoteMint.toBase58(), }, }, ]; const accounts = await connection.getProgramAccounts(PROGRAM_ID, { filters, }); return accounts .map(({ account, pubkey }) => Market.loadFromBuffer({ address: pubkey, buffer: account.data }), ) .sort((a, b) => new BN(b.quoteVolume().toString()) .sub(new BN(a.quoteVolume().toString())) .toNumber(), ); } static async setupIxs( connection: Connection, baseMint: PublicKey, quoteMint: PublicKey, payer: PublicKey, ): Promise<{ ixs: TransactionInstruction[]; signers: Signer[] }> { const marketKeypair: Keypair = Keypair.generate(); const createAccountIx: TransactionInstruction = SystemProgram.createAccount( { fromPubkey: payer, newAccountPubkey: marketKeypair.publicKey, space: FIXED_MANIFEST_HEADER_SIZE, lamports: await connection.getMinimumBalanceForRentExemption( FIXED_MANIFEST_HEADER_SIZE, ), programId: PROGRAM_ID, }, ); const market = marketKeypair.publicKey; const baseVault = getVaultAddress(market, baseMint); const quoteVault = getVaultAddress(market, quoteMint); const createMarketIx = createCreateMarketInstruction({ payer, baseMint, quoteMint, market, baseVault, quoteVault, tokenProgram22: TOKEN_2022_PROGRAM_ID, }); return { ixs: [createAccountIx, createMarketIx], signers: [marketKeypair] }; } }