@drift-labs/sdk
Version:
SDK for Drift Protocol
277 lines (276 loc) • 13 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PythLazerSubscriber = void 0;
const pyth_lazer_sdk_1 = require("@pythnetwork/pyth-lazer-sdk");
const perpMarkets_1 = require("../constants/perpMarkets");
/**
* 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.
*/
class PythLazerSubscriber {
/**
* 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(endpoints, token, priceFeedArrays, env = 'devnet', resubTimeoutMs = 2000, sdkLogging = false, feedProperties = [
'price',
'bestAskPrice',
'bestBidPrice',
'exponent',
'feedUpdateTimestamp',
]) {
this.endpoints = endpoints;
this.token = token;
this.priceFeedArrays = priceFeedArrays;
this.resubTimeoutMs = resubTimeoutMs;
this.sdkLogging = sdkLogging;
this.symbolsCache = null;
this.feedIdChunkToPriceMessage = new Map();
this.feedIdToPrice = new Map();
this.feedIdHashToFeedIds = new Map();
this.subscriptionIdsToFeedIdsHash = new Map();
this.allSubscribedIds = [];
this.receivingData = false;
this.isUnsubscribing = false;
this.marketIndextoPriceFeedIdChunk = new Map();
this.marketIndextoPriceFeedId = new Map();
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_1.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);
}
}
}
async fetchSymbolsIfNeeded() {
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
}
}
filterStableFeeds(feedIds) {
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() {
var _a;
await this.fetchSymbolsIfNeeded();
this.pythLazerClient = await pyth_lazer_sdk_1.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) => {
var _a, _b;
this.receivingData = true;
clearTimeout(this.timeoutId);
switch (message.type) {
case 'json': {
if (message.value.type == 'streamUpdated') {
if ((_a = message.value.solana) === null || _a === void 0 ? void 0 : _a.data) {
this.feedIdChunkToPriceMessage.set(this.subscriptionIdsToFeedIdsHash.get(message.value.subscriptionId), message.value.solana.data);
}
if ((_b = message.value.parsed) === null || _b === void 0 ? void 0 : _b.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: (_a = priceFeedArray.channel) !== null && _a !== void 0 ? _a : 'fixed_rate@200ms',
jsonBinaryEncoding: 'hex',
});
subscriptionId++;
}
this.receivingData = true;
this.setTimeout();
}
setTimeout() {
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() {
var _a;
this.isUnsubscribing = true;
(_a = this.pythLazerClient) === null || _a === void 0 ? void 0 : _a.shutdown();
this.pythLazerClient = undefined;
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
this.isUnsubscribing = false;
}
hash(arr) {
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) {
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) {
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) {
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) {
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) {
const feedId = this.marketIndextoPriceFeedId.get(marketIndex);
if (feedId === undefined) {
return undefined;
}
return this.feedIdToPrice.get(feedId);
}
}
exports.PythLazerSubscriber = PythLazerSubscriber;
PythLazerSubscriber.SYMBOLS_API_URL = 'https://history.pyth-lazer.dourolabs.app/history/v1/symbols';