diff --git a/sdk/examples/phoenix.ts b/sdk/examples/phoenix.ts new file mode 100644 index 000000000..9455b9837 --- /dev/null +++ b/sdk/examples/phoenix.ts @@ -0,0 +1,40 @@ +import { Connection, PublicKey } from '@solana/web3.js'; +import { PRICE_PRECISION, PhoenixSubscriber } from '../src'; +import { PROGRAM_ID } from '@ellipsis-labs/phoenix-sdk'; + +export async function listenToBook(): Promise { + const connection = new Connection('https://api.mainnet-beta.solana.com'); + + const phoenixSubscriber = new PhoenixSubscriber({ + connection, + programId: PROGRAM_ID, + marketAddress: new PublicKey( + '4DoNfFBfF7UokCC2FQzriy7yHK6DY6NVdYpuekQ5pRgg' + ), + accountSubscription: { + type: 'websocket', + }, + }); + + await phoenixSubscriber.subscribe(); + + for (let i = 0; i < 10; i++) { + const bid = phoenixSubscriber.getBestBid().toNumber() / PRICE_PRECISION; + const ask = phoenixSubscriber.getBestAsk().toNumber() / PRICE_PRECISION; + console.log(`iter ${i}:`, bid.toFixed(3), '@', ask.toFixed(3)); + await new Promise((r) => setTimeout(r, 2000)); + } + + await phoenixSubscriber.unsubscribe(); +} + +(async function () { + try { + await listenToBook(); + } catch (err) { + console.log('Error: ', err); + process.exit(1); + } + + process.exit(0); +})(); diff --git a/sdk/package.json b/sdk/package.json index 98ccfc3e4..3adac09b8 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -36,6 +36,7 @@ "@coral-xyz/anchor": "0.26.0", "@ellipsis-labs/phoenix-sdk": "^1.3.2", "@project-serum/serum": "^0.13.38", + "@ellipsis-labs/phoenix-sdk": "^1.3.1", "@pythnetwork/client": "2.5.3", "@solana/spl-token": "^0.1.6", "@solana/web3.js": "1.73.2", diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 97cab53c7..0dcbe45f6 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -55,6 +55,8 @@ export * from './config'; export * from './constants/numericConstants'; export * from './serum/serumSubscriber'; export * from './serum/serumFulfillmentConfigMap'; +export * from './phoenix/phoenixSubscriber'; +export * from './phoenix/phoenixFulfillmentConfigMap'; export * from './tx/retryTxSender'; export * from './util/computeUnits'; export * from './util/tps'; diff --git a/sdk/src/phoenix/phoenixFulfillmentConfigMap.ts b/sdk/src/phoenix/phoenixFulfillmentConfigMap.ts new file mode 100644 index 000000000..0708cc196 --- /dev/null +++ b/sdk/src/phoenix/phoenixFulfillmentConfigMap.ts @@ -0,0 +1,26 @@ +import { PublicKey } from '@solana/web3.js'; +import { PhoenixV1FulfillmentConfigAccount } from '../types'; +import { DriftClient } from '../driftClient'; + +export class PhoenixFulfillmentConfigMap { + driftClient: DriftClient; + map = new Map(); + + public constructor(driftClient: DriftClient) { + this.driftClient = driftClient; + } + + public async add( + marketIndex: number, + phoenixMarketAddress: PublicKey + ): Promise { + const account = await this.driftClient.getPhoenixV1FulfillmentConfig( + phoenixMarketAddress + ); + this.map.set(marketIndex, account); + } + + public get(marketIndex: number): PhoenixV1FulfillmentConfigAccount { + return this.map.get(marketIndex); + } +} diff --git a/sdk/src/phoenix/phoenixSubscriber.ts b/sdk/src/phoenix/phoenixSubscriber.ts new file mode 100644 index 000000000..76aeb549a --- /dev/null +++ b/sdk/src/phoenix/phoenixSubscriber.ts @@ -0,0 +1,156 @@ +import { Connection, PublicKey, SYSVAR_CLOCK_PUBKEY } from '@solana/web3.js'; +import { BulkAccountLoader } from '../accounts/bulkAccountLoader'; +import { + MarketData, + Client, + deserializeMarketData, + deserializeClockData, + toNum, + getMarketUiLadder, +} from '@ellipsis-labs/phoenix-sdk'; +import { PRICE_PRECISION } from '../constants/numericConstants'; +import { BN } from '@coral-xyz/anchor'; + +export type PhoenixMarketSubscriberConfig = { + connection: Connection; + programId: PublicKey; + marketAddress: PublicKey; + accountSubscription: + | { + // enables use to add web sockets in the future + type: 'polling'; + accountLoader: BulkAccountLoader; + } + | { + type: 'websocket'; + }; +}; + +export class PhoenixSubscriber { + connection: Connection; + client: Client; + programId: PublicKey; + marketAddress: PublicKey; + subscriptionType: 'polling' | 'websocket'; + accountLoader: BulkAccountLoader | undefined; + market: MarketData; + marketCallbackId: string | number; + clockCallbackId: string | number; + + subscribed: boolean; + lastSlot: number; + lastUnixTimestamp: number; + + public constructor(config: PhoenixMarketSubscriberConfig) { + this.connection = config.connection; + this.programId = config.programId; + this.marketAddress = config.marketAddress; + if (config.accountSubscription.type === 'polling') { + this.subscriptionType = 'polling'; + this.accountLoader = config.accountSubscription.accountLoader; + } else { + this.subscriptionType = 'websocket'; + } + this.lastSlot = 0; + this.lastUnixTimestamp = 0; + } + + public async subscribe(): Promise { + if (this.subscribed) { + return; + } + + this.market = deserializeMarketData( + (await this.connection.getAccountInfo(this.marketAddress, 'confirmed')) + .data + ); + + const clock = deserializeClockData( + (await this.connection.getAccountInfo(SYSVAR_CLOCK_PUBKEY, 'confirmed')) + .data + ); + this.lastUnixTimestamp = toNum(clock.unixTimestamp); + + if (this.subscriptionType === 'websocket') { + this.marketCallbackId = this.connection.onAccountChange( + this.marketAddress, + (accountInfo, _ctx) => { + this.market = deserializeMarketData(accountInfo.data); + } + ); + this.clockCallbackId = this.connection.onAccountChange( + SYSVAR_CLOCK_PUBKEY, + (accountInfo, ctx) => { + this.lastSlot = ctx.slot; + const clock = deserializeClockData(accountInfo.data); + this.lastUnixTimestamp = toNum(clock.unixTimestamp); + } + ); + } else { + this.marketCallbackId = await this.accountLoader.addAccount( + this.marketAddress, + (buffer, slot) => { + this.lastSlot = slot; + this.market = deserializeMarketData(buffer); + } + ); + this.clockCallbackId = await this.accountLoader.addAccount( + SYSVAR_CLOCK_PUBKEY, + (buffer, slot) => { + this.lastSlot = slot; + const clock = deserializeClockData(buffer); + this.lastUnixTimestamp = toNum(clock.unixTimestamp); + } + ); + } + + this.subscribed = true; + } + + public getBestBid(): BN { + const ladder = getMarketUiLadder( + this.market, + this.lastSlot, + this.lastUnixTimestamp, + 1 + ); + return new BN(Math.floor(ladder.bids[0][0] * PRICE_PRECISION)); + } + + public getBestAsk(): BN { + const ladder = getMarketUiLadder( + this.market, + this.lastSlot, + this.lastUnixTimestamp, + 1 + ); + return new BN(Math.floor(ladder.asks[0][0] * PRICE_PRECISION)); + } + + public async unsubscribe(): Promise { + if (!this.subscribed) { + return; + } + + // remove listeners + if (this.subscriptionType === 'websocket') { + await this.connection.removeAccountChangeListener( + this.marketCallbackId as number + ); + await this.connection.removeAccountChangeListener( + this.clockCallbackId as number + ); + } else { + this.accountLoader.removeAccount( + this.marketAddress, + this.marketCallbackId as string + ); + this.accountLoader.removeAccount( + SYSVAR_CLOCK_PUBKEY, + this.clockCallbackId as string + ); + } + + this.subscribed = false; + } +}