support lighter spot

This commit is contained in:
discountry
2025-12-07 21:37:58 +08:00
parent 5f79b134f2
commit 180f47b6e0
89 changed files with 11921 additions and 124 deletions
+646 -53
View File
@@ -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);
}