@cks-systems/manifest-sdk
Version:
TypeScript SDK for Manifest
755 lines (701 loc) • 22.4 kB
text/typescript
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] };
}
}