UNPKG

@drift-labs/sdk

Version:
366 lines (331 loc) 11.5 kB
import { Channel, PriceFeedProperty, PythLazerClient, } from '@pythnetwork/pyth-lazer-sdk'; import { DriftEnv } from '../config'; import { PerpMarkets } from '../constants/perpMarkets'; /** * Configuration for a group of Pyth Lazer price feeds. */ export type PythLazerPriceFeedArray = { /** Optional channel for update frequency (e.g., 'fixed_rate@200ms') */ channel?: Channel; /** Array of Pyth Lazer price feed IDs to subscribe to */ priceFeedIds: number[]; }; type FeedSymbolInfo = { name: string; state: string; }; /** * Manages subscriptions to Pyth Lazer price feeds and provides access to real-time price data. * Automatically filters out non-stable feeds and handles reconnection logic. */ export class PythLazerSubscriber { private static readonly SYMBOLS_API_URL = 'https://history.pyth-lazer.dourolabs.app/history/v1/symbols'; private symbolsCache: Map<number, FeedSymbolInfo> | null = null; private pythLazerClient?: PythLazerClient; feedIdChunkToPriceMessage: Map<string, string> = new Map(); feedIdToPrice: Map<number, number> = new Map(); feedIdHashToFeedIds: Map<string, number[]> = new Map(); subscriptionIdsToFeedIdsHash: Map<number, string> = new Map(); allSubscribedIds: number[] = []; timeoutId?: NodeJS.Timeout; receivingData = false; isUnsubscribing = false; marketIndextoPriceFeedIdChunk: Map<number, number[]> = new Map(); marketIndextoPriceFeedId: Map<number, number> = new Map(); private readonly feedProperties: PriceFeedProperty[]; /** * Creates a new PythLazerSubscriber instance. * @param endpoints - Array of WebSocket endpoint URLs for Pyth Lazer * @param token - Authentication token for Pyth Lazer API * @param priceFeedArrays - Array of price feed configurations to subscribe to * @param env - Drift environment (mainnet-beta, devnet, etc.) * @param resubTimeoutMs - Milliseconds to wait before resubscribing on data timeout * @param sdkLogging - Whether to log Pyth SDK logs to the console. This is very noisy but could be useful for debugging. * @param feedProperties - Price feed properties to request. Must include both 'price' and 'exponent' (required for getPriceFromMarketIndex). Defaults to ['price', 'bestAskPrice', 'bestBidPrice', 'exponent']. Stored by copy so caller mutation does not affect this instance. */ constructor( private endpoints: string[], private token: string, private priceFeedArrays: PythLazerPriceFeedArray[], env: DriftEnv = 'devnet', private resubTimeoutMs: number = 2000, private sdkLogging: boolean = false, feedProperties: PriceFeedProperty[] = [ 'price', 'bestAskPrice', 'bestBidPrice', 'exponent', 'feedUpdateTimestamp', ] ) { this.feedProperties = [...feedProperties]; if ( !this.feedProperties.includes('price') || !this.feedProperties.includes('exponent') ) { throw new Error( "feedProperties must include both 'price' and 'exponent' for getPriceFromMarketIndex to work" ); } const markets = PerpMarkets[env].filter( (market) => market.pythLazerId !== undefined ); this.allSubscribedIds = this.priceFeedArrays .map((array) => array.priceFeedIds) .flat(); for (const priceFeedIds of priceFeedArrays) { const filteredMarkets = markets.filter((market) => priceFeedIds.priceFeedIds.includes(market.pythLazerId!) ); for (const market of filteredMarkets) { this.marketIndextoPriceFeedIdChunk.set( market.marketIndex, priceFeedIds.priceFeedIds ); this.marketIndextoPriceFeedId.set( market.marketIndex, market.pythLazerId! ); } } } private async fetchSymbolsIfNeeded(): Promise<void> { if (this.symbolsCache !== null) return; try { const response = await fetch(PythLazerSubscriber.SYMBOLS_API_URL); if (!response.ok) throw new Error(`HTTP ${response.status}`); const symbols = await response.json(); this.symbolsCache = new Map(); for (const symbol of symbols) { this.symbolsCache.set(symbol.pyth_lazer_id, { name: symbol.name, state: symbol.state, }); } } catch (error) { console.warn( `Failed to fetch Pyth Lazer symbols, proceeding with all feeds: ${error}` ); this.symbolsCache = new Map(); // Empty map = no filtering } } private filterStableFeeds(feedIds: number[]): number[] { if (this.symbolsCache === null || this.symbolsCache.size === 0) { return feedIds; // No filtering if cache unavailable } return feedIds.filter((feedId) => { const info = this.symbolsCache!.get(feedId); if (!info) { console.warn( `Feed ID ${feedId} not found in symbols API, including anyway` ); return true; } if (info.state !== 'stable') { console.warn( `Removing feed ID ${feedId} (${info.name}) - state is "${info.state}", not "stable"` ); return false; } return true; }); } /** * Subscribes to Pyth Lazer price feeds. Automatically filters out non-stable feeds * and establishes WebSocket connections for real-time price updates. */ async subscribe() { await this.fetchSymbolsIfNeeded(); this.pythLazerClient = await PythLazerClient.create({ token: this.token, logger: this.sdkLogging ? console : undefined, webSocketPoolConfig: { urls: this.endpoints, numConnections: 4, // Optionally specify number of parallel redundant connections to reduce the chance of dropped messages. The connections will round-robin across the provided URLs. Default is 4. onError: (error) => { console.error('⛔️ PythLazerClient error:', error.message); }, onWebSocketError: (error) => { console.error('⛔️ WebSocket error:', error.message); }, onWebSocketPoolError: (error) => { console.error('⛔️ WebSocket pool error:', error.message); }, // Optional configuration for resilient WebSocket connections rwsConfig: { heartbeatTimeoutDurationMs: 5000, // Optional heartbeat timeout duration in milliseconds maxRetryDelayMs: 1000, // Optional maximum retry delay in milliseconds logAfterRetryCount: 10, // Optional log after how many retries }, }, }); // Reset allSubscribedIds to rebuild with only stable feeds this.allSubscribedIds = []; let subscriptionId = 1; for (const priceFeedArray of this.priceFeedArrays) { const filteredFeedIds = this.filterStableFeeds( priceFeedArray.priceFeedIds ); if (filteredFeedIds.length === 0) { console.warn( `All feeds filtered out for subscription ${subscriptionId}, skipping` ); continue; } // Update allSubscribedIds with only stable feeds this.allSubscribedIds.push(...filteredFeedIds); const feedIdsHash = this.hash(filteredFeedIds); this.feedIdHashToFeedIds.set(feedIdsHash, filteredFeedIds); this.subscriptionIdsToFeedIdsHash.set(subscriptionId, feedIdsHash); // Update marketIndextoPriceFeedIdChunk to use filtered feeds for (const [ marketIndex, chunk, ] of this.marketIndextoPriceFeedIdChunk.entries()) { if (this.hash(chunk) === this.hash(priceFeedArray.priceFeedIds)) { this.marketIndextoPriceFeedIdChunk.set(marketIndex, filteredFeedIds); } } // Remove entries from marketIndextoPriceFeedId for filtered-out feeds for (const [ marketIndex, feedId, ] of this.marketIndextoPriceFeedId.entries()) { if ( !filteredFeedIds.includes(feedId) && priceFeedArray.priceFeedIds.includes(feedId) ) { this.marketIndextoPriceFeedId.delete(marketIndex); this.marketIndextoPriceFeedIdChunk.delete(marketIndex); } } this.pythLazerClient.addMessageListener((message) => { this.receivingData = true; clearTimeout(this.timeoutId); switch (message.type) { case 'json': { if (message.value.type == 'streamUpdated') { if (message.value.solana?.data) { this.feedIdChunkToPriceMessage.set( this.subscriptionIdsToFeedIdsHash.get( message.value.subscriptionId )!, message.value.solana.data ); } if (message.value.parsed?.priceFeeds) { for (const priceFeed of message.value.parsed.priceFeeds) { const price = Number(priceFeed.price!) * Math.pow(10, Number(priceFeed.exponent!)); this.feedIdToPrice.set(priceFeed.priceFeedId, price); } } } break; } default: { break; } } this.setTimeout(); }); this.pythLazerClient.send({ type: 'subscribe', subscriptionId, priceFeedIds: filteredFeedIds, properties: this.feedProperties, formats: ['solana'], deliveryFormat: 'json', channel: priceFeedArray.channel ?? ('fixed_rate@200ms' as Channel), jsonBinaryEncoding: 'hex', }); subscriptionId++; } this.receivingData = true; this.setTimeout(); } protected setTimeout(): void { this.timeoutId = setTimeout(async () => { if (this.isUnsubscribing) { // If we are in the process of unsubscribing, do not attempt to resubscribe return; } if (this.receivingData) { console.log(`No ws data from pyth lazer client resubscribing`); await this.unsubscribe(); this.receivingData = false; await this.subscribe(); } }, this.resubTimeoutMs); } /** * Unsubscribes from all Pyth Lazer price feeds and closes WebSocket connections. */ async unsubscribe() { this.isUnsubscribing = true; this.pythLazerClient?.shutdown(); this.pythLazerClient = undefined; clearTimeout(this.timeoutId); this.timeoutId = undefined; this.isUnsubscribing = false; } hash(arr: number[]): string { return 'h:' + arr.join('|'); } /** * Retrieves the latest Solana-format price message for a group of feed IDs. * @param feedIds - Array of price feed IDs * @returns Hex-encoded price message data, or undefined if not available */ async getLatestPriceMessage(feedIds: number[]): Promise<string | undefined> { return this.feedIdChunkToPriceMessage.get(this.hash(feedIds)); } /** * Retrieves the latest Solana-format price message for a specific market. * @param marketIndex - The market index to get price data for * @returns Hex-encoded price message data, or undefined if not found */ async getLatestPriceMessageForMarketIndex( marketIndex: number ): Promise<string | undefined> { const feedIds = this.marketIndextoPriceFeedIdChunk.get(marketIndex); if (!feedIds) { return undefined; } return await this.getLatestPriceMessage(feedIds); } /** * Gets the array of price feed IDs associated with a market index. * @param marketIndex - The market index to look up * @returns Array of price feed IDs, or empty array if not found */ getPriceFeedIdsFromMarketIndex(marketIndex: number): number[] { return this.marketIndextoPriceFeedIdChunk.get(marketIndex) || []; } /** * Gets the array of price feed IDs from a subscription hash. * @param hash - The subscription hash * @returns Array of price feed IDs, or empty array if not found */ getPriceFeedIdsFromHash(hash: string): number[] { return this.feedIdHashToFeedIds.get(hash) || []; } /** * Gets the current parsed price for a specific market index. * @param marketIndex - The market index to get the price for * @returns The price as a number, or undefined if not available */ getPriceFromMarketIndex(marketIndex: number): number | undefined { const feedId = this.marketIndextoPriceFeedId.get(marketIndex); if (feedId === undefined) { return undefined; } return this.feedIdToPrice.get(feedId); } }