mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-10 16:58:08 +00:00
support lighter spot
This commit is contained in:
@@ -7,7 +7,15 @@ import type {
|
||||
OrderListener,
|
||||
TickerListener,
|
||||
} from "../adapter";
|
||||
import type { AsterAccountSnapshot, AsterDepth, AsterKline, AsterOrder, AsterTicker, CreateOrderParams } from "../types";
|
||||
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";
|
||||
@@ -16,10 +24,12 @@ import { LighterSigner, type CreateOrderSignParams } from "./signer";
|
||||
import { bytesToHex } from "./bytes";
|
||||
import type {
|
||||
LighterAccountDetails,
|
||||
LighterAccountAsset,
|
||||
LighterKline,
|
||||
LighterMarketStats,
|
||||
LighterOrder,
|
||||
LighterOrderBookLevel,
|
||||
LighterOrderBookMetadata,
|
||||
LighterOrderBookSnapshot,
|
||||
LighterPosition,
|
||||
} from "./types";
|
||||
@@ -145,6 +155,10 @@ const TERMINAL_ORDER_STATUSES = new Set([
|
||||
"canceled-reduce-only",
|
||||
]);
|
||||
|
||||
const KNOWN_SPOT_MARKETS: Record<string, { marketId: number; base: string; quote: string; priceDecimals?: number; sizeDecimals?: number }> = {
|
||||
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)
|
||||
@@ -188,10 +202,19 @@ export class LighterGateway {
|
||||
private lastWsPositionUpdateAt = 0;
|
||||
private readonly lastWsPositionByMarket = new Map<number, number>();
|
||||
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<string, LighterAccountAsset>();
|
||||
private minBaseAmount: number | null = null;
|
||||
private minQuoteAmount: number | null = null;
|
||||
private readonly orderIndexByClientId = new Map<string, string>();
|
||||
|
||||
private ws: WebSocket | null = null;
|
||||
@@ -209,6 +232,10 @@ export class LighterGateway {
|
||||
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;
|
||||
@@ -226,11 +253,33 @@ export class LighterGateway {
|
||||
constructor(options: LighterGatewayOptions) {
|
||||
this.displaySymbol = options.symbol;
|
||||
this.marketSymbol = (options.marketSymbol ?? options.symbol).toUpperCase();
|
||||
this.environment = inferEnvironment(options.environment, options.baseUrl);
|
||||
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}`);
|
||||
@@ -244,6 +293,9 @@ export class LighterGateway {
|
||||
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.nonceManager = new HttpNonceManager({
|
||||
accountIndex: options.accountIndex,
|
||||
apiKeyIndices: this.apiKeyIndices,
|
||||
@@ -306,47 +358,62 @@ export class LighterGateway {
|
||||
await this.ensureInitialized();
|
||||
const conversion = this.mapCreateOrderParams(params);
|
||||
const { baseAmountScaledString, priceScaledString, triggerPriceScaledString, ...signParams } = conversion;
|
||||
const { apiKeyIndex, nonce } = this.nonceManager.next();
|
||||
|
||||
const attemptCreate = async (): Promise<AsterOrder> => {
|
||||
const { apiKeyIndex, nonce } = 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.nonceManager.refresh(apiKeyIndex).catch((err) => this.logger("nonce.refresh", err));
|
||||
throw new Error("RETRY_INVALID_NONCE");
|
||||
}
|
||||
this.logger("createOrder", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
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 && !this.loggedCreateOrderPayload) {
|
||||
this.logger("createOrder.txInfo", signed.txInfo);
|
||||
this.loggedCreateOrderPayload = true;
|
||||
}
|
||||
const auth = await this.ensureAuthToken();
|
||||
const response = await this.http.sendTransaction(signed.txType, signed.txInfo, {
|
||||
authToken: auth,
|
||||
priceProtection: false,
|
||||
});
|
||||
if (debugEnabled && response.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);
|
||||
return await attemptCreate();
|
||||
} catch (error) {
|
||||
this.nonceManager.acknowledgeFailure(apiKeyIndex);
|
||||
this.logger("createOrder", error);
|
||||
if (error instanceof Error && error.message === "RETRY_INVALID_NONCE") {
|
||||
return await attemptCreate();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -365,12 +432,14 @@ export class LighterGateway {
|
||||
nonce,
|
||||
apiKeyIndex,
|
||||
});
|
||||
const auth = await this.ensureAuthToken();
|
||||
await this.http.sendTransaction(signed.txType, signed.txInfo, { authToken: auth });
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -387,10 +456,12 @@ export class LighterGateway {
|
||||
nonce,
|
||||
apiKeyIndex,
|
||||
});
|
||||
const auth = await this.ensureAuthToken();
|
||||
await this.http.sendTransaction(signed.txType, signed.txInfo, { authToken: auth });
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -409,12 +480,47 @@ export class LighterGateway {
|
||||
}
|
||||
|
||||
private async loadMetadata(): Promise<void> {
|
||||
if (this.marketId != null && this.priceDecimals != null && this.sizeDecimals != null) return;
|
||||
const books = await this.http.getOrderBooks();
|
||||
const desiredSymbol = this.marketSymbol;
|
||||
let target = books.find((book) => (book.symbol ? String(book.symbol).toUpperCase() : "") === desiredSymbol);
|
||||
if (!target && this.marketId != null) {
|
||||
target = books.find((book) => Number(book.market_id) === Number(this.marketId));
|
||||
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) {
|
||||
@@ -422,13 +528,31 @@ export class LighterGateway {
|
||||
}
|
||||
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<void> {
|
||||
@@ -462,6 +586,7 @@ export class LighterGateway {
|
||||
}
|
||||
this.accountDetails = details;
|
||||
this.applyHttpPositions(details);
|
||||
this.applyAccountAssets(details.assets);
|
||||
this.emitAccount();
|
||||
} catch (error) {
|
||||
this.logger("refreshAccount", error);
|
||||
@@ -487,6 +612,27 @@ export class LighterGateway {
|
||||
}
|
||||
}
|
||||
|
||||
private applyAccountAssets(assets?: LighterAccountAsset[] | Record<string, LighterAccountAsset> | 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;
|
||||
@@ -531,6 +677,7 @@ export class LighterGateway {
|
||||
ws.removeAllListeners();
|
||||
this.stopHeartbeat();
|
||||
this.stopClientPing();
|
||||
this.rejectPendingJsonRequests(new Error("WebSocket closed"));
|
||||
if (this.ws === ws) {
|
||||
this.ws = null;
|
||||
}
|
||||
@@ -608,6 +755,13 @@ export class LighterGateway {
|
||||
auth,
|
||||
})
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "subscribe",
|
||||
channel: `account_all_assets/${Number(this.signer.accountIndex)}`,
|
||||
auth,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureAuthToken(): Promise<string> {
|
||||
@@ -622,6 +776,109 @@ export class LighterGateway {
|
||||
return token;
|
||||
}
|
||||
|
||||
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(":") ||
|
||||
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;
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
@@ -730,9 +987,14 @@ export class LighterGateway {
|
||||
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);
|
||||
}
|
||||
@@ -760,6 +1022,27 @@ export class LighterGateway {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -858,6 +1141,14 @@ export class LighterGateway {
|
||||
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)) {
|
||||
@@ -986,6 +1277,54 @@ export class LighterGateway {
|
||||
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) {
|
||||
@@ -1101,8 +1440,16 @@ export class LighterGateway {
|
||||
this.displaySymbol,
|
||||
this.accountDetails,
|
||||
this.positions,
|
||||
[],
|
||||
{ marketSymbol: this.marketSymbol, marketId: this.marketId }
|
||||
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();
|
||||
@@ -1306,6 +1653,69 @@ export class LighterGateway {
|
||||
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;
|
||||
@@ -1328,6 +1738,66 @@ export class LighterGateway {
|
||||
};
|
||||
}
|
||||
|
||||
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): 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 Number.isFinite(available) ? available : null;
|
||||
}
|
||||
return 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 availableBase = this.getAvailableAssetAmount(this.baseAssetId, this.baseAssetSymbol);
|
||||
if (availableBase != null && availableBase + 1e-9 < qty) {
|
||||
throw new Error(
|
||||
`Insufficient base asset (${this.baseAssetSymbol ?? "BASE"} available ${availableBase}) 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<CreateOrderSignParams, "nonce"> & {
|
||||
baseAmountScaledString: string;
|
||||
priceScaledString: string;
|
||||
@@ -1337,12 +1807,41 @@ export class LighterGateway {
|
||||
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 baseAmount = scaleQuantityWithMinimum(params.quantity, this.sizeDecimals);
|
||||
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;
|
||||
@@ -1352,7 +1851,8 @@ export class LighterGateway {
|
||||
if (priceScaled == null) {
|
||||
throw new Error("Lighter order requires price");
|
||||
}
|
||||
const reduceOnly = params.reduceOnly === "true" || params.closePosition === "true" ? 1 : 0;
|
||||
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;
|
||||
@@ -1369,6 +1869,23 @@ export class LighterGateway {
|
||||
? 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,
|
||||
@@ -1533,3 +2050,79 @@ function decimalsToStep(decimals: number): number {
|
||||
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;
|
||||
}
|
||||
|
||||
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<string>();
|
||||
if (upper) forms.add(upper);
|
||||
if (sanitized) forms.add(sanitized);
|
||||
if (base) forms.add(base);
|
||||
return Array.from(forms);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user