import { setInterval, clearInterval, setTimeout, clearTimeout } from "timers"; import WebSocket from "ws"; import type { AccountListener, DepthListener, KlineListener, OrderListener, TickerListener, } from "../adapter"; import type { AsterAccountAsset, AsterAccountSnapshot, AsterDepth, AsterKline, AsterOrder, AsterTicker, CreateOrderParams, } from "../types"; import { extractMessage } from "../../utils/errors"; import type { OrderSide, OrderType } from "../types"; import { LighterHttpClient } from "./http-client"; import { HttpNonceManager } from "./nonce-manager"; import { LighterSigner, type CreateOrderSignParams } from "./signer"; import { bytesToHex } from "./bytes"; import type { LighterAccountDetails, LighterAccountAsset, LighterKline, LighterMarketStats, LighterOrder, LighterOrderBookLevel, LighterOrderBookMetadata, LighterOrderBookSnapshot, LighterPosition, } from "./types"; import { DEFAULT_AUTH_TOKEN_BUFFER_MS, DEFAULT_LIGHTER_ENVIRONMENT, LIGHTER_HOSTS, LIGHTER_ORDER_TYPE, LIGHTER_TIME_IN_FORCE, DEFAULT_ORDER_EXPIRY_PLACEHOLDER, IMMEDIATE_OR_CANCEL_EXPIRY_PLACEHOLDER, type LighterEnvironment, } from "./constants"; import { decimalToScaled, scaledToDecimalString, scaleQuantityWithMinimum } from "./decimal"; import { lighterOrderToAster, toAccountSnapshot, toDepth, toKlines, toOrders, toTicker } from "./mappers"; import { normalizeOrderIdentity, orderIdentityEquals } from "./order-identity"; import { shouldResetMarketOrders } from "./order-feed"; interface SimpleEvent { add(handler: (value: T) => void): void; remove(handler: (value: T) => void): void; emit(value: T): void; listenerCount(): number; } function createEvent(): SimpleEvent { const listeners = new Set<(value: T) => void>(); return { add(handler) { listeners.add(handler); }, remove(handler) { listeners.delete(handler); }, emit(value) { for (const handler of Array.from(listeners)) { try { handler(value); } catch (error) { console.error("[LighterGateway] listener error", error); } } }, listenerCount() { return listeners.size; }, }; } function isLighterEnvironment(value: string | undefined | null): value is LighterEnvironment { if (!value) return false; return Object.prototype.hasOwnProperty.call(LIGHTER_HOSTS, value); } function detectEnvironmentFromUrl(baseUrl: string | undefined | null): LighterEnvironment | null { if (!baseUrl) return null; const matchHost = (host: string): LighterEnvironment | null => { for (const [env, config] of Object.entries(LIGHTER_HOSTS)) { try { const restHost = new URL(config.rest).hostname.toLowerCase(); if (restHost === host) { return env as LighterEnvironment; } } catch { // ignore invalid config URLs } } if (host.includes("mainnet")) return "mainnet"; if (host.includes("testnet")) return "testnet"; if (host.includes("staging")) return "staging"; if (host.includes("dev")) return "dev"; return null; }; try { const parsed = new URL(baseUrl); return matchHost(parsed.hostname.toLowerCase()); } catch { return matchHost(baseUrl.toLowerCase()); } } function inferEnvironment(envOption: string | undefined, baseUrl?: string | null): LighterEnvironment { if (isLighterEnvironment(envOption)) { return envOption; } const detected = detectEnvironmentFromUrl(baseUrl ?? undefined); return detected ?? DEFAULT_LIGHTER_ENVIRONMENT; } interface Pollers { ticker?: ReturnType; klines: Map>; } const KLINE_DEFAULT_COUNT = 120; const DEFAULT_TICKER_POLL_MS = 3000; const DEFAULT_KLINE_POLL_MS = 15000; const WS_HEARTBEAT_INTERVAL_MS = 5_000; const CLIENT_PING_INTERVAL_MS = 2_000; const WS_STALE_TIMEOUT_MS = 20_000; const FEED_STALE_TIMEOUT_MS = 8_000; const STALE_CHECK_INTERVAL_MS = 2_000; const POSITION_HTTP_MAX_STALE_MS = 60_000; const ACCOUNT_POLL_INTERVAL_MS = 5_000; const POSITION_EPSILON = 1e-12; const RESOLUTION_MS: Record = { "1m": 60_000, "5m": 300_000, "15m": 900_000, "1h": 3_600_000, "4h": 14_400_000, "1d": 86_400_000, }; const TERMINAL_ORDER_STATUSES = new Set([ "filled", "canceled", "cancelled", "expired", "canceled-post-only", "canceled-reduce-only", ]); const KNOWN_SPOT_MARKETS: Record = { ETHUSDC: { marketId: 2048, base: "ETH", quote: "USDC", priceDecimals: 2, sizeDecimals: 4 }, }; export interface LighterGatewayOptions { symbol: string; // display symbol used by strategy logging marketSymbol?: string; // actual Lighter order book symbol (e.g., BTC) accountIndex: number; apiKeys: Record; baseUrl?: string; environment?: keyof typeof LIGHTER_HOSTS; marketId?: number; priceDecimals?: number; sizeDecimals?: number; chainId?: number; apiKeyIndices?: number[]; tickerPollMs?: number; klinePollMs?: number; logger?: (context: string, error: unknown) => void; l1Address?: string; } export class LighterGateway { private readonly displaySymbol: string; private readonly marketSymbol: string; private readonly http: LighterHttpClient; private readonly signer: LighterSigner; private readonly nonceManager: HttpNonceManager; private readonly logger: (context: string, error: unknown) => void; private readonly apiKeyIndices: number[]; private readonly environment: keyof typeof LIGHTER_HOSTS; private readonly pollers: Pollers = { ticker: undefined, klines: new Map() }; private accountPoller: ReturnType | null = null; private accountPollInFlight = false; private readonly klineCache = new Map(); private readonly accountEvent = createEvent(); private readonly ordersEvent = createEvent(); private readonly depthEvent = createEvent(); private readonly tickerEvent = createEvent(); private readonly klinesEvent = createEvent(); private readonly auth = { token: null as string | null, expiresAt: 0 }; private readonly l1Address: string | null; private loggedCreateOrderPayload = false; private readonly logTxInfo: boolean; private readonly primaryApiKeyIndex: number; private lastNonceRefreshAt = 0; private orderChain: Promise = Promise.resolve(); private lastWsPositionUpdateAt = 0; private readonly lastWsPositionByMarket = new Map(); private httpPositionsEmptyLogged = false; private forcedSpotPreset = false; private marketId: number | null = null; private marketType: "perp" | "spot" | null = null; private priceDecimals: number | null = null; private sizeDecimals: number | null = null; private baseAssetId: number | null = null; private quoteAssetId: number | null = null; private baseAssetSymbol: string | null = null; private quoteAssetSymbol: string | null = null; private readonly assets = new Map(); private minBaseAmount: number | null = null; private minQuoteAmount: number | null = null; private readonly orderIndexByClientId = new Map(); private ws: WebSocket | null = null; private reconnectTimer: ReturnType | null = null; private reconnectAttempts = 0; private readonly wsUrl: string; private connectPromise: Promise | null = null; private heartbeatTimer: ReturnType | null = null; private pingTimer: ReturnType | null = null; private lastMessageAt = 0; private accountDetails: LighterAccountDetails | null = null; private positions: LighterPosition[] = []; private orders: LighterOrder[] = []; private readonly orderMap = new Map(); private orderBook: LighterOrderBookSnapshot | null = null; private ticker: LighterMarketStats | null = null; private initialized = false; private readonly pendingJsonRequests = new Map< string, { resolve: (value: unknown) => void; reject: (error: unknown) => void } >(); private readonly tickerPollMs: number; private readonly klinePollMs: number; private lastDepthUpdateAt = Date.now(); private lastOrdersUpdateAt = Date.now(); private lastAccountUpdateAt = Date.now(); private lastTickerUpdateAt = Date.now(); private staleReason: string | null = null; private staleMonitor: ReturnType | null = null; // Track last applied order book sequence to drop stale WS messages private lastOrderBookOffset: number = 0; private lastOrderBookTimestamp: number = 0; constructor(options: LighterGatewayOptions) { this.displaySymbol = options.symbol; this.marketSymbol = (options.marketSymbol ?? options.symbol).toUpperCase(); this.marketType = guessMarketType(this.marketSymbol); const parsedSymbols = parseBaseQuote(this.marketSymbol); this.baseAssetSymbol = parsedSymbols.base ?? null; this.quoteAssetSymbol = parsedSymbols.quote ?? null; this.applyPresetMarket(); if (process.env.LIGHTER_MARKET_ID) { this.marketId = Number(process.env.LIGHTER_MARKET_ID); } if (process.env.LIGHTER_MARKET_TYPE) { this.marketType = normalizeMarketType(process.env.LIGHTER_MARKET_TYPE) ?? this.marketType; } const envPreference = options.environment ?? process.env.LIGHTER_ENV ?? (this.forcedSpotPreset && !options.baseUrl ? "mainnet" : undefined); this.environment = inferEnvironment(envPreference, options.baseUrl); const host = options.baseUrl ?? LIGHTER_HOSTS[this.environment]?.rest; if (!host) { throw new Error(`Unknown Lighter environment ${this.environment}`); } if (process.env.LIGHTER_DEBUG === "1" || process.env.LIGHTER_DEBUG === "true") { // eslint-disable-next-line no-console console.error( "[LighterGateway] init", JSON.stringify({ env: this.environment, host, marketId: this.marketId, marketType: this.marketType }) ); } const wsHost = LIGHTER_HOSTS[this.environment]?.ws; if (!wsHost) { throw new Error(`WebSocket endpoint not configured for env ${this.environment}`); } this.wsUrl = wsHost; this.http = new LighterHttpClient({ baseUrl: host }); this.signer = new LighterSigner({ accountIndex: options.accountIndex, chainId: options.chainId ?? (this.environment === "mainnet" ? 304 : 300), apiKeys: options.apiKeys, baseUrl: host, }); this.apiKeyIndices = options.apiKeyIndices ?? Object.keys(options.apiKeys).map(Number); if (this.forcedSpotPreset && this.apiKeyIndices.length > 1) { this.apiKeyIndices.splice(1); // stick to the first key to avoid nonce drift } this.primaryApiKeyIndex = this.apiKeyIndices[0]!; this.nonceManager = new HttpNonceManager({ accountIndex: options.accountIndex, apiKeyIndices: this.apiKeyIndices, http: this.http, }); const debugEnabled = process.env.LIGHTER_DEBUG === "1" || process.env.LIGHTER_DEBUG === "true"; this.logger = options.logger ?? ((context, error) => { if (debugEnabled) { // eslint-disable-next-line no-console console.error(`[LighterGateway] ${context}`, error); } }); this.marketId = options.marketId != null ? Number(options.marketId) : null; this.priceDecimals = options.priceDecimals ?? null; this.sizeDecimals = options.sizeDecimals ?? null; this.tickerPollMs = options.tickerPollMs ?? DEFAULT_TICKER_POLL_MS; this.klinePollMs = options.klinePollMs ?? DEFAULT_KLINE_POLL_MS; this.l1Address = options.l1Address ?? null; this.logTxInfo = process.env.LIGHTER_LOG_TX === "1" || process.env.LIGHTER_LOG_TX === "true"; const now = Date.now(); this.lastDepthUpdateAt = now; this.lastOrdersUpdateAt = now; this.lastAccountUpdateAt = now; this.lastTickerUpdateAt = now; } async ensureInitialized(): Promise { if (this.initialized) return; if (!this.connectPromise) { this.connectPromise = this.initialize().catch((error) => { this.connectPromise = null; throw error; }); } await this.connectPromise; this.initialized = true; } onAccount(handler: AccountListener): void { this.accountEvent.add(handler); } onOrders(handler: OrderListener): void { this.ordersEvent.add(handler); } onDepth(handler: DepthListener): void { this.depthEvent.add(handler); } onTicker(handler: TickerListener): void { this.tickerEvent.add(handler); } onKlines(handler: KlineListener): void { this.klinesEvent.add(handler); } async createOrder(params: CreateOrderParams): Promise { const run = async (): Promise => { await this.ensureInitialized(); const conversion = this.mapCreateOrderParams(params); const { baseAmountScaledString, priceScaledString, triggerPriceScaledString, ...signParams } = conversion; const apiKeyIndex = this.primaryApiKeyIndex != null ? this.primaryApiKeyIndex : this.apiKeyIndices[0] ?? 0; await this.refreshNonceForOrder(apiKeyIndex); const { nonce } = this.primaryApiKeyIndex != null ? this.nonceManager.nextFor(this.primaryApiKeyIndex) : this.nonceManager.next(); try { const signed = await this.signer.signCreateOrder({ ...signParams, apiKeyIndex, nonce, }); const debugEnabled = process.env.LIGHTER_DEBUG === "1" || process.env.LIGHTER_DEBUG === "true"; if (this.logTxInfo || debugEnabled) { // eslint-disable-next-line no-console console.error( "[LighterGateway] createOrder.tx", JSON.stringify({ txType: signed.txType, txInfo: signed.txInfo }) ); } const response = await this.dispatchTransaction(signed.txType, signed.txInfo, { priceProtection: false }); if (debugEnabled && (response as { code?: number })?.code && (response as { code?: number }).code !== 200) { this.logger("createOrder.sendTx.response", response); } const clientOrderIndexStr = signParams.clientOrderIndex.toString(); return lighterOrderToAster(this.displaySymbol, { order_index: clientOrderIndexStr, client_order_index: clientOrderIndexStr, order_id: clientOrderIndexStr, client_order_id: clientOrderIndexStr, market_index: signParams.marketIndex, initial_base_amount: baseAmountScaledString, remaining_base_amount: baseAmountScaledString, price: priceScaledString, trigger_price: triggerPriceScaledString, is_ask: signParams.isAsk === 1, side: signParams.isAsk === 1 ? "sell" : "buy", type: params.type?.toLowerCase(), reduce_only: signParams.reduceOnly === 1, status: "NEW", created_at: Date.now(), } as LighterOrder); } catch (error) { this.nonceManager.acknowledgeFailure(apiKeyIndex); if (isInvalidNonce(error)) { await this.refreshNonceThrottle(apiKeyIndex); } this.logger("createOrder", error); throw error; } }; // serialize order creation to avoid concurrent nonce consumption const chain = this.orderChain.then(run, run); this.orderChain = chain.then( () => undefined, () => undefined ); return chain; } async cancelOrder(params: { marketIndex?: number; orderId: number | string; apiKeyIndex?: number }): Promise { await this.ensureInitialized(); const marketIndex = params.marketIndex ?? this.marketId; if (marketIndex == null) throw new Error("Market index unknown"); const resolvedOrderId = this.resolveOrderIndex(String(params.orderId)); const indexValue = BigInt(resolvedOrderId); const { apiKeyIndex, nonce } = this.nonceManager.next(); try { const signed = await this.signer.signCancelOrder({ marketIndex, orderIndex: indexValue, nonce, apiKeyIndex, }); await this.dispatchTransaction(signed.txType, signed.txInfo); // Optimistically remove the order locally to avoid stale duplicates until WS confirms this.removeOrderLocally(String(params.orderId)); } catch (error) { this.nonceManager.acknowledgeFailure(apiKeyIndex); if (isInvalidNonce(error)) { await this.nonceManager.refresh(apiKeyIndex).catch((err) => this.logger("nonce.refresh", err)); } throw error; } } async cancelAllOrders(params?: { timeInForce?: number; scheduleMs?: number; apiKeyIndex?: number }): Promise { await this.ensureInitialized(); const timeInForce = params?.timeInForce ?? 0; const time = params?.scheduleMs != null ? BigInt(params.scheduleMs) : 0n; const { apiKeyIndex, nonce } = this.nonceManager.next(); try { const signed = await this.signer.signCancelAll({ timeInForce, scheduledTime: time, nonce, apiKeyIndex, }); await this.dispatchTransaction(signed.txType, signed.txInfo); } catch (error) { this.nonceManager.acknowledgeFailure(apiKeyIndex); if (isInvalidNonce(error)) { await this.nonceManager.refresh(apiKeyIndex).catch((err) => this.logger("nonce.refresh", err)); } throw error; } } private async initialize(): Promise { await this.loadMetadata(); await this.nonceManager.init(true); await this.refreshAccountSnapshot(); await this.openWebSocket(); // Emit an initial empty orders snapshot so strategies depending on an order // snapshot at startup can proceed even if the websocket does not publish // orders until there is activity. this.emitOrders(); this.startPolling(); this.startStaleMonitor(); } private async loadMetadata(): Promise { const books = await this.http.getOrderBooks(); const desiredSymbol = this.marketSymbol; const wantsSpot = guessMarketType(desiredSymbol) === "spot" || this.marketType === "spot"; this.logger("loadMetadata", { desiredSymbol, wantsSpot, presetMarketId: this.marketId, bookCount: books.length }); let target: LighterOrderBookMetadata | null = null; if (!this.marketId && wantsSpot) { const normalized = desiredSymbol.toUpperCase().replace(/[^A-Z0-9]/g, ""); const preset = KNOWN_SPOT_MARKETS[normalized]; if (preset) { this.marketId = preset.marketId; this.baseAssetSymbol = this.baseAssetSymbol ?? preset.base; this.quoteAssetSymbol = this.quoteAssetSymbol ?? preset.quote; this.marketType = "spot"; } } // If marketId is explicitly set (env/preset), force-match by ID first if (this.marketId != null) { target = books.find((book) => Number(book.market_id) === Number(this.marketId)) ?? null; if (wantsSpot && target && normalizeMarketType(target.market_type) !== "spot") { // Do not silently switch to perp; enforce spot const spotById = books.find( (book) => normalizeMarketType(book.market_type) === "spot" && Number(book.market_id) === Number(this.marketId) ); target = spotById ?? null; } if (!target) { throw new Error( `Configured market id ${this.marketId} not found in Lighter order books. Check LIGHTER_ENV/baseUrl matches the venue that lists spot ETH/USDC (e.g., mainnet).` ); } } if (!target) { target = this.pickBestOrderBook(books, desiredSymbol, this.marketId); if (wantsSpot && (!target || normalizeMarketType(target.market_type) !== "spot")) { const spotAlt = this.findSpotSibling(books, desiredSymbol, this.marketId); if (spotAlt) target = spotAlt; } } if (!target) { if (this.marketId != null && this.priceDecimals != null && this.sizeDecimals != null) { return; } throw new Error(`Symbol ${desiredSymbol} not listed on Lighter order books`); } if (wantsSpot && normalizeMarketType(target.market_type) !== "spot") { throw new Error( `Expected spot market for ${desiredSymbol}, but resolved to market_id=${target.market_id} type=${target.market_type ?? "unknown"}` ); } this.marketId = Number(target.market_id); this.marketType = normalizeMarketType(target.market_type) ?? this.marketType ?? guessMarketType(target.symbol); this.baseAssetId = target.base_asset_id ?? this.baseAssetId; this.quoteAssetId = target.quote_asset_id ?? this.quoteAssetId; this.minBaseAmount = Number(target.min_base_amount ?? target.min_base_amount); this.minQuoteAmount = Number(target.min_quote_amount ?? target.min_quote_amount); if (this.priceDecimals == null) { this.priceDecimals = target.supported_price_decimals; } if (this.sizeDecimals == null) { this.sizeDecimals = target.supported_size_decimals; } if (!this.baseAssetSymbol || !this.quoteAssetSymbol) { const parsed = parseBaseQuote(target.symbol ?? this.marketSymbol); if (!this.baseAssetSymbol) this.baseAssetSymbol = parsed.base ?? null; if (!this.quoteAssetSymbol) this.quoteAssetSymbol = parsed.quote ?? null; } if (wantsSpot && this.marketType !== "spot") { throw new Error(`Expected spot market for ${desiredSymbol}, but resolved to ${target.market_type ?? "unknown"}`); } } private async refreshAccountSnapshot(): Promise { try { const auth = await this.ensureAuthToken(); let details: LighterAccountDetails | null = null; if (this.l1Address) { details = await this.http.getAccountDetails(Number(this.signer.accountIndex), auth, { by: "l1_address", value: this.l1Address, }); } if (!details) { details = await this.http.getAccountDetails(Number(this.signer.accountIndex), auth, { by: "index", value: Number(this.signer.accountIndex), }); } if (!details) { if (!this.accountDetails) { this.accountDetails = { account_index: Number(this.signer.accountIndex), status: 1, collateral: "0", available_balance: "0", } as LighterAccountDetails; this.positions = []; this.emitAccount(); } return; } this.accountDetails = details; this.applyHttpPositions(details); this.applyAccountAssets(details.assets); this.emitAccount(); } catch (error) { this.logger("refreshAccount", error); } } private applyHttpPositions(details: LighterAccountDetails): void { if (!Object.prototype.hasOwnProperty.call(details, "positions")) { return; } const normalized = this.normalizePositions(details.positions); if (normalized.length) { this.replacePositions(normalized); this.httpPositionsEmptyLogged = false; return; } if (this.isEmptyPositionsPayload(details.positions)) { if (this.positions.length && !this.httpPositionsEmptyLogged) { this.logger("accountPoll", "HTTP positions payload empty, retaining existing positions until WS confirms"); this.httpPositionsEmptyLogged = true; } this.pruneStalePositionsFromHttp(); } } private applyAccountAssets(assets?: LighterAccountAsset[] | Record | null): void { const normalized = this.normalizeAssets(assets); if (!normalized.length) { return; // ignore empty payloads to avoid wiping spot balances } for (const asset of normalized) { const key = this.normalizeAssetKey(asset); if (!key) continue; this.assets.set(key, asset); const assetId = Number(asset.asset_id); if (Number.isFinite(assetId)) { if (this.baseAssetId != null && assetId === this.baseAssetId && asset.symbol) { this.baseAssetSymbol = asset.symbol.toUpperCase(); } if (this.quoteAssetId != null && assetId === this.quoteAssetId && asset.symbol) { this.quoteAssetSymbol = asset.symbol.toUpperCase(); } } } } private recordWsPositionUpdate(): void { this.lastWsPositionUpdateAt = Date.now(); this.httpPositionsEmptyLogged = false; } private markWsPositionForMarket(marketId: number): void { if (!Number.isFinite(marketId)) return; this.lastWsPositionByMarket.set(marketId, Date.now()); } private pruneStalePositionsFromHttp(): void { if (!this.positions.length) return; const now = Date.now(); const remaining: LighterPosition[] = []; let removed = false; for (const pos of this.positions) { const marketId = Number(pos.market_id); const lastWs = this.lastWsPositionByMarket.get(marketId) ?? 0; if (Number.isFinite(marketId) && lastWs && now - lastWs > POSITION_HTTP_MAX_STALE_MS) { this.lastWsPositionByMarket.delete(marketId); removed = true; continue; } remaining.push(pos); } if (removed) { this.logger("accountPoll", "Pruned stale positions based on HTTP inactivity"); this.positions = remaining; this.recordWsPositionUpdate(); } } private async openWebSocket(): Promise { if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { return; } await new Promise((resolve, reject) => { const ws = new WebSocket(this.wsUrl); this.ws = ws; let settled = false; const cleanup = () => { ws.removeAllListeners(); this.stopHeartbeat(); this.stopClientPing(); this.rejectPendingJsonRequests(new Error("WebSocket closed")); if (this.ws === ws) { this.ws = null; } }; const fail = (error: unknown) => { if (settled) return; settled = true; reject(error instanceof Error ? error : new Error(String(error))); }; ws.on("open", async () => { try { this.lastMessageAt = Date.now(); this.lastDepthUpdateAt = this.lastMessageAt; this.staleReason = null; this.startHeartbeat(); this.startClientPing(); await this.subscribeChannels(); this.startStaleMonitor(); settled = true; resolve(); } catch (error) { cleanup(); fail(error); return; } }); ws.on("message", (data) => { this.lastMessageAt = Date.now(); this.handleMessage(data); }); ws.on("pong", () => { this.lastMessageAt = Date.now(); }); ws.on("close", (code, reason) => { cleanup(); const normalizedReason = Buffer.isBuffer(reason) && reason.length > 0 ? reason.toString("utf8") : undefined; if (!settled) { fail(new Error(`WebSocket closed before ready (code=${code}${normalizedReason ? `, reason=${normalizedReason}` : ""})`)); return; } this.stopStaleMonitor(); this.scheduleReconnect(); }); ws.on("error", (error) => { this.logger("ws:error", error); cleanup(); if (!settled) { fail(error); return; } this.stopStaleMonitor(); this.scheduleReconnect(); }); }); } private async subscribeChannels(): Promise { const ws = this.ws; if (!ws || ws.readyState !== WebSocket.OPEN) return; const marketId = this.marketId; if (marketId == null) throw new Error("Market ID unknown"); ws.send(JSON.stringify({ type: "subscribe", channel: `order_book/${marketId}` })); ws.send(JSON.stringify({ type: "subscribe", channel: `account_all/${Number(this.signer.accountIndex)}` })); const auth = await this.ensureAuthToken(); // Subscribe to per-market account updates to receive timely position changes ws.send( JSON.stringify({ type: "subscribe", channel: `account_market/${Number(marketId)}/${Number(this.signer.accountIndex)}`, auth, }) ); ws.send( JSON.stringify({ type: "subscribe", channel: `account_all_orders/${Number(this.signer.accountIndex)}`, auth, }) ); ws.send( JSON.stringify({ type: "subscribe", channel: `account_all_assets/${Number(this.signer.accountIndex)}`, auth, }) ); } private async ensureAuthToken(): Promise { const now = Date.now(); if (this.auth.token && now < this.auth.expiresAt - DEFAULT_AUTH_TOKEN_BUFFER_MS) { return this.auth.token; } const deadline = now + 10 * 60 * 1000; // 10 minutes horizon const token = await this.signer.createAuthToken(deadline); this.auth.token = token; this.auth.expiresAt = deadline; return token; } private async refreshNonceForOrder(apiKeyIndex: number): Promise { try { await this.nonceManager.refresh(apiKeyIndex); this.lastNonceRefreshAt = Date.now(); } catch (error) { // Swallow refresh errors to avoid blocking order flow; real send will surface issues this.logger("nonce.refresh.order", error); } } private async refreshNonceThrottle(apiKeyIndex: number): Promise { const now = Date.now(); if (now - this.lastNonceRefreshAt < 1500) { return; // avoid spamming nextNonce; let next cycle try again } this.lastNonceRefreshAt = now; await this.nonceManager.refresh(apiKeyIndex); } private async dispatchTransaction( txType: number, txInfo: string, options: { priceProtection?: boolean } = {} ): Promise { const auth = await this.ensureAuthToken(); try { return await this.http.sendTransaction(txType, txInfo, { authToken: auth, priceProtection: options.priceProtection, }); } catch (error) { this.logger("http:sendTx", error); if (this.ws && this.ws.readyState === WebSocket.OPEN) { return this.sendTransactionViaWs(txType, txInfo); } throw error; } } private async sendTransactionViaWs(txType: number, txInfo: string): Promise { const ws = this.ws; if (!ws || ws.readyState !== WebSocket.OPEN) { throw new Error("WebSocket not connected for tx dispatch"); } const id = `tx-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; const payload = { type: "jsonapi/sendtx", data: { id, tx_type: txType, tx_info: tryParseTxInfo(txInfo), }, }; return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pendingJsonRequests.delete(id); reject(new Error("WebSocket sendtx timeout")); }, 3000); this.pendingJsonRequests.set(id, { resolve: (value) => { clearTimeout(timer); resolve(value); }, reject: (error) => { clearTimeout(timer); reject(error); }, }); try { ws.send(JSON.stringify(payload)); } catch (error) { clearTimeout(timer); this.pendingJsonRequests.delete(id); reject(error); } }); } private pickBestOrderBook( books: LighterOrderBookMetadata[], desiredSymbol: string, desiredMarketId?: number | null ): LighterOrderBookMetadata | null { const desiredForms = normalizeSymbolForms(desiredSymbol); let candidates = books.filter((book) => { const symbolForms = normalizeSymbolForms(book.symbol); const idMatch = desiredMarketId != null && Number(book.market_id) === Number(desiredMarketId); const symbolMatch = desiredForms.some((form) => symbolForms.includes(form)); return idMatch || symbolMatch; }); if (!candidates.length && desiredMarketId != null) { candidates = books.filter((book) => Number(book.market_id) === Number(desiredMarketId)); } if (!candidates.length) return null; const normalizedDesired = desiredSymbol.toUpperCase(); const wantsSpot = desiredSymbol.includes("/") || desiredSymbol.includes("-") || desiredSymbol.includes(":") || normalizedDesired.includes("USDC") || normalizedDesired.endsWith("USD"); const preferred = candidates.filter((book) => wantsSpot ? normalizeMarketType(book.market_type) === "spot" : true ); if (wantsSpot && preferred.length) { candidates = preferred; } candidates.sort((a, b) => { const aExact = normalizeSymbolForms(a.symbol).includes(desiredSymbol.toUpperCase()) ? 1 : 0; const bExact = normalizeSymbolForms(b.symbol).includes(desiredSymbol.toUpperCase()) ? 1 : 0; if (aExact !== bExact) return bExact - aExact; const aSpot = normalizeMarketType(a.market_type) === "spot" ? 1 : 0; const bSpot = normalizeMarketType(b.market_type) === "spot" ? 1 : 0; if (aSpot !== bSpot) return bSpot - aSpot; return 0; }); return candidates[0]; } private scheduleReconnect(): void { if (this.reconnectTimer) return; const attempt = this.reconnectAttempts + 1; const delay = Math.min(2000 * attempt, 30_000); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.openWebSocket() .then(() => { this.reconnectAttempts = 0; }) .catch((error) => { this.logger("reconnect", error); this.reconnectAttempts = attempt; this.scheduleReconnect(); }); }, delay); } private forceReconnect(reason: string): void { const now = Date.now(); if (this.staleReason && now - this.lastDepthUpdateAt < FEED_STALE_TIMEOUT_MS / 2) { this.staleReason = null; } if (this.staleReason) return; this.staleReason = reason; this.logger("ws:stale", reason); try { this.ws?.terminate(); } catch (error) { this.logger("ws:terminate", error); } this.stopHeartbeat(); this.stopClientPing(); this.scheduleReconnect(); } private startHeartbeat(): void { if (this.heartbeatTimer) return; this.heartbeatTimer = setInterval(() => { const ws = this.ws; if (!ws || ws.readyState !== WebSocket.OPEN) return; const now = Date.now(); if (now - this.lastMessageAt > WS_STALE_TIMEOUT_MS) { try { ws.terminate(); } catch (error) { this.logger("ws:terminate", error); } finally { this.stopHeartbeat(); this.stopClientPing(); this.scheduleReconnect(); } return; } try { ws.ping(); } catch (error) { this.logger("ws:ping", error); } }, WS_HEARTBEAT_INTERVAL_MS); } private stopHeartbeat(): void { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; } } private startClientPing(): void { if (this.pingTimer) return; this.pingTimer = setInterval(() => { const ws = this.ws; if (!ws || ws.readyState !== WebSocket.OPEN) return; try { ws.send(JSON.stringify({ type: "ping" })); } catch (error) { this.logger("ws:clientPing", error); } }, CLIENT_PING_INTERVAL_MS); } private stopClientPing(): void { if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; } } private handleMessage(data: WebSocket.RawData): void { try { const text = typeof data === "string" ? data : data.toString("utf8"); const message = JSON.parse(text); const type = message?.type; switch (type) { case "connected": break; case "ping": this.handlePing(message); break; case "subscribed/order_book": this.handleOrderBookSnapshot(message); break; case "update/order_book": this.handleOrderBookUpdate(message); break; case "subscribed/account_all": case "update/account_all": this.handleAccountAll(message); break; case "subscribed/account_market": case "update/account_market": this.handleAccountMarket(message); break; case "subscribed/account_all_orders": case "update/account_all_orders": this.handleAccountOrders(message); break; case "subscribed/account_all_assets": case "update/account_all_assets": this.handleAccountAssets(message); break; default: break; } this.maybeResolveJsonRequest(message); } catch (error) { this.logger("ws:message", error); } } private handlePing(message: Record | null | undefined): void { const extraPayload: Record = {}; if (message && typeof message === "object") { for (const [key, value] of Object.entries(message)) { if (key === "type") continue; extraPayload[key] = value; } } this.sendPong(extraPayload); } private sendPong(extra: Record = {}): void { const ws = this.ws; if (!ws || ws.readyState !== WebSocket.OPEN) return; const payload = Object.keys(extra).length ? { ...extra, type: "pong" } : { type: "pong" }; try { ws.send(JSON.stringify(payload)); } catch (error) { this.logger("ws:pong", error); } } private maybeResolveJsonRequest(message: any): void { const id = extractJsonRequestId(message); if (!id) return; const pending = this.pendingJsonRequests.get(id); if (!pending) return; this.pendingJsonRequests.delete(id); pending.resolve(message); } private rejectPendingJsonRequests(reason: unknown): void { for (const [id, pending] of Array.from(this.pendingJsonRequests.entries())) { try { pending.reject(reason); } catch (error) { this.logger(`ws:pending:${id}`, error); } finally { this.pendingJsonRequests.delete(id); } } } private handleOrderBookSnapshot(message: any): void { if (!message?.order_book) return; const incomingOffset = Number(message.offset ?? message.order_book?.offset ?? 0); const incomingTs = Number(message.timestamp ?? 0); if (this.lastOrderBookOffset && incomingOffset && incomingOffset < this.lastOrderBookOffset) { return; } if (incomingOffset === this.lastOrderBookOffset && incomingTs && incomingTs <= this.lastOrderBookTimestamp) { return; } const snapshot: LighterOrderBookSnapshot = { market_id: this.marketId ?? 0, offset: message.order_book.offset ?? Date.now(), bids: sortAndTrimLevels(normalizeLevels(message.order_book.bids ?? []), "bid"), asks: sortAndTrimLevels(normalizeLevels(message.order_book.asks ?? []), "ask"), }; this.orderBook = snapshot; this.lastOrderBookOffset = snapshot.offset ?? incomingOffset ?? this.lastOrderBookOffset; this.lastOrderBookTimestamp = incomingTs || Date.now(); this.emitDepth(); this.markDepthUpdate(); } private handleOrderBookUpdate(message: any): void { if (!this.orderBook) return; const incomingOffset = Number(message.offset ?? message.order_book?.offset ?? 0); const incomingTs = Number(message.timestamp ?? 0); if (this.lastOrderBookOffset && incomingOffset && incomingOffset < this.lastOrderBookOffset) { return; } if (incomingOffset === this.lastOrderBookOffset && incomingTs && incomingTs <= this.lastOrderBookTimestamp) { return; } const update = message?.order_book; if (!update) return; if (Array.isArray(update.asks)) { const asks = normalizeLevels(update.asks); this.orderBook.asks = sortAndTrimLevels(mergeLevels(this.orderBook.asks ?? [], asks), "ask"); } if (Array.isArray(update.bids)) { const bids = normalizeLevels(update.bids); this.orderBook.bids = sortAndTrimLevels(mergeLevels(this.orderBook.bids ?? [], bids), "bid"); } this.orderBook.offset = update.offset ?? this.orderBook.offset; this.lastOrderBookOffset = Number(this.orderBook.offset ?? incomingOffset ?? this.lastOrderBookOffset); this.lastOrderBookTimestamp = incomingTs || Date.now(); this.emitDepth(); this.markDepthUpdate(); } private handleAccountAll(message: any): void { if (!message) return; if (Object.prototype.hasOwnProperty.call(message, "positions")) { const positionsObject = message.positions ?? {}; const incoming = this.normalizePositions(positionsObject); if (incoming.length) { this.mergePositions(incoming); this.recordWsPositionUpdate(); } } this.emitAccount(); } private handleAccountMarket(message: any): void { if (!message) return; const type = typeof message.type === "string" ? message.type : ""; const position: LighterPosition | undefined = message.position as LighterPosition | undefined; const channelMarketId = this.extractMarketIdFromChannel(message.channel); if (position && Number.isFinite(Number(position.market_id))) { this.mergePositions([position]); this.markWsPositionForMarket(Number(position.market_id)); this.recordWsPositionUpdate(); } if (Array.isArray(message.orders) && message.orders.length) { const marketId = Number(position?.market_id ?? channelMarketId ?? this.marketId ?? NaN); this.applyOrderList(message.orders, Number.isFinite(marketId) ? Number(marketId) : null, type === "subscribed/account_market"); } else if (type === "subscribed/account_market" && channelMarketId != null) { this.clearOrdersForMarket(channelMarketId); this.emitOrders(); } if (position && this.shouldRemovePosition(position)) { const target = Number(position.market_id ?? channelMarketId); if (Number.isFinite(target)) { this.positions = this.positions.filter((entry) => Number(entry.market_id) !== target); this.lastWsPositionByMarket.delete(target); this.recordWsPositionUpdate(); } } this.emitAccount(); } private handleAccountOrders(message: any): void { if (!message) return; const snapshot = message.type === "subscribed/account_all_orders"; const ordersObject = message.orders ?? {}; this.applyOrderBuckets(ordersObject, snapshot); } private handleAccountAssets(message: any): void { if (!message) return; const assets = this.normalizeAssets(message.assets); if (!assets.length) return; this.applyAccountAssets(assets); this.emitAccount(); } private normalizePositions(source: unknown): LighterPosition[] { if (!source) return []; if (Array.isArray(source)) { return source.filter((entry): entry is LighterPosition => this.isPosition(entry)); } if (isPlainObject(source)) { return Object.values(source).filter((entry): entry is LighterPosition => this.isPosition(entry)); } if (this.isPosition(source)) return [source]; return []; } private isPosition(value: unknown): value is LighterPosition { return typeof value === "object" && value != null && Number.isFinite(Number((value as LighterPosition).market_id)); } private mergePositions(updates: LighterPosition[]): void { if (!updates.length) return; const byMarket = new Map(); for (const existing of this.positions ?? []) { const mid = Number(existing.market_id); if (Number.isFinite(mid)) { byMarket.set(mid, existing); } } for (const update of updates) { const marketId = Number(update.market_id); if (!Number.isFinite(marketId)) continue; if (this.shouldRemovePosition(update)) { byMarket.delete(marketId); this.lastWsPositionByMarket.delete(marketId); } else { byMarket.set(marketId, update); this.markWsPositionForMarket(marketId); } } this.positions = Array.from(byMarket.values()); } private replacePositions(positions: LighterPosition[]): void { if (!positions.length) { this.positions = []; this.lastWsPositionByMarket.clear(); return; } const filtered = this.filterPositions(positions); this.positions = filtered; const now = Date.now(); this.lastWsPositionByMarket.clear(); for (const pos of filtered) { const marketId = Number(pos.market_id); if (Number.isFinite(marketId)) { this.lastWsPositionByMarket.set(marketId, now); } } } private filterPositions(positions: LighterPosition[]): LighterPosition[] { const byMarket = new Map(); for (const entry of positions) { const marketId = Number(entry.market_id); if (!Number.isFinite(marketId)) continue; if (this.shouldRemovePosition(entry)) { byMarket.delete(marketId); } else { byMarket.set(marketId, entry); } } return Array.from(byMarket.values()); } private shouldRemovePosition(position: LighterPosition): boolean { const size = Number(position.position ?? 0); return !Number.isFinite(size) || Math.abs(size) < POSITION_EPSILON; } private removePositionsForMarkets(markets: number[]): void { if (!markets.length) return; const targets = new Set(markets.filter((value) => Number.isFinite(value)).map((value) => Number(value))); if (!targets.size) return; this.positions = (this.positions ?? []).filter((position) => !targets.has(Number(position.market_id))); } private applyOrderBuckets(rawOrders: unknown, snapshot: boolean): void { const ordersObject = isPlainObject(rawOrders) ? (rawOrders as Record) : {}; const marketKeys = Object.keys(ordersObject); if (snapshot && marketKeys.length === 0) { this.orderMap.clear(); this.orderIndexByClientId.clear(); this.orders = []; this.emitOrders(); return; } if (snapshot) { this.orderMap.clear(); this.orderIndexByClientId.clear(); } for (const [market, bucket] of Object.entries(ordersObject)) { const marketId = Number(market); const shouldReset = shouldResetMarketOrders(bucket, snapshot); if (shouldReset && Number.isFinite(marketId)) { this.clearOrdersForMarket(marketId); } const normalized = this.normalizeOrders(bucket); if (!normalized.length) continue; for (const order of normalized) { this.applyOrderUpdate(order); } } this.orders = Array.from(this.orderMap.values()); this.emitOrders(); } private normalizeOrders(source: unknown): LighterOrder[] { if (!source) return []; if (Array.isArray(source)) { return (source as unknown[]).filter((entry): entry is LighterOrder => this.isOrder(entry)); } if (isPlainObject(source) && this.isOrder(source)) { return [source]; } return []; } private isOrder(value: unknown): value is LighterOrder { return typeof value === "object" && value != null; } private normalizeAssets(source: unknown): LighterAccountAsset[] { if (!source) return []; const coerce = (entry: unknown): LighterAccountAsset | null => { if (!this.isAsset(entry)) return null; const balance = typeof entry.balance === "number" ? entry.balance.toString() : entry.balance ?? "0"; const locked = typeof entry.locked_balance === "number" ? entry.locked_balance.toString() : entry.locked_balance; const rawSymbol = (entry.symbol ?? (entry.asset_id != null ? String(entry.asset_id) : undefined)) ?? undefined; const symbol = rawSymbol ? rawSymbol.toUpperCase() : undefined; return { ...entry, balance, locked_balance: locked, symbol }; }; if (Array.isArray(source)) { return (source as unknown[]) .map(coerce) .filter((entry): entry is LighterAccountAsset => Boolean(entry)); } if (isPlainObject(source)) { return Object.values(source) .map(coerce) .filter((entry): entry is LighterAccountAsset => Boolean(entry)); } if (this.isAsset(source)) { const coerced = coerce(source); return coerced ? [coerced] : []; } return []; } private normalizeAssetKey(asset: LighterAccountAsset): string | null { if (asset.asset_id != null && Number.isFinite(Number(asset.asset_id))) { return `id:${Number(asset.asset_id)}`; } if (asset.symbol) { return `symbol:${asset.symbol.toUpperCase()}`; } return null; } private isAsset(value: unknown): value is LighterAccountAsset { if (typeof value !== "object" || value == null) return false; const balance = (value as LighterAccountAsset).balance as unknown; return typeof balance === "string" || typeof balance === "number"; } private applyOrderList(rawOrders: unknown, marketId: number | null, snapshot: boolean): void { const orders = this.normalizeOrders(rawOrders); if (snapshot) { if (marketId != null) { this.clearOrdersForMarket(marketId); } else { this.orderMap.clear(); this.orderIndexByClientId.clear(); } } for (const order of orders) { this.applyOrderUpdate(order); } this.orders = Array.from(this.orderMap.values()); this.emitOrders(); } private applyOrderUpdate(order: LighterOrder): void { const orderIndex = this.extractOrderIndex(order); const clientIndex = this.extractClientIndex(order); if (orderIndex && clientIndex) { this.orderIndexByClientId.set(clientIndex, orderIndex); } if (orderIndex) { this.orderIndexByClientId.set(orderIndex, orderIndex); } const key = orderIndex ?? clientIndex; if (!key) return; const status = String(order.status ?? "").toLowerCase(); if (TERMINAL_ORDER_STATUSES.has(status)) { const existing = this.orderMap.get(key); this.orderMap.delete(key); if (existing) { this.forgetOrderIdentity(existing); } return; } if ( order.client_order_index != null || order.order_index != null || order.client_order_id != null || order.order_id != null ) { for (const [existingKey, existingOrder] of Array.from(this.orderMap.entries())) { if (existingKey === key) continue; const sameOrderIndex = orderIdentityEquals(order.order_index, existingOrder.order_index) || orderIdentityEquals(order.order_id, existingOrder.order_id); const sameClientIndex = orderIdentityEquals(order.client_order_index, existingOrder.client_order_index) || orderIdentityEquals(order.client_order_id, existingOrder.client_order_id); if (sameOrderIndex || sameClientIndex) { const removed = this.orderMap.get(existingKey); this.orderMap.delete(existingKey); if (removed) { this.forgetOrderIdentity(removed); } } } } this.orderMap.set(key, order); } private clearOrdersForMarket(marketId: number): void { const normalized = Number(marketId); if (!Number.isFinite(normalized)) return; for (const [key, existing] of Array.from(this.orderMap.entries())) { const existingMarket = (existing as { market_index?: number | string; market_id?: number | string }).market_index ?? (existing as { market_id?: number | string }).market_id; if (Number(existingMarket) === normalized) { this.orderMap.delete(key); this.forgetOrderIdentity(existing); } } } private extractMarketIdFromChannel(channel: unknown): number | null { if (typeof channel !== "string") return null; const match = channel.match(/account_market:(\d+)/); if (match && match[1]) { const value = Number(match[1]); return Number.isFinite(value) ? value : null; } return null; } private isEmptyPositionsPayload(value: unknown): boolean { if (value == null) return true; if (Array.isArray(value)) return value.length === 0; if (isPlainObject(value)) return Object.keys(value).length === 0; return false; } private emitDepth(): void { if (!this.orderBook || this.marketId == null) return; const depth = toDepth(this.displaySymbol, this.orderBook); this.depthEvent.emit(depth); this.emitSyntheticTicker(); } private markDepthUpdate(): void { this.lastDepthUpdateAt = Date.now(); if (this.staleReason && this.staleReason.startsWith("depth")) { this.logger("ws:stale:recovered", this.staleReason); this.staleReason = null; } } private emitAccount(): void { if (!this.accountDetails) return; const snapshot = toAccountSnapshot( this.displaySymbol, this.accountDetails, this.positions, this.buildAccountAssets(), { marketSymbol: this.marketSymbol, marketId: this.marketId, marketType: this.marketType ?? guessMarketType(this.marketSymbol), baseAssetSymbol: this.baseAssetSymbol, quoteAssetSymbol: this.quoteAssetSymbol, baseAssetId: this.baseAssetId, quoteAssetId: this.quoteAssetId, } ); this.accountEvent.emit(snapshot); this.lastAccountUpdateAt = Date.now(); if (this.staleReason && this.staleReason.startsWith("account")) { this.staleReason = null; } } private emitOrders(): void { const mapped = toOrders(this.displaySymbol, this.orders ?? []); this.ordersEvent.emit(mapped); this.lastOrdersUpdateAt = Date.now(); if (this.staleReason && this.staleReason.startsWith("orders")) { this.staleReason = null; } } private resolveOrderIndex(orderId: string): string { const normalized = normalizeOrderIdentity(orderId); if (!normalized) { throw new Error(`Invalid order id: ${orderId}`); } return this.orderIndexByClientId.get(normalized) ?? normalized; } private removeOrderLocally(orderId: string): void { const key = normalizeOrderIdentity(orderId); if (!key) return; const existing = this.orderMap.get(key); this.orderMap.delete(key); this.orderIndexByClientId.delete(key); if (existing) { this.forgetOrderIdentity(existing); } this.orders = Array.from(this.orderMap.values()); this.emitOrders(); } private extractOrderIndex(order: LighterOrder): string | null { return ( normalizeOrderIdentity(order.order_id) ?? normalizeOrderIdentity(order.order_index) ?? null ); } private extractClientIndex(order: LighterOrder): string | null { return ( normalizeOrderIdentity(order.client_order_id) ?? normalizeOrderIdentity(order.client_order_index) ?? null ); } private forgetOrderIdentity(order: LighterOrder): void { const orderIndex = this.extractOrderIndex(order); const clientIndex = this.extractClientIndex(order); if (orderIndex) { this.orderIndexByClientId.delete(orderIndex); } if (clientIndex) { this.orderIndexByClientId.delete(clientIndex); } } private startPolling(): void { if (!this.pollers.ticker) { this.pollers.ticker = setInterval(() => { this.refreshTicker().catch((error) => this.logger("ticker", error)); }, this.tickerPollMs); void this.refreshTicker(); } if (!this.accountPoller) { const pollAccount = () => { if (this.accountPollInFlight) return; this.accountPollInFlight = true; this.refreshAccountSnapshot() .catch((error) => this.logger("accountPoll", error)) .finally(() => { this.accountPollInFlight = false; }); }; this.accountPoller = setInterval(pollAccount, ACCOUNT_POLL_INTERVAL_MS); pollAccount(); } } private startStaleMonitor(): void { if (this.staleMonitor) return; this.staleMonitor = setInterval(() => this.checkFeedStaleness(), STALE_CHECK_INTERVAL_MS); } private stopStaleMonitor(): void { if (!this.staleMonitor) return; clearInterval(this.staleMonitor); this.staleMonitor = null; } private checkFeedStaleness(): void { if (this.staleReason) return; const now = Date.now(); if (now - this.lastDepthUpdateAt > FEED_STALE_TIMEOUT_MS) { this.forceReconnect("depth stale"); } } private async refreshTicker(): Promise { try { const stats = await this.http.getExchangeStats(); const marketId = this.marketId; if (marketId == null) return; const match = stats.find( (entry) => Number(entry.market_id) === marketId || (entry.symbol ? entry.symbol.toUpperCase() : "") === this.marketSymbol ); if (!match) return; const ticker = toTicker(this.displaySymbol, match); this.tickerEvent.emit(ticker); this.loggedCreateOrderPayload = false; this.lastTickerUpdateAt = Date.now(); if (this.staleReason && this.staleReason.startsWith("ticker")) { this.staleReason = null; } } catch (error) { this.logger("refreshTicker", error); } } watchKlines(interval: string, handler: KlineListener): void { this.klinesEvent.add(handler); const cached = this.klineCache.get(interval); if (cached) { handler(cloneKlines(cached)); } const existing = this.pollers.klines.get(interval); if (!existing) { const poll = () => { void this.refreshKlines(interval).catch((error) => this.logger("klines", error)); }; const timer = setInterval(poll, this.klinePollMs); this.pollers.klines.set(interval, timer); poll(); } } private async refreshKlines(interval: string): Promise { await this.ensureInitialized(); const marketId = this.marketId; if (marketId == null) return; const resolutionMs = RESOLUTION_MS[interval]; if (!resolutionMs) return; const end = Date.now(); const count = Math.max(KLINE_DEFAULT_COUNT, 200); const start = end - resolutionMs * count; const startTs = Math.max(0, Math.floor(start)); const endTs = Math.max(startTs + resolutionMs, Math.floor(end)); const raw = await this.http.getCandlesticks({ marketId, resolution: interval, countBack: count, endTimestamp: endTs, startTimestamp: startTs, setTimestampToEnd: true, }); const sorted = (raw as LighterKline[]).slice().sort((a, b) => a.start_timestamp - b.start_timestamp); const mapped = toKlines(this.displaySymbol, interval, sorted); this.klineCache.set(interval, mapped); this.klinesEvent.emit(cloneKlines(mapped)); this.emitSyntheticTicker(); } private emitSyntheticTicker(): void { if (!this.orderBook) return; const bestBid = getBestPrice(this.orderBook.bids, "bid"); const bestAsk = getBestPrice(this.orderBook.asks, "ask"); if (bestBid == null && bestAsk == null) return; const last = bestBid != null && bestAsk != null ? (bestBid + bestAsk) / 2 : (bestBid ?? bestAsk ?? 0); const ticker: AsterTicker = { symbol: this.displaySymbol, eventType: "lighterSyntheticTicker", eventTime: Date.now(), lastPrice: last.toString(), openPrice: (bestBid ?? last).toString(), highPrice: (bestAsk ?? last).toString(), lowPrice: (bestBid ?? last).toString(), volume: "0", quoteVolume: "0", bidPrice: bestBid != null ? bestBid.toString() : undefined, askPrice: bestAsk != null ? bestAsk.toString() : undefined, priceChange: bestBid != null && bestAsk != null ? (bestAsk - bestBid).toString() : undefined, markPrice: last.toString(), priceChangePercent: undefined, weightedAvgPrice: undefined, lastQty: undefined, openTime: Date.now(), closeTime: Date.now(), firstId: undefined, lastId: undefined, count: undefined, }; this.tickerEvent.emit(ticker); } private buildAccountAssets(): AsterAccountAsset[] { if (!this.assets.size) return []; const now = Date.now(); const list: AsterAccountAsset[] = []; for (const asset of this.assets.values()) { const balanceNum = parseNumber(asset.balance); const lockedNum = parseNumber(asset.locked_balance ?? 0); const availableRaw = balanceNum != null && lockedNum != null ? balanceNum - lockedNum : null; const available = availableRaw != null ? Math.max(0, availableRaw) : null; const assetId = Number(asset.asset_id); const matchSymbol = Number.isFinite(assetId) && this.baseAssetId != null && assetId === this.baseAssetId ? this.baseAssetSymbol : Number.isFinite(assetId) && this.quoteAssetId != null && assetId === this.quoteAssetId ? this.quoteAssetSymbol : null; const assetSymbol = (matchSymbol ?? asset.symbol ?? (asset.asset_id != null ? String(asset.asset_id) : "ASSET")).toUpperCase(); list.push({ asset: assetSymbol, walletBalance: asset.balance ?? "0", availableBalance: available != null && Number.isFinite(available) ? available.toString() : asset.balance ?? "0", updateTime: now, assetId: Number.isFinite(assetId) ? assetId : undefined, }); } return list.sort((a, b) => a.asset.localeCompare(b.asset)); } private applyPresetMarket(): void { const normalized = (this.marketSymbol ?? "").toUpperCase().replace(/[^A-Z0-9]/g, ""); const preset = KNOWN_SPOT_MARKETS[normalized]; if (!preset) return; if (this.marketId == null) this.marketId = preset.marketId; if (!this.baseAssetSymbol) this.baseAssetSymbol = preset.base; if (!this.quoteAssetSymbol) this.quoteAssetSymbol = preset.quote; if (!this.marketType) this.marketType = "spot"; if (preset.priceDecimals != null) this.priceDecimals = this.priceDecimals ?? preset.priceDecimals; if (preset.sizeDecimals != null) this.sizeDecimals = this.sizeDecimals ?? preset.sizeDecimals; this.forcedSpotPreset = true; } private findSpotSibling( books: LighterOrderBookMetadata[], desiredSymbol: string, desiredMarketId?: number | null ): LighterOrderBookMetadata | null { const parsed = parseBaseQuote(desiredSymbol); const base = parsed.base; const quote = parsed.quote; if (!base || !quote) return null; const matches = books.filter((book) => { if (normalizeMarketType(book.market_type) !== "spot") return false; const forms = normalizeSymbolForms(book.symbol); const hasBase = forms.includes(base) || forms.includes(`${base}${quote}`) || forms.includes(`${base}-${quote}`); const hasQuote = forms.includes(quote) || forms.includes(`${base}${quote}`) || forms.includes(`${base}-${quote}`); const idMatch = desiredMarketId != null && Number(book.market_id) === Number(desiredMarketId); return (hasBase && hasQuote) || idMatch; }); if (!matches.length) return null; return matches[0]; } async getPrecision(): Promise<{ priceTick: number; qtyStep: number; priceDecimals: number; sizeDecimals: number; marketId: number | null; minBaseAmount: number | null; minQuoteAmount: number | null; }> { await this.loadMetadata(); if (this.priceDecimals == null || this.sizeDecimals == null) { throw new Error("Lighter market metadata not initialized"); } const priceTick = decimalsToStep(this.priceDecimals); const qtyStep = decimalsToStep(this.sizeDecimals); return { priceTick, qtyStep, priceDecimals: this.priceDecimals, sizeDecimals: this.sizeDecimals, marketId: this.marketId ?? null, minBaseAmount: this.minBaseAmount ?? null, minQuoteAmount: this.minQuoteAmount ?? null, }; } private isSpotMarket(): boolean { return (this.marketType ?? "").toLowerCase() === "spot"; } private enforceMinimums(quantity: number, price: number | null | undefined): number { let qty = Number(quantity); if (!Number.isFinite(qty) || qty <= 0) { throw new Error("Lighter order requires positive quantity"); } if (this.minBaseAmount != null && Number.isFinite(this.minBaseAmount)) { qty = Math.max(qty, this.minBaseAmount); } if (this.minQuoteAmount != null && Number.isFinite(this.minQuoteAmount) && Number.isFinite(price)) { const minByQuote = this.minQuoteAmount / Number(price); if (Number.isFinite(minByQuote) && minByQuote > 0) { qty = Math.max(qty, minByQuote); } } return qty; } private getAvailableAssetAmount(assetId?: number | null, symbol?: string | null): { available: number | null; wallet: number | null } { if (!this.assets.size) return null; const normalizedSymbol = symbol ? symbol.toUpperCase() : null; for (const asset of this.assets.values()) { const idMatches = assetId != null && Number.isFinite(Number(asset.asset_id)) && Number(asset.asset_id) === assetId; const symbolMatches = normalizedSymbol && asset.symbol && asset.symbol.toUpperCase() === normalizedSymbol; if (!idMatches && !symbolMatches) continue; const balance = parseNumber(asset.balance); const locked = parseNumber(asset.locked_balance ?? 0); if (balance == null) continue; const available = locked != null ? balance - locked : balance; return { available: Number.isFinite(available) ? available : null, wallet: Number.isFinite(balance) ? balance : null, }; } return { available: null, wallet: null }; } private assertSpotBalance(params: { isAsk: boolean; quantity: number | null | undefined; price: number | null }): void { const qty = Number(params.quantity); if (!Number.isFinite(qty) || qty <= 0) return; if (params.isAsk) { const baseAmounts = this.getAvailableAssetAmount(this.baseAssetId, this.baseAssetSymbol); const availableBase = baseAmounts?.available ?? null; const walletBase = baseAmounts?.wallet ?? null; const effective = Math.max( availableBase != null && Number.isFinite(availableBase) ? availableBase : 0, walletBase != null && Number.isFinite(walletBase) ? walletBase : 0 ); if (effective + 1e-9 < qty) { throw new Error( `Insufficient base asset (${this.baseAssetSymbol ?? "BASE"} available ${availableBase ?? 0}${ walletBase != null ? ` wallet ${walletBase}` : "" }) for spot sell ${qty}` ); } return; } const price = Number(params.price); if (!Number.isFinite(price) || price <= 0) return; const requiredQuote = qty * price; const availableQuote = this.getAvailableAssetAmount(this.quoteAssetId, this.quoteAssetSymbol); if (availableQuote != null && availableQuote + 1e-9 < requiredQuote) { throw new Error( `Insufficient quote asset (${this.quoteAssetSymbol ?? "QUOTE"} available ${availableQuote}) for spot buy requiring ${requiredQuote}` ); } } private mapCreateOrderParams(params: CreateOrderParams): Omit & { baseAmountScaledString: string; priceScaledString: string; triggerPriceScaledString: string; clientOrderIndex: bigint; } { if (this.marketId == null || this.priceDecimals == null || this.sizeDecimals == null) { throw new Error("Lighter market metadata not initialized"); } const wantsSpot = guessMarketType(this.marketSymbol) === "spot" || this.marketType === "spot"; if (wantsSpot && this.marketType !== "spot") { throw new Error( `Refusing to place order on non-spot market (marketId=${this.marketId}, type=${this.marketType ?? "unknown"}) for symbol=${this.marketSymbol}` ); } if (params.quantity == null || !Number.isFinite(params.quantity)) { throw new Error("Lighter orders require quantity"); } if (process.env.LIGHTER_DEBUG === "1" || process.env.LIGHTER_DEBUG === "true") { // eslint-disable-next-line no-console console.error( "[LighterGateway] createOrder.inputs", JSON.stringify({ marketId: this.marketId, marketType: this.marketType, priceDecimals: this.priceDecimals, sizeDecimals: this.sizeDecimals, symbol: this.marketSymbol, wantsSpot, }) ); } const side = params.side; const isAsk = side === "SELL" ? 1 : 0; const enforcedQty = this.enforceMinimums(params.quantity, params.price ?? null); if (this.isSpotMarket() && isAsk === 1) { const availableBase = this.getAvailableAssetAmount(this.baseAssetId, this.baseAssetSymbol); if (availableBase != null && availableBase + 1e-9 < enforcedQty) { throw new Error( `Spot sell quantity ${enforcedQty} exceeds available base ${availableBase} (min trade size may be higher than balance)` ); } } const baseAmount = scaleQuantityWithMinimum(enforcedQty, this.sizeDecimals); const baseAmountScaledString = scaledToDecimalString(baseAmount, this.sizeDecimals); const clientOrderIndex = BigInt(Date.now() % Number.MAX_SAFE_INTEGER); let priceScaled = params.price != null ? decimalToScaled(params.price, this.priceDecimals) : null; if ((params.type === "MARKET" || params.type === "STOP_MARKET") && priceScaled == null) { priceScaled = decimalToScaled(this.estimateMarketPrice(side), this.priceDecimals); } if (priceScaled == null) { throw new Error("Lighter order requires price"); } const reduceOnly = this.isSpotMarket() ? 0 : params.reduceOnly === "true" || params.closePosition === "true" ? 1 : 0; const resultType = mapOrderType(params.type ?? "LIMIT"); const resultTimeInForce = mapTimeInForce(params.timeInForce, params.type ?? "LIMIT"); let triggerPriceScaled = 0n; if (params.stopPrice != null) { triggerPriceScaled = decimalToScaled(params.stopPrice, this.priceDecimals); } // Align with chain expectations: // - Pure MARKET orders use immediate expiry (0) // - STOP orders rest until trigger, so they require an absolute future expiry // - All other orders use absolute future timestamp (ms) for ~28 days const TWENTY_EIGHT_DAYS_MS = 28 * 24 * 60 * 60 * 1000; const isImmediate = resultType === LIGHTER_ORDER_TYPE.MARKET; const orderExpiry = isImmediate ? BigInt(IMMEDIATE_OR_CANCEL_EXPIRY_PLACEHOLDER) : BigInt(Date.now() + TWENTY_EIGHT_DAYS_MS); const resolvedPrice = params.price ?? (() => { try { return Number(scaledToDecimalString(priceScaled, this.priceDecimals)); } catch { return null; } })(); if (this.isSpotMarket()) { this.assertSpotBalance({ isAsk: isAsk === 1, quantity: params.quantity, price: resolvedPrice, }); } return { marketIndex: this.marketId, clientOrderIndex, baseAmount, baseAmountScaledString, price: Number(priceScaled), priceScaledString: scaledToDecimalString(priceScaled, this.priceDecimals), isAsk, orderType: resultType, timeInForce: resultTimeInForce, reduceOnly, triggerPrice: Number(triggerPriceScaled), triggerPriceScaledString: scaledToDecimalString(triggerPriceScaled, this.priceDecimals), orderExpiry, expiredAt: BigInt(Date.now() + 10 * 60 * 1000), }; } private estimateMarketPrice(side: OrderSide): number { if (this.orderBook) { const levels = side === "SELL" ? this.orderBook.bids : this.orderBook.asks; if (levels && levels.length) { const sorted = [...levels].sort((a, b) => { const aPrice = Number(a.price); const bPrice = Number(b.price); return side === "SELL" ? bPrice - aPrice : aPrice - bPrice; }); const level = sorted[0]; if (level) return Number(level.price); } } if (this.ticker) { return Number(this.ticker.last_trade_price); } throw new Error("Unable to determine market price for order"); } } function mergeLevels(existing: LighterOrderBookLevel[], updates: LighterOrderBookLevel[]): LighterOrderBookLevel[] { const map = new Map(); for (const level of existing) { const key = normalizePriceKey(level.price); map.set(key, normalizeSizeValue(level.size)); } for (const update of updates) { const key = normalizePriceKey(update.price); if (Number(update.size) <= 0) { map.delete(key); } else { map.set(key, normalizeSizeValue(update.size)); } } return Array.from(map.entries()).map(([price, size]) => ({ price, size } as LighterOrderBookLevel)); } function cloneKlines(klines: AsterKline[]): AsterKline[] { return klines.map((kline) => ({ ...kline })); } function getBestPrice(levels: LighterOrderBookLevel[] | Array | undefined, side: "bid" | "ask"): number | null { if (!levels || !levels.length) return null; const sorted = levels .map((level) => { if (Array.isArray(level)) return Number(level[0]); return Number((level as LighterOrderBookLevel).price); }) .filter((price) => Number.isFinite(price)); if (!sorted.length) return null; return side === "bid" ? Math.max(...sorted) : Math.min(...sorted); } function normalizeLevels(raw: Array): LighterOrderBookLevel[] { if (!Array.isArray(raw)) return []; return raw .map((entry) => { if (Array.isArray(entry)) { const price = normalizePriceKey(entry[0] as string | number); const size = normalizeSizeValue(entry[1]); return { price, size } as LighterOrderBookLevel; } const obj = entry as LighterOrderBookLevel; return { price: normalizePriceKey(obj.price), size: normalizeSizeValue(obj.size), } as LighterOrderBookLevel; }) .filter((lvl) => lvl.price != null && lvl.size != null); } // Ensure correct side ordering and limit depth size function sortAndTrimLevels( levels: LighterOrderBookLevel[] | undefined, side: "bid" | "ask", limit: number = 200 ): LighterOrderBookLevel[] { const list = Array.isArray(levels) ? levels.slice() : []; list.sort((a, b) => { const pa = Number(a.price); const pb = Number(b.price); if (!Number.isFinite(pa) || !Number.isFinite(pb)) return 0; return side === "bid" ? pb - pa : pa - pb; }); return list.slice(0, Math.max(1, limit)); } function normalizePriceKey(value: string | number | undefined): string { if (value == null) return "0"; const num = Number(value); if (!Number.isFinite(num)) { return String(value).trim(); } const fixed = num.toFixed(12); return fixed.replace(/\.?0+$/, "") || "0"; } function normalizeSizeValue(value: string | number | undefined): string { if (value == null) return "0"; const num = Number(value); if (!Number.isFinite(num)) { return String(value).trim(); } if (Math.abs(num) < 1e-12) return "0"; return num.toString(); } function mapOrderType(type: OrderType): number { switch (type) { case "MARKET": return LIGHTER_ORDER_TYPE.MARKET; case "STOP_MARKET": return LIGHTER_ORDER_TYPE.STOP_LOSS; default: return LIGHTER_ORDER_TYPE.LIMIT; } } function mapTimeInForce(timeInForce: string | undefined, type: OrderType): number { // Lighter expects STOP orders to be immediate-or-cancel at trigger time. // Force IOC for MARKET and STOP_MARKET to satisfy chain validation. if (type === "MARKET" || type === "STOP_MARKET") { return LIGHTER_TIME_IN_FORCE.IMMEDIATE_OR_CANCEL; } const value = (timeInForce ?? "GTC").toUpperCase(); switch (value) { case "IOC": return LIGHTER_TIME_IN_FORCE.IMMEDIATE_OR_CANCEL; case "GTX": return LIGHTER_TIME_IN_FORCE.POST_ONLY; default: return LIGHTER_TIME_IN_FORCE.GOOD_TILL_TIME; } } function decimalsToStep(decimals: number): number { if (!Number.isFinite(decimals) || decimals <= 0) { return 1; } const step = Number(`1e-${decimals}`); return Number.isFinite(step) ? step : Math.pow(10, -decimals); } function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value != null && !Array.isArray(value); } function parseNumber(value: string | number): number | null { const num = typeof value === "number" ? value : Number(value); if (!Number.isFinite(num)) return null; return num; } function parseBaseQuote(symbol: string | null | undefined): { base?: string; quote?: string } { if (!symbol) return {}; const sanitized = symbol.toUpperCase(); const delimiters = ["/", "-", ":"]; for (const delimiter of delimiters) { if (sanitized.includes(delimiter)) { const [base, quote] = sanitized.split(delimiter); return { base: base || undefined, quote: quote || undefined }; } } if (sanitized.length >= 6) { // Fall back to first 3/remaining split for compact symbols like ETHUSDC return { base: sanitized.slice(0, 3), quote: sanitized.slice(3) }; } return { base: sanitized }; } function normalizeMarketType(value: string | null | undefined): "perp" | "spot" | undefined { if (!value) return undefined; const normalized = value.toLowerCase(); if (normalized === "spot") return "spot"; if (normalized === "perp" || normalized === "perpetual" || normalized === "futures") return "perp"; return undefined; } function guessMarketType(symbol: string | null | undefined): "perp" | "spot" | null { if (!symbol) return null; const upper = symbol.toUpperCase(); if (upper.includes("/") || upper.includes("-") || upper.includes(":") || upper.endsWith("USDC")) { return "spot"; } return null; } function isInvalidNonce(error: unknown): boolean { const message = typeof error === "string" ? error : error instanceof Error ? error.message : String(error); return message.includes("invalid nonce") || message.includes("\"code\":21104") || message.includes("21104"); } function extractJsonRequestId(message: any): string | null { if (!message || typeof message !== "object") return null; const direct = (message as { id?: unknown }).id; const nested = (message as { data?: { id?: unknown } }).data?.id; const value = direct ?? nested; if (value === undefined || value === null) return null; const str = String(value); return str ? str : null; } function tryParseTxInfo(value: string): unknown { try { return JSON.parse(value); } catch (_) { return value; } } function normalizeSymbolForms(value: string | null | undefined): string[] { if (!value) return []; const upper = value.toUpperCase(); const sanitized = upper.replace(/[^A-Z0-9]/g, ""); const parts = upper.split(/[-:/]/).filter(Boolean); const base = parts.length ? parts[0] : ""; const forms = new Set(); if (upper) forms.add(upper); if (sanitized) forms.add(sanitized); if (base) forms.add(base); return Array.from(forms); }