import { HexString, PriceFeed } from "@pythnetwork/price-service-sdk"; import axios, { AxiosInstance } from "axios"; import axiosRetry from "axios-retry"; import * as WebSocket from "isomorphic-ws"; import { Logger } from "ts-log"; import { ResilientWebSocket } from "./ResillientWebSocket"; import { makeWebsocketUrl, removeLeading0xIfExists } from "./utils"; export type DurationInMs = number; export type PriceFeedRequestConfig = { /* Optional verbose to request for verbose information from the service */ verbose?: boolean; /* Optional binary to include the price feeds binary update data */ binary?: boolean; /* Optional config for the websocket subscription to receive out of order updates */ allowOutOfOrder?: boolean; }; export type PriceServiceConnectionConfig = { /* Timeout of each request (for all of retries). Default: 5000ms */ timeout?: DurationInMs; /** * Number of times a HTTP request will be retried before the API returns a failure. Default: 3. * * The connection uses exponential back-off for the delay between retries. However, * it will timeout regardless of the retries at the configured `timeout` time. */ httpRetries?: number; /* Optional logger (e.g: console or any logging library) to log internal events */ logger?: Logger; /* Deprecated: please use priceFeedRequestConfig.verbose instead */ verbose?: boolean; /* Configuration for the price feed requests */ priceFeedRequestConfig?: PriceFeedRequestConfig; }; type ClientMessage = { type: "subscribe" | "unsubscribe"; ids: HexString[]; verbose?: boolean; binary?: boolean; allow_out_of_order?: boolean; }; type ServerResponse = { type: "response"; status: "success" | "error"; error?: string; }; type ServerPriceUpdate = { type: "price_update"; price_feed: any; }; type ServerMessage = ServerResponse | ServerPriceUpdate; export type PriceFeedUpdateCallback = (priceFeed: PriceFeed) => void; export class PriceServiceConnection { private httpClient: AxiosInstance; private priceFeedCallbacks: Map>; private wsClient: undefined | ResilientWebSocket; private wsEndpoint: undefined | string; private logger: Logger; private priceFeedRequestConfig: PriceFeedRequestConfig; /** * Custom handler for web socket errors (connection and message parsing). * * Default handler only logs the errors. */ onWsError: (error: Error) => void; /** * Constructs a new Connection. * * @param endpoint endpoint URL to the price service. Example: https://website/example/ * @param config Optional PriceServiceConnectionConfig for custom configurations. */ constructor(endpoint: string, config?: PriceServiceConnectionConfig) { this.httpClient = axios.create({ baseURL: endpoint, timeout: config?.timeout || 5000, }); axiosRetry(this.httpClient, { retries: config?.httpRetries || 3, retryDelay: axiosRetry.exponentialDelay, }); this.priceFeedRequestConfig = { binary: config?.priceFeedRequestConfig?.binary, verbose: config?.priceFeedRequestConfig?.verbose ?? config?.verbose, allowOutOfOrder: config?.priceFeedRequestConfig?.allowOutOfOrder, }; this.priceFeedCallbacks = new Map(); // Default logger is console for only warnings and errors. this.logger = config?.logger || { trace: () => {}, debug: () => {}, info: () => {}, warn: console.warn, error: console.error, }; this.onWsError = (error: Error) => { this.logger.error(error); // Exit the process if it is running in node. if ( typeof process !== "undefined" && typeof process.exit === "function" ) { this.logger.error("Halting the process due to the websocket error"); process.exit(1); } else { this.logger.error( "Cannot halt process. Please handle the websocket error." ); } }; this.wsEndpoint = makeWebsocketUrl(endpoint); } /** * Fetch Latest PriceFeeds of given price ids. * This will throw an axios error if there is a network problem or the price service returns a non-ok response (e.g: Invalid price ids) * * @param priceIds Array of hex-encoded price ids. * @returns Array of PriceFeeds */ async getLatestPriceFeeds( priceIds: HexString[] ): Promise { if (priceIds.length === 0) { return []; } const response = await this.httpClient.get("/api/latest_price_feeds", { params: { ids: priceIds, verbose: this.priceFeedRequestConfig.verbose, binary: this.priceFeedRequestConfig.binary, }, }); const priceFeedsJson = response.data as any[]; return priceFeedsJson.map((priceFeedJson) => PriceFeed.fromJson(priceFeedJson) ); } /** * Fetch latest VAA of given price ids. * This will throw an axios error if there is a network problem or the price service returns a non-ok response (e.g: Invalid price ids) * * This function is coupled to wormhole implemntation. * * @param priceIds Array of hex-encoded price ids. * @returns Array of base64 encoded VAAs. */ async getLatestVaas(priceIds: HexString[]): Promise { const response = await this.httpClient.get("/api/latest_vaas", { params: { ids: priceIds, }, }); return response.data; } /** * Fetch the earliest VAA of the given price id that is published since the given publish time. * This will throw an error if the given publish time is in the future, or if the publish time * is old and the price service endpoint does not have a db backend for historical requests. * This will throw an axios error if there is a network problem or the price service returns a non-ok response (e.g: Invalid price id) * * This function is coupled to wormhole implemntation. * * @param priceId Hex-encoded price id. * @param publishTime Epoch timestamp in seconds. * @returns Tuple of VAA and publishTime. */ async getVaa( priceId: HexString, publishTime: EpochTimeStamp ): Promise<[string, EpochTimeStamp]> { const response = await this.httpClient.get("/api/get_vaa", { params: { id: priceId, publish_time: publishTime, }, }); return [response.data.vaa, response.data.publishTime]; } /** * Fetch the PriceFeed of the given price id that is published since the given publish time. * This will throw an error if the given publish time is in the future, or if the publish time * is old and the price service endpoint does not have a db backend for historical requests. * This will throw an axios error if there is a network problem or the price service returns a non-ok response (e.g: Invalid price id) * * @param priceId Hex-encoded price id. * @param publishTime Epoch timestamp in seconds. * @returns PriceFeed */ async getPriceFeed( priceId: HexString, publishTime: EpochTimeStamp ): Promise { const response = await this.httpClient.get("/api/get_price_feed", { params: { id: priceId, publish_time: publishTime, verbose: this.priceFeedRequestConfig.verbose, binary: this.priceFeedRequestConfig.binary, }, }); return PriceFeed.fromJson(response.data); } /** * Fetch the list of available price feed ids. * This will throw an axios error if there is a network problem or the price service returns a non-ok response. * * @returns Array of hex-encoded price ids. */ async getPriceFeedIds(): Promise { const response = await this.httpClient.get("/api/price_feed_ids"); return response.data; } /** * Subscribe to updates for given price ids. * * It will start a websocket connection if it's not started yet. * Also, it won't throw any exception if given price ids are invalid or connection errors. Instead, * it calls `connection.onWsError`. If you want to handle the errors you should set the * `onWsError` function to your custom error handler. * * @param priceIds Array of hex-encoded price ids. * @param cb Callback function that is called with a PriceFeed upon updates to given price ids. */ async subscribePriceFeedUpdates( priceIds: HexString[], cb: PriceFeedUpdateCallback ) { if (this.wsClient === undefined) { await this.startWebSocket(); } priceIds = priceIds.map((priceId) => removeLeading0xIfExists(priceId)); const newPriceIds: HexString[] = []; for (const id of priceIds) { if (!this.priceFeedCallbacks.has(id)) { this.priceFeedCallbacks.set(id, new Set()); newPriceIds.push(id); } this.priceFeedCallbacks.get(id)!.add(cb); } const message: ClientMessage = { ids: newPriceIds, type: "subscribe", verbose: this.priceFeedRequestConfig.verbose, binary: this.priceFeedRequestConfig.binary, allow_out_of_order: this.priceFeedRequestConfig.allowOutOfOrder, }; await this.wsClient?.send(JSON.stringify(message)); } /** * Unsubscribe from updates for given price ids. * * It will close the websocket connection if it's not subscribed to any price feed updates anymore. * Also, it won't throw any exception if given price ids are invalid or connection errors. Instead, * it calls `connection.onWsError`. If you want to handle the errors you should set the * `onWsError` function to your custom error handler. * * @param priceIds Array of hex-encoded price ids. * @param cb Optional callback, if set it will only unsubscribe this callback from updates for given price ids. */ async unsubscribePriceFeedUpdates( priceIds: HexString[], cb?: PriceFeedUpdateCallback ) { if (this.wsClient === undefined) { await this.startWebSocket(); } priceIds = priceIds.map((priceId) => removeLeading0xIfExists(priceId)); const removedPriceIds: HexString[] = []; for (const id of priceIds) { if (this.priceFeedCallbacks.has(id)) { let idRemoved = false; if (cb === undefined) { this.priceFeedCallbacks.delete(id); idRemoved = true; } else { this.priceFeedCallbacks.get(id)!.delete(cb); if (this.priceFeedCallbacks.get(id)!.size === 0) { this.priceFeedCallbacks.delete(id); idRemoved = true; } } if (idRemoved) { removedPriceIds.push(id); } } } const message: ClientMessage = { ids: removedPriceIds, type: "unsubscribe", }; await this.wsClient?.send(JSON.stringify(message)); if (this.priceFeedCallbacks.size === 0) { this.closeWebSocket(); } } /** * Starts connection websocket. * * This function is called automatically upon subscribing to price feed updates. */ async startWebSocket() { if (this.wsEndpoint === undefined) { throw new Error("Websocket endpoint is undefined."); } this.wsClient = new ResilientWebSocket(this.wsEndpoint, this.logger); this.wsClient.onError = this.onWsError; this.wsClient.onReconnect = () => { if (this.priceFeedCallbacks.size > 0) { const message: ClientMessage = { ids: Array.from(this.priceFeedCallbacks.keys()), type: "subscribe", verbose: this.priceFeedRequestConfig.verbose, binary: this.priceFeedRequestConfig.binary, allow_out_of_order: this.priceFeedRequestConfig.allowOutOfOrder, }; this.logger.info("Resubscribing to existing price feeds."); this.wsClient?.send(JSON.stringify(message)); } }; this.wsClient.onMessage = (data: WebSocket.Data) => { this.logger.info(`Received message ${data.toString()}`); let message: ServerMessage; try { message = JSON.parse(data.toString()) as ServerMessage; } catch (e: any) { this.logger.error(`Error parsing message ${data.toString()} as JSON.`); this.logger.error(e); this.onWsError(e); return; } if (message.type === "response") { if (message.status === "error") { this.logger.error( `Error response from the websocket server ${message.error}.` ); this.onWsError(new Error(message.error)); } } else if (message.type === "price_update") { let priceFeed; try { priceFeed = PriceFeed.fromJson(message.price_feed); } catch (e: any) { this.logger.error( `Error parsing price feeds from message ${data.toString()}.` ); this.logger.error(e); this.onWsError(e); return; } if (this.priceFeedCallbacks.has(priceFeed.id)) { for (const cb of this.priceFeedCallbacks.get(priceFeed.id)!) { cb(priceFeed); } } } else { this.logger.warn( `Ignoring unsupported server response ${data.toString()}.` ); } }; await this.wsClient.startWebSocket(); } /** * Closes connection websocket. * * At termination, the websocket should be closed to finish the * process elegantly. It will automatically close when the connection * is subscribed to no price feeds. */ closeWebSocket() { this.wsClient?.closeWebSocket(); this.wsClient = undefined; this.priceFeedCallbacks.clear(); } }