Files
ritmex-bot/src/exchanges/lighter/gateway.ts
T

2341 lines
84 KiB
TypeScript

import { setInterval, clearInterval, setTimeout, clearTimeout } from "timers";
import WebSocket from "ws";
import { ReconnectScheduler, linearBackoff } from "../reconnect-scheduler";
import type {
AccountListener,
DepthListener,
KlineListener,
OrderListener,
TickerListener,
} from "../adapter";
import type {
AccountAsset,
AccountSnapshot,
Depth,
Kline,
Order,
Ticker,
CreateOrderParams,
} from "../types";
import type { OrderSide, OrderType } from "../types";
import { LighterHttpClient } from "./http-client";
import { HttpNonceManager } from "./nonce-manager";
import { LighterSigner, type CreateOrderSignParams } from "./signer";
import type {
LighterAccountDetails,
LighterAccountAsset,
LighterKline,
LighterMarketStats,
LighterOrder,
LighterOrderBookLevel,
LighterOrderBookMetadata,
LighterOrderBookSnapshot,
LighterPosition,
} from "./types";
import {
DEFAULT_AUTH_TOKEN_BUFFER_MS,
LIGHTER_ORDER_TYPE,
LIGHTER_TIME_IN_FORCE,
IMMEDIATE_OR_CANCEL_EXPIRY_PLACEHOLDER,
type LighterEnvironment,
} from "./constants";
import { resolveLighterNetwork, type LighterNetworkResolution } from "./network";
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<T> {
add(handler: (value: T) => void): void;
remove(handler: (value: T) => void): void;
emit(value: T): void;
listenerCount(): number;
}
function createEvent<T>(): SimpleEvent<T> {
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;
},
};
}
interface Pollers {
ticker?: ReturnType<typeof setInterval>;
klines: Map<string, ReturnType<typeof setInterval>>;
}
/**
* Spot balance of a single asset. `effective` is what every balance guard compares
* against: an unknown or unparseable asset collapses to 0 so guards fail closed
* instead of waving an order through.
*/
interface SpotAssetBalance {
available: number | null;
wallet: number | null;
effective: number;
}
function makeSpotAssetBalance(available: number | null, wallet: number | null): SpotAssetBalance {
return {
available,
wallet,
effective: Math.max(
available != null && Number.isFinite(available) ? available : 0,
wallet != null && Number.isFinite(wallet) ? wallet : 0
),
};
}
/** Tolerance that absorbs float drift when comparing a balance against an order size. */
const BALANCE_EPSILON = 1e-9;
const KLINE_DEFAULT_COUNT = 120;
const DEFAULT_TICKER_POLL_MS = 3000;
const DEFAULT_KLINE_POLL_MS = 15000;
const WS_RECONNECT_BASE_MS = 2_000;
const WS_RECONNECT_MAX_MS = 30_000;
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 ORDER_RESYNC_INTERVAL_MS = 10_000;
const POSITION_EPSILON = 1e-12;
const RESOLUTION_MS: Record<string, number> = {
"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",
]);
interface SpotMarketPreset {
marketId: number;
base: string;
quote: string;
priceDecimals?: number;
sizeDecimals?: number;
}
/**
* Market ids are per-deployment, so presets are keyed by environment first — reusing a mainnet
* id on Robinhood Chain would silently trade a different instrument.
*/
const KNOWN_SPOT_MARKETS: Partial<Record<LighterEnvironment, Record<string, SpotMarketPreset>>> = {
mainnet: {
ETHUSDC: { marketId: 2048, base: "ETH", quote: "USDC", priceDecimals: 2, sizeDecimals: 4 },
},
rh: {
ETHUSDG: { marketId: 2048, base: "ETH", quote: "USDG", priceDecimals: 2, sizeDecimals: 4 },
},
};
/**
* Which deployment lists a given spot symbol. Used only to pick an environment when the user
* supplied neither LIGHTER_ENV nor LIGHTER_BASE_URL, since the default is testnet.
*/
const SPOT_PRESET_ENVIRONMENTS: Record<string, LighterEnvironment> = {
ETHUSDC: "mainnet",
ETHUSDG: "rh",
};
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<number, string>;
baseUrl?: string;
/** Canonical name or alias; see LIGHTER_ENVIRONMENT_ALIASES. */
environment?: string;
wsUrl?: string;
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 network: LighterNetworkResolution;
private readonly environment: LighterEnvironment | null;
private networkVerified = false;
private readonly pollers: Pollers = { ticker: undefined, klines: new Map() };
private accountPoller: ReturnType<typeof setInterval> | null = null;
private accountPollInFlight = false;
private ordersResyncTimer: ReturnType<typeof setInterval> | null = null;
private ordersResyncInFlight = false;
private readonly klineCache = new Map<string, Kline[]>();
private readonly accountEvent = createEvent<AccountSnapshot>();
private readonly ordersEvent = createEvent<Order[]>();
private readonly depthEvent = createEvent<Depth>();
private readonly tickerEvent = createEvent<Ticker>();
private readonly klinesEvent = createEvent<Kline[]>();
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<void> = Promise.resolve();
private lastWsPositionUpdateAt = 0;
private readonly lastWsPositionByMarket = new Map<number, number>();
private httpPositionsEmptyLogged = false;
private forcedSpotPreset = false;
private marketId: number | null = null;
/** Exact symbol as listed by the venue (e.g. `ETH/USDG`), used to match stats payloads. */
private resolvedMarketSymbol: string | 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<string, LighterAccountAsset>();
private minBaseAmount: number | null = null;
private minQuoteAmount: number | null = null;
private readonly orderIndexByClientId = new Map<string, string>();
private ws: WebSocket | null = null;
private readonly reconnect: ReconnectScheduler = new ReconnectScheduler({
connect: async () => {
await this.openWebSocket();
this.reconnect.onConnected();
},
backoff: linearBackoff(WS_RECONNECT_BASE_MS, WS_RECONNECT_MAX_MS),
onError: (error) => this.logger("reconnect", error),
});
private readonly wsUrl: string;
private connectPromise: Promise<void> | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private pingTimer: ReturnType<typeof setInterval> | null = null;
private lastMessageAt = 0;
private accountDetails: LighterAccountDetails | null = null;
private positions: LighterPosition[] = [];
private orders: LighterOrder[] = [];
private readonly orderMap = new Map<string, LighterOrder>();
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<typeof setInterval> | 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;
// Explicit overrides are applied before presets so a preset can only fill a gap, never
// overwrite what the operator asked for.
this.marketId =
options.marketId != null
? Number(options.marketId)
: process.env.LIGHTER_MARKET_ID
? Number(process.env.LIGHTER_MARKET_ID)
: null;
this.priceDecimals = options.priceDecimals ?? null;
this.sizeDecimals = options.sizeDecimals ?? null;
if (process.env.LIGHTER_MARKET_TYPE) {
this.marketType = normalizeMarketType(process.env.LIGHTER_MARKET_TYPE) ?? this.marketType;
}
const baseUrl = options.baseUrl ?? process.env.LIGHTER_BASE_URL ?? undefined;
// A spot-only symbol implies its venue, but only when nothing more explicit was given —
// otherwise the default (testnet) would be picked for a market that does not exist there.
const presetEnvHint = SPOT_PRESET_ENVIRONMENTS[normalizeSymbolKey(this.marketSymbol)];
this.network = resolveLighterNetwork({
environment: options.environment ?? process.env.LIGHTER_ENV ?? (baseUrl ? undefined : presetEnvHint),
baseUrl,
wsUrl: options.wsUrl ?? process.env.LIGHTER_WS_URL,
chainId: options.chainId,
});
this.environment = this.network.environment;
// Market ids are per-deployment, so presets can only be applied once the venue is known.
this.applyPresetMarket();
this.wsUrl = this.network.wsUrl;
this.http = new LighterHttpClient({ baseUrl: this.network.restUrl });
this.signer = new LighterSigner({
accountIndex: options.accountIndex,
chainId: this.network.chainId,
apiKeys: options.apiKeys,
baseUrl: this.network.restUrl,
});
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.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;
this.announceNetwork();
}
/** One line so an operator can confirm which venue the bot actually attached to. */
private announceNetwork(): void {
// eslint-disable-next-line no-console
console.error(
`[Lighter] env=${this.environment ?? "custom"} rest=${this.network.restUrl} ws=${this.network.wsUrl} ` +
`chainId=${this.network.chainId} account=${Number(this.signer.accountIndex)}`
);
}
async ensureInitialized(): Promise<void> {
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<Order> {
const run = async (): Promise<Order> => {
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<void> {
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<void> {
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<void> {
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();
}
/**
* Proves the REST host really is the deployment the config claims, before a single order is
* signed. The signing chain id is not exposed by any endpoint, so it can only be validated
* indirectly: `layer1BasicInfo` carries the L1 chain id and the ZkLighter contract address,
* both unique per deployment. A mismatch means REST, websocket and chain id have drifted
* apart — every transaction would be signed for the wrong chain — so it fails closed.
*/
private async verifyNetworkIdentity(): Promise<void> {
if (this.networkVerified) return;
const { expectedL1ChainId, expectedZkLighterContract } = this.network;
if (expectedL1ChainId == null && expectedZkLighterContract == null) {
this.networkVerified = true;
return;
}
let info: Awaited<ReturnType<LighterHttpClient["getLayer1BasicInfo"]>>;
try {
info = await this.http.getLayer1BasicInfo();
} catch (error) {
// An auxiliary endpoint being unreachable must not block trading; the real calls will
// surface a connectivity problem on their own.
this.logger("verifyNetwork", error);
return;
}
const actualL1ChainId = info.l1_providers?.[0]?.chainId ?? null;
const actualContract =
info.contract_addresses?.find((entry) => entry.name === "ZkLighterContract")?.address ?? null;
const mismatches: string[] = [];
if (expectedL1ChainId != null && actualL1ChainId != null && actualL1ChainId !== expectedL1ChainId) {
mismatches.push(`L1 chainId ${actualL1ChainId} (expected ${expectedL1ChainId})`);
}
if (
expectedZkLighterContract &&
actualContract &&
actualContract.toLowerCase() !== expectedZkLighterContract.toLowerCase()
) {
mismatches.push(`ZkLighter contract ${actualContract} (expected ${expectedZkLighterContract})`);
}
if (mismatches.length) {
throw new Error(
`Lighter network mismatch: ${this.network.restUrl} reports ${mismatches.join(" and ")}. ` +
`Config claims env=${this.environment ?? "custom"} (signing chainId ${this.network.chainId}). ` +
`Fix LIGHTER_ENV / LIGHTER_BASE_URL before trading.`
);
}
this.networkVerified = true;
}
private async loadMetadata(): Promise<void> {
await this.verifyNetworkIdentity();
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 && this.environment) {
const preset = KNOWN_SPOT_MARKETS[this.environment]?.[normalizeSymbolKey(desiredSymbol)];
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) {
// Market ids are per-deployment, so a stale id carried over from another venue is the
// most likely cause here.
throw new Error(
`Configured market id ${this.marketId} not found on ${this.environment ?? this.network.restUrl}. ` +
`Market ids differ per deployment — clear LIGHTER_MARKET_ID or set one listed by this venue.`
);
}
}
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.assertUnitMultiplier(target);
this.marketId = Number(target.market_id);
this.resolvedMarketSymbol = target.symbol ?? null;
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"}`);
}
}
/**
* Robinhood Chain lists a few tokenized-equity markets whose contract `multiplier` is not 1
* (corporate actions / accrued yield). Size and price scaling here assumes 1.0, so those
* markets are refused rather than traded with quietly wrong quantities. Override only if you
* have verified the scaling yourself.
*/
private assertUnitMultiplier(book: LighterOrderBookMetadata): void {
const raw = book.multiplier;
if (raw == null) return;
const multiplier = Number(raw);
if (!Number.isFinite(multiplier) || Math.abs(multiplier - 1) < 1e-9) return;
if (process.env.LIGHTER_ALLOW_NON_UNIT_MULTIPLIER === "1" || process.env.LIGHTER_ALLOW_NON_UNIT_MULTIPLIER === "true") {
this.logger(
"loadMetadata",
`market ${book.symbol} has multiplier ${raw}; order sizing assumes 1.0 and may be off`
);
return;
}
throw new Error(
`Lighter market ${book.symbol} (id=${book.market_id}) has contract multiplier ${raw}, not 1.0. ` +
`Order size/price scaling assumes 1.0, so trading it could size positions incorrectly. ` +
`Set LIGHTER_ALLOW_NON_UNIT_MULTIPLIER=1 to proceed anyway.`
);
}
private async refreshAccountSnapshot(): Promise<void> {
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<string, LighterAccountAsset> | null): void {
const payloadProvided = assets !== undefined && assets !== null;
const hasRawEntries =
Array.isArray(assets) ? assets.length > 0 : isPlainObject(assets) ? Object.keys(assets).length > 0 : false;
const normalized = this.normalizeAssets(assets);
if (!normalized.length) {
// Explicitly clear cached balances when the venue returns an empty payload,
// so closed spot positions don't linger as phantom holdings.
if (payloadProvided && !hasRawEntries) {
this.assets.clear();
}
return;
}
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<void> {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
return;
}
await new Promise<void>((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.reconnect.schedule();
});
ws.on("error", (error) => {
this.logger("ws:error", error);
cleanup();
if (!settled) {
fail(error);
return;
}
this.stopStaleMonitor();
this.reconnect.schedule();
});
});
}
private async subscribeChannels(): Promise<void> {
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<string> {
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<void> {
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<void> {
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<unknown> {
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<unknown> {
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(":") ||
SPOT_QUOTE_SUFFIXES.some((suffix) => normalizedDesired.includes(suffix));
const preferred = candidates.filter((book) =>
wantsSpot ? normalizeMarketType(book.market_type) === "spot" : true
);
if (wantsSpot && preferred.length) {
candidates = preferred;
}
const preferSpot = wantsSpot;
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) {
const aScore = preferSpot ? aSpot : 1 - aSpot; // prefer perp when not explicitly spot
const bScore = preferSpot ? bSpot : 1 - bSpot;
return bScore - aScore;
}
return 0;
});
return candidates[0] ?? null;
}
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.reconnect.schedule();
}
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.reconnect.schedule();
}
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<string, unknown> | null | undefined): void {
const extraPayload: Record<string, unknown> = {};
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<string, unknown> = {}): 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<number, LighterPosition>();
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<number, LighterPosition>();
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<string, unknown>) : {};
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;
// Subscriptions use `account_market/{market}/{account}`; echoes may come back colon-separated.
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,
// Falls back to the venue's settlement asset (USDG on rh, USDC elsewhere) so the
// dashboard never labels a balance with the wrong currency.
quoteAssetSymbol: this.quoteAssetSymbol ?? this.network.defaultQuoteAsset,
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();
}
if (!this.ordersResyncTimer) {
const resyncOrders = () => {
const now = Date.now();
if (now - this.lastOrdersUpdateAt < ORDER_RESYNC_INTERVAL_MS - 500) return;
if (this.ordersResyncInFlight) return;
this.ordersResyncInFlight = true;
this.requestOrdersSnapshot()
.catch((error) => this.logger("ordersResync", error))
.finally(() => {
this.ordersResyncInFlight = false;
});
};
this.ordersResyncTimer = setInterval(resyncOrders, ORDER_RESYNC_INTERVAL_MS);
}
}
private async requestOrdersSnapshot(): Promise<void> {
const ws = this.ws;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
try {
const auth = await this.ensureAuthToken();
ws.send(
JSON.stringify({
type: "subscribe",
channel: `account_all_orders/${Number(this.signer.accountIndex)}`,
auth,
})
);
} catch (error) {
this.logger("ordersResyncSnapshot", error);
}
}
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<void> {
try {
const stats = await this.http.getExchangeStats();
const marketId = this.marketId;
if (marketId == null) return;
// Neither mainnet nor rh returns market_id in this payload today, so matching falls back
// to the exact venue symbol. Compared delimiter-free (`ETH/USDG` vs `ETHUSDG`) but never
// by base alone, which would let the ETH perp masquerade as the ETH/USDG spot market.
const desiredKey = normalizeSymbolKey(this.resolvedMarketSymbol ?? this.marketSymbol);
const match = stats.find((entry) => {
if (entry.market_id != null && Number(entry.market_id) === marketId) return true;
return entry.symbol ? normalizeSymbolKey(entry.symbol) === desiredKey : false;
});
if (!match) return;
// Cached so estimateMarketPrice has a last-trade fallback when the book is empty.
this.ticker = match;
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<void> {
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: Ticker = {
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(): AccountAsset[] {
if (!this.assets.size) return [];
const now = Date.now();
const list: AccountAsset[] = [];
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: String(asset.balance ?? "0"),
availableBalance:
available != null && Number.isFinite(available) ? available.toString() : String(asset.balance ?? "0"),
updateTime: now,
assetId: Number.isFinite(assetId) ? assetId : undefined,
});
}
return list.sort((a, b) => a.asset.localeCompare(b.asset));
}
private applyPresetMarket(): void {
if (!this.environment) return;
const normalized = normalizeSymbolKey(this.marketSymbol);
const preset = KNOWN_SPOT_MARKETS[this.environment]?.[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] ?? null;
}
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 getSpotAssetBalance(assetId?: number | null, symbol?: string | null): SpotAssetBalance {
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 makeSpotAssetBalance(
Number.isFinite(available) ? available : null,
Number.isFinite(balance) ? balance : null
);
}
return makeSpotAssetBalance(null, 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 base = this.getSpotAssetBalance(this.baseAssetId, this.baseAssetSymbol);
if (base.effective + BALANCE_EPSILON < qty) {
throw new Error(
`Insufficient base asset (${this.baseAssetSymbol ?? "BASE"} available ${base.available ?? 0}${
base.wallet != null ? ` wallet ${base.wallet}` : ""
}) for spot sell ${qty}`
);
}
return;
}
const price = Number(params.price);
if (!Number.isFinite(price) || price <= 0) return;
const requiredQuote = qty * price;
const quote = this.getSpotAssetBalance(this.quoteAssetId, this.quoteAssetSymbol);
if (quote.effective + BALANCE_EPSILON < requiredQuote) {
throw new Error(
`Insufficient quote asset (${this.quoteAssetSymbol ?? "QUOTE"} available ${
quote.available ?? 0
}) for spot buy requiring ${requiredQuote}`
);
}
}
private mapCreateOrderParams(params: CreateOrderParams): Omit<CreateOrderSignParams, "nonce"> & {
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 base = this.getSpotAssetBalance(this.baseAssetId, this.baseAssetSymbol);
if (base.effective + BALANCE_EPSILON < enforcedQty) {
throw new Error(
`Spot sell quantity ${enforcedQty} exceeds available base ${base.effective} (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<string, string>();
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: Kline[]): Kline[] {
return klines.map((kline) => ({ ...kline }));
}
function getBestPrice(levels: LighterOrderBookLevel[] | Array<any> | 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 | [string | number, string | number]>): 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<string, unknown> {
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;
}
/**
* Quote assets that mark a compact symbol as spot. Deliberately excludes bare "USD": mainnet
* lists forex perps such as NZDUSD that would otherwise be mistaken for spot pairs.
*/
const SPOT_QUOTE_SUFFIXES = ["USDC", "USDG"];
function guessMarketType(symbol: string | null | undefined): "perp" | "spot" | null {
if (!symbol) return null;
const upper = symbol.toUpperCase();
if (upper.includes("/") || upper.includes("-") || upper.includes(":")) {
return "spot";
}
if (SPOT_QUOTE_SUFFIXES.some((suffix) => upper.endsWith(suffix))) {
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;
}
}
/** Delimiter-free upper-case form, e.g. `ETH/USDG` and `eth-usdg` both become `ETHUSDG`. */
function normalizeSymbolKey(value: string | null | undefined): string {
return (value ?? "").toUpperCase().replace(/[^A-Z0-9]/g, "");
}
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<string>();
if (upper) forms.add(upper);
if (sanitized) forms.add(sanitized);
if (base) forms.add(base);
return Array.from(forms);
}