Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@
"types/markets": [
"./build/types/src/utils/markets/types.d.ts"
],
"utils/stream": [
"./build/types/src/utils/stream/index.d.ts"
],
"utils/prices": [
"./build/types/src/utils/prices/index.d.ts"
],
Expand Down Expand Up @@ -216,6 +219,12 @@
"require": "./build/cjs/src/utils/markets/index.js",
"default": "./build/cjs/src/utils/markets/index.js"
},
"./utils/stream": {
"types": "./build/types/src/utils/stream/index.d.ts",
"import": "./build/esm/src/utils/stream/index.js",
"require": "./build/cjs/src/utils/stream/index.js",
"default": "./build/cjs/src/utils/stream/index.js"
},
"./types/markets": {
"types": "./build/types/src/utils/markets/types.d.ts",
"import": "./build/esm/src/utils/markets/types.js",
Expand Down Expand Up @@ -506,6 +515,7 @@
"test:ci": "vitest run",
"test:multicall": "vitest run --config vitest.multicall.config.ts",
"test:v2": "vitest run --config vitest.v2.config.ts",
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"build": "rm -rf build && yarn build:types && yarn build:cjs && yarn build:esm && yarn clean",
"build:cjs": "tsc -p tsconfig.cjs.json && echo '{\"type\": \"commonjs\"}' > build/cjs/src/package.json",
"build:esm": "tsc -p tsconfig.esm.json && echo '{\"type\": \"module\"}' > build/esm/src/package.json",
Expand Down Expand Up @@ -535,11 +545,13 @@
"crypto-js": "4.2.0",
"graphql": "15.8.0",
"isomorphic-performance": "5.1.1",
"isomorphic-ws": "^5.0.0",
"lodash": "4.18.1",
"query-string": "7.1.1",
"typescript": "5.4.2",
"universal-perf-hooks": "1.0.1",
"viem": "^2.37.1"
"viem": "^2.37.1",
"ws": "8.18.3"
},
"devDependencies": {
"@babel/plugin-transform-private-property-in-object": "^7.25.9",
Expand All @@ -549,6 +561,7 @@
"@graphql-codegen/typescript-operations": "4.6.1",
"@types/lodash": "4.14.198",
"@types/node": "18.7.13",
"@types/ws": "^8.5.10",
"lint-staged": "12.3.4",
"ts-patch": "3.2.1",
"tsx": "4.19.0",
Expand Down
70 changes: 67 additions & 3 deletions sdk/src/clients/v2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import {
type SameChainDepositRequest,
type SameChainWithdrawRequest,
} from "utils/multichain";
import { deserializeBigIntsInObject } from "utils/numbers";
import { fetchApiOrders } from "utils/orders/api";
import {
prepareOrder,
Expand All @@ -87,13 +88,15 @@ import { fetchApiPairs } from "utils/pairs/api";
import { fetchApiPerformanceAnnualized, fetchApiPerformanceSnapshots } from "utils/performance/api";
import { PerformanceAnnualized, PerformanceParams, PerformanceSnapshots } from "utils/performance/types";
import { fetchApiPositionsInfo } from "utils/positions/api";
import { fetchApiOhlcv } from "utils/prices/api";
import type { OhlcvParams } from "utils/prices/types";
import { fetchApiOhlcv, fetchApiTokenPrices } from "utils/prices/api";
import type { OhlcvCandle, OhlcvParams } from "utils/prices/types";
import { fetchApiRates } from "utils/rates/api";
import { MarketRates, RatesParams } from "utils/rates/types";
import type { IAbstractSigner } from "utils/signer";
import { fetchApiStakingPower } from "utils/staking/api";
import { StakingPowerResponse } from "utils/staking/types";
import type { Subscription, WebSocketCtor } from "utils/stream";
import { WsStreamClient, createChannelSubscription, toStreamUrl } from "utils/stream";
import { fetchSubaccountStatus, prepareSubaccountApproval, signSubaccountApproval } from "utils/subaccount/api";
import type {
SubaccountStatusRequest,
Expand All @@ -116,6 +119,7 @@ import {
} from "utils/subaccount/sdkClient";
import type { SdkSubaccountApproval, SdkSubaccountStatus, SubaccountState } from "utils/subaccount/types";
import { fetchApiTokens } from "utils/tokens/api";
import type { TokenPricesData } from "utils/tokens/types";
import { fetchApiTrades, searchApiTrades } from "utils/trades/api";
import type { FetchTradesParams, SearchTradesParams, TradesListResponse } from "utils/trades/types";

Expand Down Expand Up @@ -189,6 +193,7 @@ export { PrivateKeySigner } from "utils/signer";
export { HttpError } from "utils/http/http";
export { HttpClientWithFallback } from "utils/http/httpFallback";
export type { IHttp } from "utils/http/types";
export type { FrameMeta, StreamStatus, Subscription, WebSocketCtor } from "utils/stream";
export { getGasPaymentTokens } from "configs/express";
export type {
SubaccountStatusRequest,
Expand All @@ -202,8 +207,34 @@ export class GmxApiSdk {
ctx: { chainId: ContractsChainId; api: IHttp };
private _subaccount: SubaccountState | undefined;
private preparedSubaccountApprovals = new Map<string, SdkSubaccountApproval>();
private readonly streamUrlOverride?: string;
private readonly webSocketImpl?: WebSocketCtor;
private readonly reconnectBaseMs?: number;
private readonly reconnectMaxMs?: number;
private _streamClient?: WsStreamClient;

constructor({
chainId,
apiUrl,
api,
streamUrl,
webSocketImpl,
reconnectBaseMs,
reconnectMaxMs,
}: {
chainId: ContractsChainId;
apiUrl?: string;
api?: IHttp;
streamUrl?: string;
webSocketImpl?: WebSocketCtor;
reconnectBaseMs?: number;
reconnectMaxMs?: number;
}) {
this.streamUrlOverride = streamUrl;
this.webSocketImpl = webSocketImpl;
this.reconnectBaseMs = reconnectBaseMs;
this.reconnectMaxMs = reconnectMaxMs;

constructor({ chainId, apiUrl, api }: { chainId: ContractsChainId; apiUrl?: string; api?: IHttp }) {
if (api) {
this.ctx = { chainId, api };
return;
Expand Down Expand Up @@ -280,6 +311,39 @@ export class GmxApiSdk {
return fetchApiTokens(this.ctx);
}

private getStreamClient(): WsStreamClient {
if (!this._streamClient) {
const url = this.streamUrlOverride ?? toStreamUrl(this.ctx.api.url);
this._streamClient = new WsStreamClient({
url,
webSocketImpl: this.webSocketImpl,
reconnectBaseMs: this.reconnectBaseMs,
reconnectMaxMs: this.reconnectMaxMs,
});
}
return this._streamClient;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream URL ignores API failover

Medium Severity

getStreamClient builds the WebSocket URL once from this.ctx.api.url or streamUrlOverride and caches _streamClient. With HttpClientWithFallback, HTTP can rotate primaries after failures while the stream stays on the first host, so REST and live prices can diverge after failover.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1ca4a55. Configure here.

}

fetchTokenPrices(): Promise<TokenPricesData> {
return fetchApiTokenPrices(this.ctx);
}

watchTokenPrices(): Subscription<TokenPricesData> {
return createChannelSubscription(
this.getStreamClient(),
"prices",
(raw) => deserializeBigIntsInObject(raw as Record<string, unknown>, { handleInts: true }) as unknown as TokenPricesData
);
}

watchCandles(params: { symbol: string; timeframe: string }): Subscription<OhlcvCandle> {
return createChannelSubscription(
this.getStreamClient(),
`candles:${params.symbol}:${params.timeframe}`,
(raw) => raw as OhlcvCandle
);
}

fetchPositionsInfo(params: { address: string; includeRelatedOrders?: boolean }) {
return fetchApiPositionsInfo(this.ctx, params);
}
Expand Down
7 changes: 7 additions & 0 deletions sdk/src/utils/prices/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { IHttp } from "utils/http/types";
import { deserializeBigIntsInObject } from "utils/numbers";
import type { TokenPricesData } from "utils/tokens/types";

import { OhlcvCandle, OhlcvParams } from "./types";

Expand All @@ -12,3 +14,8 @@ export async function fetchApiOhlcv(ctx: { api: IHttp }, params: OhlcvParams): P
},
});
}

export async function fetchApiTokenPrices(ctx: { api: IHttp }): Promise<TokenPricesData> {
const raw = await ctx.api.fetchJson("/v1/prices/tickers");
return deserializeBigIntsInObject(raw as Record<string, unknown>, { handleInts: true }) as unknown as TokenPricesData;
}
196 changes: 196 additions & 0 deletions sdk/src/utils/stream/WsStreamClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import IsomorphicWebSocket from "isomorphic-ws";

import { FrameMeta, StreamServerFrame, StreamStatus, Unsubscribe, WebSocketCtor, WebSocketLike } from "./types";

const DEFAULT_RECONNECT_BASE_MS = 500;
const DEFAULT_RECONNECT_MAX_MS = 10_000;

export type ChannelFrame = { data: unknown } & FrameMeta;
type ChannelListener = (frame: ChannelFrame) => void;

function resolveWebSocketCtor(injected?: WebSocketCtor): WebSocketCtor {
return injected ?? (IsomorphicWebSocket as unknown as WebSocketCtor);
}

export class WsStreamClient {
status: StreamStatus = "closed";

private ws?: WebSocketLike;
private readonly url: string;
private readonly WebSocketImpl: WebSocketCtor;
private readonly reconnectBaseMs: number;
private readonly reconnectMaxMs: number;
private readonly listeners = new Map<string, Set<ChannelListener>>();
private readonly statusListeners = new Set<(status: StreamStatus) => void>();
private reconnectDelay: number;
private reconnectTimer?: ReturnType<typeof setTimeout>;
private closedByUser = false;

constructor(params: {
url: string;
webSocketImpl?: WebSocketCtor;
reconnectBaseMs?: number;
reconnectMaxMs?: number;
}) {
this.url = params.url;
this.WebSocketImpl = resolveWebSocketCtor(params.webSocketImpl);
this.reconnectBaseMs = params.reconnectBaseMs ?? DEFAULT_RECONNECT_BASE_MS;
this.reconnectMaxMs = params.reconnectMaxMs ?? DEFAULT_RECONNECT_MAX_MS;
this.reconnectDelay = this.reconnectBaseMs;
}

subscribe(channel: string, listener: ChannelListener): Unsubscribe {
let set = this.listeners.get(channel);
if (!set) {
set = new Set();
this.listeners.set(channel, set);
}
set.add(listener);

this.ensureConnected();
if (this.status === "live") {
this.sendOp("subscribe", [channel]);
}

return () => {
const current = this.listeners.get(channel);
if (!current) {
return;
}
current.delete(listener);
if (current.size === 0) {
this.listeners.delete(channel);
if (this.status === "live") {
this.sendOp("unsubscribe", [channel]);
}
}
if (this.listeners.size === 0) {
this.close();
}
};
}

addStatusListener(listener: (status: StreamStatus) => void): Unsubscribe {
this.statusListeners.add(listener);
return () => {
this.statusListeners.delete(listener);
};
}

private ensureConnected() {
if (this.ws || this.reconnectTimer) {
return;
}
this.closedByUser = false;
this.connect();
}

private connect() {
this.setStatus(this.status === "closed" ? "connecting" : "reconnecting");

let ws: WebSocketLike;
try {
ws = new this.WebSocketImpl(this.url);
} catch {
this.scheduleReconnect();
return;
}
this.ws = ws;

ws.onopen = () => {
this.reconnectDelay = this.reconnectBaseMs;
this.setStatus("live");
const channels = [...this.listeners.keys()];
if (channels.length) {
this.sendOp("subscribe", channels);
}
};
ws.onmessage = (ev) => this.onMessage(ev.data);
ws.onclose = () => this.onClose();
ws.onerror = () => {
// a close event always follows; reconnection is handled there
};
}

private onMessage(raw: unknown) {
const text = typeof raw === "string" ? raw : String(raw);
let frame: StreamServerFrame;
try {
frame = JSON.parse(text);
} catch {
return;
}
if ("op" in frame || frame.type !== "snapshot") {
return;
}
if (frame.data === undefined) {
return;
}
const set = this.listeners.get(frame.ch);
if (!set) {
return;
}
const payload: ChannelFrame = {
data: frame.data,
serverTs: frame.serverTs,
originTs: frame.originTs,
receivedAt: Date.now(),
byteLength: text.length,
};
for (const listener of set) {
listener(payload);
}
}

private onClose() {
this.ws = undefined;
if (this.closedByUser || this.listeners.size === 0) {
this.setStatus("closed");
return;
}
this.scheduleReconnect();
}

private scheduleReconnect() {
this.setStatus("reconnecting");
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = undefined;
this.connect();
}, this.reconnectDelay);
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.reconnectMaxMs);
}

private sendOp(op: "subscribe" | "unsubscribe", channels: string[]) {
try {
this.ws?.send(JSON.stringify({ op, channels }));
} catch {
// socket raced into a non-open state; resubscribe runs on reconnect
}
}

private setStatus(status: StreamStatus) {
if (this.status === status) {
return;
}
this.status = status;
for (const listener of this.statusListeners) {
listener(status);
}
}

close() {
this.closedByUser = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = undefined;
}
this.listeners.clear();
try {
this.ws?.close();
} catch {
// already closing
}
this.ws = undefined;
this.setStatus("closed");
}
}
Loading