diff --git a/ts/client/src/accounts/bookSide.ts b/ts/client/src/accounts/bookSide.ts new file mode 100644 index 000000000..3506b0bb0 --- /dev/null +++ b/ts/client/src/accounts/bookSide.ts @@ -0,0 +1,158 @@ +import { PublicKey } from "@solana/web3.js"; +import { Market, BookSideAccount, SideUtils, Side, OpenBookV2Client, LeafNode, InnerNode, U64_MAX_BN } from ".."; +import { BN } from "@coral-xyz/anchor"; +import { Order } from "../structs/order"; + +export class BookSide { + + public clusterTime: BN; + + constructor( + public market: Market, + public pubkey: PublicKey, + public account: BookSideAccount, + public side: Side) { + this.clusterTime = new BN(0); + } + + public *items(): Generator { + const fGen = this.fixedItems(); + const oPegGen = this.oraclePeggedItems(); + + let fOrderRes = fGen.next(); + let oPegOrderRes = oPegGen.next(); + + while (true) { + if (fOrderRes.value && oPegOrderRes.value) { + if (this.compareOrders(fOrderRes.value, oPegOrderRes.value)) { + yield fOrderRes.value; + fOrderRes = fGen.next(); + } else { + yield oPegOrderRes.value; + oPegOrderRes = oPegGen.next(); + } + } else if (fOrderRes.value && !oPegOrderRes.value) { + yield fOrderRes.value; + fOrderRes = fGen.next(); + } else if (!fOrderRes.value && oPegOrderRes.value) { + yield oPegOrderRes.value; + oPegOrderRes = oPegGen.next(); + } else if (!fOrderRes.value && !oPegOrderRes.value) { + break; + } + } + } + + get rootFixed() { + return this.account.roots[0]; + } + + get rootOraclePegged() { + return this.account.roots[1]; + } + + public *fixedItems(): Generator { + if (this.rootFixed.leafCount === 0) { + return; + } + const stack = [this.rootFixed.maybeNode]; + const [left, right] = this.side === SideUtils.Bid ? [1, 0] : [0, 1]; + + while (stack.length > 0) { + const index = stack.pop()!; + const node = this.account.nodes.nodes[index]; + if (node.tag === BookSide.INNER_NODE_TAG) { + const innerNode = this.toInnerNode(node.data); + stack.push(innerNode.children[right], innerNode.children[left]); + } else if (node.tag === BookSide.LEAF_NODE_TAG) { + const leafNode = this.toLeafNode(node.data); + const expiryTimestamp = leafNode.timeInForce + ? leafNode.timestamp.add(new BN(leafNode.timeInForce)) + : U64_MAX_BN; + + yield new Order( + this.market, + leafNode, + this.side, + this.clusterTime.gt(expiryTimestamp), + ); + } + } + } + + public *oraclePeggedItems(): Generator { + if (this.rootOraclePegged.leafCount === 0) { + return; + } + const stack = [this.rootOraclePegged.maybeNode]; + const [left, right] = this.side === SideUtils.Bid ? [1, 0] : [0, 1]; + + while (stack.length > 0) { + const index = stack.pop()!; + const node = this.account.nodes.nodes[index]; + if (node.tag === BookSide.INNER_NODE_TAG) { + const innerNode = this.toInnerNode(node.data); + stack.push(innerNode.children[right], innerNode.children[left]); + } else if (node.tag === BookSide.LEAF_NODE_TAG) { + const leafNode = this.toLeafNode(node.data); + const expiryTimestamp = leafNode.timeInForce + ? leafNode.timestamp.add(new BN(leafNode.timeInForce)) + : U64_MAX_BN; + + yield new Order( + this.market, + leafNode, + this.side, + this.clusterTime.gt(expiryTimestamp), + true, + ); + } + } + } + + public compareOrders(a: Order, b: Order): boolean { + return a.priceLots.eq(b.priceLots) + ? a.seqNum.lt(b.seqNum) // if prices are equal prefer orders in the order they are placed + : this.side === SideUtils.Bid // else compare the actual prices + ? a.priceLots.gt(b.priceLots) + : b.priceLots.gt(a.priceLots); + } + + public best(): Order | undefined { + return this.items().next().value; + } + + public getL2(depth: number): [number, number, BN, BN][] { + const levels: [BN, BN][] = []; + for (const { priceLots, sizeLots } of this.items()) { + if (levels.length > 0 && levels[levels.length - 1][0].eq(priceLots)) { + levels[levels.length - 1][1].iadd(sizeLots); + } else if (levels.length === depth) { + break; + } else { + levels.push([priceLots, sizeLots]); + } + } + return levels.map(([priceLots, sizeLots]) => [ + this.market.priceLotsToUi(priceLots), + this.market.baseLotsToUi(sizeLots), + priceLots, + sizeLots, + ]); + } + + private static INNER_NODE_TAG = 1; + private static LEAF_NODE_TAG = 2; + + private toInnerNode(data: number[]): InnerNode { + return (this.market.client.program as any)._coder.types.typeLayouts + .get('InnerNode') + .decode(Buffer.from([BookSide.INNER_NODE_TAG].concat(data))); + } + private toLeafNode(data: number[]): LeafNode { + return (this.market.client.program as any)._coder.types.typeLayouts + .get('LeafNode') + .decode(Buffer.from([BookSide.LEAF_NODE_TAG].concat(data))); + } + +} diff --git a/ts/client/src/accounts/market.ts b/ts/client/src/accounts/market.ts new file mode 100644 index 000000000..b990f27a6 --- /dev/null +++ b/ts/client/src/accounts/market.ts @@ -0,0 +1,126 @@ +import Big from 'big.js'; +import { BN } from '@coral-xyz/anchor'; +import { PublicKey } from "@solana/web3.js"; +import { toNative, MarketAccount, OpenBookV2Client, BookSideAccount, BookSide, SideUtils, nameToString } from '..'; + +export class Market { + public minOrderSize: number; + public tickSize: number; + public quoteLotFactor: number; + + /** + * use async loadBids() or loadOrderBook() to populate bids + */ + public bids: BookSide | undefined; + + /** + * use async loadAsks() or loadOrderBook() to populate asks + */ + public asks: BookSide | undefined; + + constructor(public client: OpenBookV2Client, public pubkey: PublicKey, public account: MarketAccount) { + this.minOrderSize = new Big(10) + .pow(account.baseDecimals - account.quoteDecimals) + .mul(new Big(account.quoteLotSize.toString())) + .div(new Big(account.baseLotSize.toString())) + .toNumber(); + this.quoteLotFactor = new Big(account.quoteLotSize.toString()) + .div(new Big(10).pow(account.quoteDecimals)) + .toNumber(); + this.tickSize = new Big(10) + .pow(account.baseDecimals - account.quoteDecimals) + .mul(new Big(account.quoteLotSize.toString())) + .div(new Big(account.baseLotSize.toString())) + .toNumber(); + } + + public static async load(client: OpenBookV2Client, pubkey: PublicKey): Promise { + const account = await client.program.account.market.fetch(pubkey); + return new Market(client, pubkey, account); + } + + public baseLotsToUi(lots: BN): number { + return Number(lots.toString()) * this.minOrderSize; + } + public quoteLotsToUi(lots: BN): number { + return Number(lots.toString()) * this.quoteLotFactor; + } + public priceLotsToUi(lots: BN): number { + return Number(lots.toString()) * this.tickSize; + } + + public baseUiToLots(uiAmount: number): BN { + return toNative(uiAmount, this.account.baseDecimals).div(this.account.baseLotSize); + } + public quoteUiToLots(uiAmount: number): BN { + return toNative(uiAmount, this.account.quoteDecimals).div(this.account.quoteLotSize); + } + public priceUiToLots(uiAmount: number): BN { + return toNative(uiAmount, this.account.quoteDecimals) + .imul(this.account.baseLotSize) + .div(new BN(Math.pow(10, this.account.baseDecimals)) + .imul(this.account.quoteLotSize)); + } + + public async loadBids(): Promise { + const bidSide = await this.client.program.account.bookSide.fetch(this.account.bids) as BookSideAccount; + this.bids = new BookSide(this, this.account.bids, bidSide, SideUtils.Bid); + return this.bids; + } + + public async loadAsks(): Promise { + const askSide = await this.client.program.account.bookSide.fetch(this.account.asks) as BookSideAccount; + this.asks = new BookSide(this, this.account.asks, askSide, SideUtils.Ask); + return this.asks; + } + + public async loadOrderBook(): Promise { + await Promise.all([this.loadBids(), this.loadAsks()]); + return this; + } + + public toPrettyString(): string { + const mkt = this.account; + let debug = `Market: ${nameToString(mkt.name)}\n`; + debug += ` authority: ${mkt.marketAuthority.toBase58()}\n`; + debug += ` collectFeeAdmin: ${mkt.collectFeeAdmin.toBase58()}\n`; + if (!mkt.openOrdersAdmin.key.equals(PublicKey.default)) + debug += ` openOrdersAdmin: ${mkt.openOrdersAdmin.key.toBase58()}\n`; + if (!mkt.consumeEventsAdmin.key.equals(PublicKey.default)) + debug += ` consumeEventsAdmin: ${mkt.consumeEventsAdmin.key.toBase58()}\n`; + if (!mkt.closeMarketAdmin.key.equals(PublicKey.default)) + debug += ` closeMarketAdmin: ${mkt.closeMarketAdmin.key.toBase58()}\n`; + + debug += ` baseMint: ${mkt.baseMint.toBase58()}\n`; + debug += ` quoteMint: ${mkt.quoteMint.toBase58()}\n`; + debug += ` marketBaseVault: ${mkt.marketBaseVault.toBase58()}\n`; + debug += ` marketQuoteVault: ${mkt.marketQuoteVault.toBase58()}\n`; + + if (!mkt.oracleA.key.equals(PublicKey.default)) { + debug += ` oracleConfig: { confFilter: ${mkt.oracleConfig.confFilter}, maxStalenessSlots: ${mkt.oracleConfig.maxStalenessSlots.toString()} }\n`; + debug += ` oracleA: ${mkt.oracleA.key.toBase58()}\n`; + } + if (!mkt.oracleB.key.equals(PublicKey.default)) + debug += ` oracleB: ${mkt.oracleB.key.toBase58()}\n`; + + debug += ` bids: ${mkt.bids.toBase58()}\n`; + const bb = this.bids?.best(); + if (bb) { + debug += ` best: ${bb.price} ${bb.size} ${bb.leafNode.owner.toBase58()}\n`; + } + + debug += ` asks: ${mkt.asks.toBase58()}\n`; + const ba = this.asks?.best(); + if (ba) { + debug += ` best: ${ba.price} ${ba.size} ${ba.leafNode.owner.toBase58()}\n`; + } + + debug += ` eventHeap: ${mkt.eventHeap.toBase58()}\n`; + + debug += ` minOrderSize: ${this.minOrderSize}\n`; + debug += ` tickSize: ${this.tickSize}\n`; + + return debug; + + } +} \ No newline at end of file diff --git a/ts/client/src/accounts/openOrders.ts b/ts/client/src/accounts/openOrders.ts new file mode 100644 index 000000000..1a4a4df11 --- /dev/null +++ b/ts/client/src/accounts/openOrders.ts @@ -0,0 +1,71 @@ +import { Keypair, PublicKey, Signer, TransactionInstruction } from "@solana/web3.js"; +import { OpenOrdersAccount, PlaceOrderType, SelfTradeBehavior as SelfTradeBehaviorType, Side } from "../client"; +import { Market } from "./market"; +import { PlaceOrderTypeUtils, SelfTradeBehaviorUtils, U64_MAX_BN } from "../utils/utils"; +import { BN } from "@coral-xyz/anchor"; + +export interface OrderToPlace { + side: Side; + price: number; + size: number; + quoteLimit?: number; + clientOrderId?: number; + orderType?: PlaceOrderType; + expiryTimestamp?: number; + selfTradeBehavior?: SelfTradeBehaviorType; + matchLoopLimit?: number; +} + +export class OpenOrders { + public delegate: Keypair | undefined; + + constructor( + public pubkey: PublicKey, + public account: OpenOrdersAccount, + public market: Market) {} + + public setDelegate(delegate: Keypair): this { + this.delegate = delegate; + return this; + } + + public async placeOrderIx( + order: OrderToPlace, + userTokenAccount: PublicKey, + remainingAccounts: PublicKey[] = [], + ): Promise<[TransactionInstruction, Signer[]]> { + const priceLots = this.market.priceUiToLots(order.price); + const maxBaseLots = this.market.baseUiToLots(order.size); + const maxQuoteLotsIncludingFees = order.quoteLimit ? new BN(order.quoteLimit) : U64_MAX_BN; + const clientOrderId = new BN(order.clientOrderId || Date.now()); + const orderType = order.orderType || PlaceOrderTypeUtils.Limit; + const expiryTimestamp = new BN(order.expiryTimestamp || -1); + const selfTradeBehavior = order.selfTradeBehavior || SelfTradeBehaviorUtils.DecrementTake; + const limit = order.matchLoopLimit || 16; + + const args = { + side: order.side, + priceLots, + maxBaseLots, + maxQuoteLotsIncludingFees, + clientOrderId, + orderType, + expiryTimestamp, + selfTradeBehavior, + limit, + }; + + return await this.market.client.placeOrderIx( + this.pubkey, + this.market.pubkey, + this.market.account, + userTokenAccount, + null, + args, + remainingAccounts, + this.delegate); + } + + +} + diff --git a/ts/client/src/client.ts b/ts/client/src/client.ts index f652c1f4c..87db9e4e6 100644 --- a/ts/client/src/client.ts +++ b/ts/client/src/client.ts @@ -35,6 +35,7 @@ export type IdsSource = 'api' | 'static' | 'get-program-accounts'; export type PlaceOrderArgs = IdlTypes['PlaceOrderArgs']; export type PlaceOrderType = IdlTypes['PlaceOrderType']; export type Side = IdlTypes['Side']; +export type SelfTradeBehavior = IdlTypes['SelfTradeBehavior']; export type PlaceOrderPeggedArgs = IdlTypes['PlaceOrderPeggedArgs']; export type PlaceMultipleOrdersArgs = IdlTypes['PlaceMultipleOrdersArgs']; @@ -47,6 +48,7 @@ export type OpenOrdersIndexerAccount = export type EventHeapAccount = IdlAccounts['eventHeap']; export type BookSideAccount = IdlAccounts['bookSide']; export type LeafNode = IdlTypes['LeafNode']; +export type InnerNode = IdlTypes['InnerNode']; export type AnyNode = IdlTypes['AnyNode']; export type FillEvent = IdlTypes['FillEvent']; export type OutEvent = IdlTypes['OutEvent']; @@ -171,16 +173,6 @@ export class OpenBookV2Client { return [ix, address]; } - // Get the MarketAccount from the market publicKey - public async deserializeMarketAccount( - publicKey: PublicKey, - ): Promise { - try { - return await this.program.account.market.fetch(publicKey); - } catch { - return null; - } - } public async deserializeOpenOrderAccount( publicKey: PublicKey, @@ -212,37 +204,6 @@ export class OpenBookV2Client { } } - public async deserializeBookSide( - publicKey: PublicKey, - ): Promise { - try { - return await this.program.account.bookSide.fetch(publicKey); - } catch { - return null; - } - } - - public priceData(key: BN): number { - const shiftedValue = key.shrn(64); // Shift right by 64 bits - return shiftedValue.toNumber(); // Convert BN to a regular number - } - - // Get bids or asks from a bookside account - public getLeafNodes(bookside: BookSideAccount): LeafNode[] { - const leafNodesData = bookside.nodes.nodes.filter( - (x: AnyNode) => x.tag === 2, - ); - const leafNodes: LeafNode[] = []; - for (const x of leafNodesData) { - const leafNode: LeafNode = this.program.coder.types.decode( - 'LeafNode', - Buffer.from([0, ...x.data]), - ); - leafNodes.push(leafNode); - } - return leafNodes; - } - public async createMarketIx( payer: PublicKey, name: string, @@ -654,7 +615,7 @@ export class OpenBookV2Client { oracleB: market.oracleB.key, userTokenAccount, tokenProgram: TOKEN_PROGRAM_ID, - openOrdersAdmin, + openOrdersAdmin: market.openOrdersAdmin.key, }) .remainingAccounts(accountsMeta) .instruction(); diff --git a/ts/client/src/index.ts b/ts/client/src/index.ts index a561c8dd6..512a5a3f3 100644 --- a/ts/client/src/index.ts +++ b/ts/client/src/index.ts @@ -1,6 +1,9 @@ import { IDL, type OpenbookV2 } from './openbook_v2'; export * from './client'; +export * from './accounts/bookSide'; +export * from './accounts/market'; +export * from './accounts/openOrders'; export * from './market'; export * from './utils/utils'; diff --git a/ts/client/src/market.ts b/ts/client/src/market.ts index 35fc62aaa..d9ea16d85 100644 --- a/ts/client/src/market.ts +++ b/ts/client/src/market.ts @@ -8,6 +8,7 @@ import { type MarketAccount, OPENBOOK_PROGRAM_ID, getFilteredProgramAccounts, + nameToString, } from './client'; import { utils, diff --git a/ts/client/src/structs/order.ts b/ts/client/src/structs/order.ts new file mode 100644 index 000000000..ce34af414 --- /dev/null +++ b/ts/client/src/structs/order.ts @@ -0,0 +1,39 @@ +import { BN } from "@coral-xyz/anchor"; +import { LeafNode, Market, Side, SideUtils, U64_MAX_BN } from ".."; + + +export class Order { + + public seqNum: BN; + public priceLots: BN; + + constructor( + public market: Market, + public leafNode: LeafNode, + public side: Side, + public isExpired = false, + public isOraclePegged = false + ){ + this.seqNum = this.side === SideUtils.Bid ? U64_MAX_BN.sub(this.leafNode.key.maskn(64)) : this.leafNode.key.maskn(64); + const priceData = this.leafNode.key.ushrn(64); + if (this.isOraclePegged) { + const priceOffset = priceData.sub(new BN(1).ushln(63)); + throw new Error("Not implemented yet"); + // TODO: add oracle price logic to Market + } else { + this.priceLots = priceData; + } + } + + get price(): number { + return this.market.priceLotsToUi(this.priceLots); + } + + get size(): number { + return this.market.baseLotsToUi(this.leafNode.quantity); + } + + get sizeLots(): BN { + return this.leafNode.quantity; + } +} \ No newline at end of file diff --git a/ts/client/src/test/market.ts b/ts/client/src/test/market.ts index 77598f83b..670263f5e 100644 --- a/ts/client/src/test/market.ts +++ b/ts/client/src/test/market.ts @@ -1,6 +1,5 @@ import { PublicKey } from "@solana/web3.js"; -import { OPENBOOK_PROGRAM_ID, findAccountsByMints, findAllMarkets, initReadOnlyOpenbookClient } from ".."; - +import { Market, MarketAccount, OPENBOOK_PROGRAM_ID,findAccountsByMints, findAllMarkets, initReadOnlyOpenbookClient } from ".."; async function testFindAccountsByMints(): Promise { const client = initReadOnlyOpenbookClient(process.env.SOL_RPC_URL as any); @@ -14,5 +13,15 @@ async function testFindAllMarkets(): Promise { console.log('markets', markets); } -void testFindAccountsByMints(); -void testFindAllMarkets(); \ No newline at end of file +async function testDecodeMarket(): Promise { + const client = initReadOnlyOpenbookClient(process.env.SOL_RPC_URL as any); + const marketPk = new PublicKey("CFSMrBssNG8Ud1edW59jNLnq2cwrQ9uY5cM3wXmqRJj3"); + const market = await Market.load(client, marketPk); + await market.loadOrderBook(); + + console.log(market.toPrettyString()); +} + +// void testFindAccountsByMints(); +// void testFindAllMarkets(); +void testDecodeMarket(); diff --git a/ts/client/src/utils/utils.ts b/ts/client/src/utils/utils.ts index a8fdbfcf1..2371419ce 100644 --- a/ts/client/src/utils/utils.ts +++ b/ts/client/src/utils/utils.ts @@ -18,7 +18,7 @@ export const SideUtils = { Ask: { ask: {} }, }; -export const OrderType = { +export const PlaceOrderTypeUtils = { Limit: { limit: {} }, ImmediateOrCancel: { immediateOrCancel: {} }, PostOnly: { postOnly: {} }, @@ -26,7 +26,7 @@ export const OrderType = { PostOnlySlide: { postOnlySlide: {} }, }; -export const SelfTradeBehavior = { +export const SelfTradeBehaviorUtils = { DecrementTake: { decrementTake: {} }, CancelProvide: { cancelProvide: {} }, AbortTransaction: { abortTransaction: {} }, @@ -37,6 +37,7 @@ export const SelfTradeBehavior = { /// export const U64_MAX_BN = new BN('18446744073709551615'); export const I64_MAX_BN = new BN('9223372036854775807').toTwos(64); +export const ORDER_FEE_UNIT: BN = new BN(1e6); export function bpsToDecimal(bps: number): number { return bps / 10000; @@ -47,7 +48,7 @@ export function percentageToDecimal(percentage: number): number { } export function toNative(uiAmount: number, decimals: number): BN { - return new BN((uiAmount * Math.pow(10, decimals)).toFixed(0)); + return new BN(Math.round(uiAmount * Math.pow(10, decimals))); } export function toUiDecimals(nativeAmount: number, decimals: number): number {