@drift-labs/sdk
Version:
SDK for Drift Protocol
366 lines (331 loc) • 11.5 kB
text/typescript
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);
}
}