diff --git a/docs/lighter/lighter-python-main/lighter/signers/signer-arm64.dylib b/docs/lighter/lighter-python-main/lighter/signers/signer-arm64.dylib new file mode 100644 index 0000000..1ef58f7 Binary files /dev/null and b/docs/lighter/lighter-python-main/lighter/signers/signer-arm64.dylib differ diff --git a/scripts/derive-public.ts b/scripts/derive-public.ts new file mode 100644 index 0000000..54b62a5 --- /dev/null +++ b/scripts/derive-public.ts @@ -0,0 +1,16 @@ +import { LighterPrivateKey } from "../src/exchanges/lighter/crypto/schnorr"; +import { bytesToHex } from "../src/exchanges/lighter/bytes"; + +function derive(keyHex: string): string { + const normalized = keyHex.startsWith("0x") ? keyHex.slice(2) : keyHex; + const key = LighterPrivateKey.fromHex(normalized); + return bytesToHex(key.publicKey().toBytes()); +} + +const input = process.argv[2]; +if (!input) { + console.error("usage: bun run scripts/derive-public.ts "); + process.exit(1); +} + +console.log(derive(input)); diff --git a/scripts/lighter-public-key.ts b/scripts/lighter-public-key.ts new file mode 100644 index 0000000..7f56d08 --- /dev/null +++ b/scripts/lighter-public-key.ts @@ -0,0 +1,21 @@ +import "dotenv/config"; +import { LighterPrivateKey } from "../src/exchanges/lighter/crypto/schnorr"; +import { bytesToHex } from "../src/exchanges/lighter/bytes"; + +function main(): void { + const raw = process.env.LIGHTER_API_PRIVATE_KEY; + if (!raw) { + throw new Error("LIGHTER_API_PRIVATE_KEY env var is required"); + } + const normalized = raw.startsWith("0x") ? raw.slice(2) : raw; + const key = LighterPrivateKey.fromHex(normalized); + const publicKeyHex = bytesToHex(key.publicKey().toBytes()); + const apiKeyIndex = process.env.LIGHTER_API_KEY_INDEX ?? "(not set)"; + + console.log(JSON.stringify({ + apiKeyIndex, + publicKeyHex, + }, null, 2)); +} + +main(); diff --git a/scripts/lighter_signer_bridge.py b/scripts/lighter_signer_bridge.py new file mode 100755 index 0000000..78607a6 --- /dev/null +++ b/scripts/lighter_signer_bridge.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 + +"""Lightweight bridge to the Lighter signer shared library using stdin/stdout JSON RPC.""" + +import ctypes +import json +import os +import platform +import subprocess +import sys +from typing import Any, Dict + + +class StrOrErr(ctypes.Structure): + _fields_ = [("str", ctypes.c_char_p), ("err", ctypes.c_char_p)] + + +def _resolve_signer_path() -> str: + base = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + signers_dir = os.path.join(base, "docs", "lighter", "lighter-python-main", "lighter", "signers") + system = platform.system() + machine = platform.machine().lower() + + if system == "Darwin": + path = os.path.join(signers_dir, "signer-arm64.dylib" if machine == "arm64" else "signer-amd64.dylib") + elif system == "Linux": + path = os.path.join(signers_dir, "signer-amd64.so") + else: + raise RuntimeError(f"Unsupported platform: {system} {machine}") + + if not os.path.exists(path): + raise FileNotFoundError(f"Signer library missing: {path}") + return path + + +def _load_library(path: str) -> ctypes.CDLL: + try: + return ctypes.CDLL(path) + except OSError as exc: # pragma: no cover - runtime environment guard + message = str(exc) + if platform.system() == "Darwin" and "code signature" in message: + subprocess.run(["/usr/bin/xattr", "-d", "com.apple.quarantine", path], check=False, capture_output=True) + subprocess.run(["/usr/bin/codesign", "--force", "--sign", "-", path], check=False, capture_output=True) + return ctypes.CDLL(path) + raise + + +SIGNER_PATH = _resolve_signer_path() + +try: + LIB = _load_library(SIGNER_PATH) +except OSError as exc: # pragma: no cover - runtime environment guard + print(json.dumps({"id": None, "error": f"failed_to_load_signer:{exc}"}), flush=True) + sys.exit(1) + + +LIB.CreateClient.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int, ctypes.c_int, ctypes.c_longlong] +LIB.CreateClient.restype = ctypes.c_char_p + +LIB.SwitchAPIKey.argtypes = [ctypes.c_int] +LIB.SwitchAPIKey.restype = ctypes.c_char_p + +LIB.SignCreateOrder.argtypes = [ + ctypes.c_int, + ctypes.c_longlong, + ctypes.c_longlong, + ctypes.c_int, + ctypes.c_int, + ctypes.c_int, + ctypes.c_int, + ctypes.c_int, + ctypes.c_int, + ctypes.c_longlong, + ctypes.c_longlong, +] +LIB.SignCreateOrder.restype = StrOrErr + +LIB.SignCancelOrder.argtypes = [ctypes.c_int, ctypes.c_longlong, ctypes.c_longlong] +LIB.SignCancelOrder.restype = StrOrErr + +LIB.SignCancelAllOrders.argtypes = [ctypes.c_int, ctypes.c_longlong, ctypes.c_longlong] +LIB.SignCancelAllOrders.restype = StrOrErr + +LIB.CreateAuthToken.argtypes = [ctypes.c_longlong] +LIB.CreateAuthToken.restype = StrOrErr + + +def _unwrap(result: StrOrErr) -> Dict[str, Any]: + if result.err: + return {"error": ctypes.string_at(result.err).decode("utf-8", errors="replace")} + if result.str: + return {"result": ctypes.string_at(result.str).decode("utf-8", errors="replace")} + return {"result": None} + + +def _maybe_error(ptr: ctypes.c_char_p) -> Dict[str, Any]: + if ptr: + return {"error": ctypes.string_at(ptr).decode("utf-8", errors="replace")} + return {"result": "ok"} + + +_INITIALISED_KEYS = set() +_CLIENT_CONFIG: Dict[int, Dict[str, Any]] = {} + + +def _ensure_client(params: Dict[str, Any]) -> Dict[str, Any]: + api_key_index = int(params["apiKeyIndex"]) + + config = _CLIENT_CONFIG.get(api_key_index) + if "baseUrl" in params and "privateKey" in params: + config = { + "baseUrl": params["baseUrl"], + "privateKey": params["privateKey"], + "chainId": int(params["chainId"]), + "accountIndex": int(params["accountIndex"]), + } + _CLIENT_CONFIG[api_key_index] = config + + if config is None: + return {"error": "client_not_initialized"} + + if api_key_index in _INITIALISED_KEYS: + return {"result": "ok"} + + err_ptr = LIB.CreateClient( + config["baseUrl"].encode("utf-8"), + config["privateKey"].encode("utf-8"), + ctypes.c_int(int(config["chainId"])), + ctypes.c_int(api_key_index), + ctypes.c_longlong(int(config["accountIndex"])), + ) + outcome = _maybe_error(err_ptr) + if "error" in outcome: + return outcome + + _INITIALISED_KEYS.add(api_key_index) + return {"result": "ok"} + + +def _switch_api_key(api_key_index: int) -> Dict[str, Any]: + err_ptr = LIB.SwitchAPIKey(ctypes.c_int(api_key_index)) + return _maybe_error(err_ptr) + + +def handle_create_client(params: Dict[str, Any]) -> Dict[str, Any]: + return _ensure_client(params) + + +def handle_sign_create_order(params: Dict[str, Any]) -> Dict[str, Any]: + ensure = _ensure_client(params) + if "error" in ensure: + return ensure + + api_key_index = int(params["apiKeyIndex"]) + switched = _switch_api_key(api_key_index) + if "error" in switched: + return switched + + expiry = int(params["orderExpiry"]) + result = LIB.SignCreateOrder( + ctypes.c_int(int(params["marketIndex"])), + ctypes.c_longlong(int(params["clientOrderIndex"])), + ctypes.c_longlong(int(params["baseAmount"])), + ctypes.c_int(int(params["price"])), + ctypes.c_int(int(params["isAsk"])), + ctypes.c_int(int(params["orderType"])), + ctypes.c_int(int(params["timeInForce"])), + ctypes.c_int(int(params["reduceOnly"])), + ctypes.c_int(int(params["triggerPrice"])), + ctypes.c_longlong(expiry), + ctypes.c_longlong(int(params["nonce"])), + ) + return _unwrap(result) + + +def handle_sign_cancel_order(params: Dict[str, Any]) -> Dict[str, Any]: + ensure = _ensure_client(params) + if "error" in ensure: + return ensure + + api_key_index = int(params["apiKeyIndex"]) + switched = _switch_api_key(api_key_index) + if "error" in switched: + return switched + + result = LIB.SignCancelOrder( + ctypes.c_int(int(params["marketIndex"])), + ctypes.c_longlong(int(params["orderIndex"])), + ctypes.c_longlong(int(params["nonce"])), + ) + return _unwrap(result) + + +def handle_sign_cancel_all(params: Dict[str, Any]) -> Dict[str, Any]: + ensure = _ensure_client(params) + if "error" in ensure: + return ensure + + api_key_index = int(params["apiKeyIndex"]) + switched = _switch_api_key(api_key_index) + if "error" in switched: + return switched + + result = LIB.SignCancelAllOrders( + ctypes.c_int(int(params["timeInForce"])), + ctypes.c_longlong(int(params["scheduledTime"])), + ctypes.c_longlong(int(params["nonce"])), + ) + return _unwrap(result) + + +def handle_create_auth_token(params: Dict[str, Any]) -> Dict[str, Any]: + ensure = _ensure_client(params) + if "error" in ensure: + return ensure + + api_key_index = int(params["apiKeyIndex"]) + switched = _switch_api_key(api_key_index) + if "error" in switched: + return switched + + result = LIB.CreateAuthToken(ctypes.c_longlong(int(params["deadlineMs"]))) + return _unwrap(result) + + +HANDLERS = { + "create_client": handle_create_client, + "sign_create_order": handle_sign_create_order, + "sign_cancel_order": handle_sign_cancel_order, + "sign_cancel_all": handle_sign_cancel_all, + "create_auth_token": handle_create_auth_token, +} + + +def main() -> None: + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + request = json.loads(line) + except json.JSONDecodeError as exc: # pragma: no cover - defensive + print(json.dumps({"id": None, "error": f"invalid_json:{exc}"}), flush=True) + continue + + req_id = request.get("id") + method = request.get("method") + params = request.get("params", {}) + + handler = HANDLERS.get(method) + if not handler: + response = {"id": req_id, "error": f"unknown_method:{method}"} + else: + try: + outcome = handler(params) + outcome.setdefault("id", req_id) + response = outcome + except Exception as exc: # pragma: no cover - safety net + response = {"id": req_id, "error": f"exception:{exc}"} + + print(json.dumps(response), flush=True) + + +if __name__ == "__main__": + main() diff --git a/src/exchanges/lighter/gateway.ts b/src/exchanges/lighter/gateway.ts index 2479d6a..3c5aa34 100644 --- a/src/exchanges/lighter/gateway.ts +++ b/src/exchanges/lighter/gateway.ts @@ -23,7 +23,16 @@ import type { LighterOrderBookSnapshot, LighterPosition, } from "./types"; -import { DEFAULT_AUTH_TOKEN_BUFFER_MS, DEFAULT_LIGHTER_ENVIRONMENT, LIGHTER_HOSTS, LIGHTER_ORDER_TYPE, LIGHTER_TIME_IN_FORCE, DEFAULT_ORDER_EXPIRY_PLACEHOLDER, IMMEDIATE_OR_CANCEL_EXPIRY_PLACEHOLDER } from "./constants"; +import { + DEFAULT_AUTH_TOKEN_BUFFER_MS, + DEFAULT_LIGHTER_ENVIRONMENT, + LIGHTER_HOSTS, + LIGHTER_ORDER_TYPE, + LIGHTER_TIME_IN_FORCE, + DEFAULT_ORDER_EXPIRY_PLACEHOLDER, + IMMEDIATE_OR_CANCEL_EXPIRY_PLACEHOLDER, + type LighterEnvironment, +} from "./constants"; import { decimalToScaled, scaledToDecimalString } from "./decimal"; import { lighterOrderToAster, toAccountSnapshot, toDepth, toKlines, toOrders, toTicker } from "./mappers"; @@ -58,6 +67,47 @@ function createEvent(): SimpleEvent { }; } +function isLighterEnvironment(value: string | undefined | null): value is LighterEnvironment { + if (!value) return false; + return Object.prototype.hasOwnProperty.call(LIGHTER_HOSTS, value); +} + +function detectEnvironmentFromUrl(baseUrl: string | undefined | null): LighterEnvironment | null { + if (!baseUrl) return null; + const matchHost = (host: string): LighterEnvironment | null => { + for (const [env, config] of Object.entries(LIGHTER_HOSTS)) { + try { + const restHost = new URL(config.rest).hostname.toLowerCase(); + if (restHost === host) { + return env as LighterEnvironment; + } + } catch { + // ignore invalid config URLs + } + } + if (host.includes("mainnet")) return "mainnet"; + if (host.includes("testnet")) return "testnet"; + if (host.includes("staging")) return "staging"; + if (host.includes("dev")) return "dev"; + return null; + }; + + try { + const parsed = new URL(baseUrl); + return matchHost(parsed.hostname.toLowerCase()); + } catch { + return matchHost(baseUrl.toLowerCase()); + } +} + +function inferEnvironment(envOption: string | undefined, baseUrl?: string | null): LighterEnvironment { + if (isLighterEnvironment(envOption)) { + return envOption; + } + const detected = detectEnvironmentFromUrl(baseUrl ?? undefined); + return detected ?? DEFAULT_LIGHTER_ENVIRONMENT; +} + interface Pollers { ticker?: ReturnType; klines: Map>; @@ -112,6 +162,7 @@ export class LighterGateway { private readonly klinesEvent = createEvent(); private readonly auth = { token: null as string | null, expiresAt: 0 }; private readonly l1Address: string | null; + private loggedCreateOrderPayload = false; private marketId: number | null = null; private priceDecimals: number | null = null; @@ -125,6 +176,7 @@ export class LighterGateway { private accountDetails: LighterAccountDetails | null = null; private positions: LighterPosition[] = []; private orders: LighterOrder[] = []; + private readonly orderMap = new Map(); private orderBook: LighterOrderBookSnapshot | null = null; private ticker: LighterMarketStats | null = null; private initialized = false; @@ -135,7 +187,7 @@ export class LighterGateway { constructor(options: LighterGatewayOptions) { this.displaySymbol = options.symbol; this.marketSymbol = (options.marketSymbol ?? options.symbol).toUpperCase(); - this.environment = options.environment ?? DEFAULT_LIGHTER_ENVIRONMENT; + this.environment = inferEnvironment(options.environment, options.baseUrl); const host = options.baseUrl ?? LIGHTER_HOSTS[this.environment]?.rest; if (!host) { throw new Error(`Unknown Lighter environment ${this.environment}`); @@ -150,6 +202,7 @@ export class LighterGateway { accountIndex: options.accountIndex, chainId: options.chainId ?? (this.environment === "mainnet" ? 304 : 300), apiKeys: options.apiKeys, + baseUrl: host, }); this.apiKeyIndices = options.apiKeyIndices ?? Object.keys(options.apiKeys).map(Number); this.nonceManager = new HttpNonceManager({ @@ -204,13 +257,21 @@ export class LighterGateway { const { baseAmountScaledString, priceScaledString, triggerPriceScaledString, ...signParams } = conversion; const { apiKeyIndex, nonce } = this.nonceManager.next(); try { - const signed = this.signer.signCreateOrder({ + const signed = await this.signer.signCreateOrder({ ...signParams, apiKeyIndex, nonce, }); + if (!this.loggedCreateOrderPayload) { + this.logger("createOrder.txInfo", signed.txInfo); + this.loggedCreateOrderPayload = true; + } const auth = await this.ensureAuthToken(); - await this.http.sendTransaction(signed.txType, signed.txInfo, { authToken: auth }); + const response = await this.http.sendTransaction(signed.txType, signed.txInfo, { + authToken: auth, + priceProtection: false, + }); + this.logger("createOrder.sendTx.response", response); return lighterOrderToAster(this.displaySymbol, { order_index: Number(signParams.clientOrderIndex % 1_000_000_000n), client_order_index: Number(signParams.clientOrderIndex), @@ -228,6 +289,7 @@ export class LighterGateway { } as LighterOrder); } catch (error) { this.nonceManager.acknowledgeFailure(apiKeyIndex); + this.logger("createOrder", error); throw error; } } @@ -239,7 +301,7 @@ export class LighterGateway { const indexValue = BigInt(typeof params.orderId === "string" ? Number(params.orderId) : params.orderId); const { apiKeyIndex, nonce } = this.nonceManager.next(); try { - const signed = this.signer.signCancelOrder({ + const signed = await this.signer.signCancelOrder({ marketIndex, orderIndex: indexValue, nonce, @@ -259,7 +321,7 @@ export class LighterGateway { const time = params?.scheduleMs != null ? BigInt(params.scheduleMs) : 0n; const { apiKeyIndex, nonce } = this.nonceManager.next(); try { - const signed = this.signer.signCancelAll({ + const signed = await this.signer.signCancelAll({ timeInForce, scheduledTime: time, nonce, @@ -479,8 +541,19 @@ export class LighterGateway { const ordersObject = message.orders ?? {}; const buckets = Object.values(ordersObject) as unknown[]; const allOrders: LighterOrder[] = buckets.flatMap((entry) => Array.isArray(entry) ? (entry as LighterOrder[]) : []); - this.orders = allOrders; - const mapped = toOrders(this.displaySymbol, allOrders); + const terminalStatuses = new Set(["filled", "canceled", "cancelled", "expired"]); + for (const order of allOrders) { + const key = String(order.order_index ?? order.order_id ?? order.client_order_index ?? ""); + const status = (order.status ?? "").toLowerCase(); + if (!key) continue; + if (terminalStatuses.has(status)) { + this.orderMap.delete(key); + } else { + this.orderMap.set(key, order); + } + } + this.orders = Array.from(this.orderMap.values()); + const mapped = toOrders(this.displaySymbol, this.orders); this.ordersEvent.emit(mapped); } @@ -488,6 +561,7 @@ export class LighterGateway { if (!this.orderBook || this.marketId == null) return; const depth = toDepth(this.displaySymbol, this.orderBook); this.depthEvent.emit(depth); + this.emitSyntheticTicker(); } private emitAccount(): void { @@ -521,6 +595,7 @@ export class LighterGateway { if (!match) return; const ticker = toTicker(this.displaySymbol, match); this.tickerEvent.emit(ticker); + this.loggedCreateOrderPayload = false; } catch (error) { this.logger("refreshTicker", error); } @@ -562,9 +637,40 @@ export class LighterGateway { startTimestamp: startTs, setTimestampToEnd: true, }); - const mapped = toKlines(this.displaySymbol, interval, raw as LighterKline[]); + const sorted = (raw as LighterKline[]).slice().sort((a, b) => a.start_timestamp - b.start_timestamp); + const mapped = toKlines(this.displaySymbol, interval, sorted); this.klineCache.set(interval, mapped); this.klinesEvent.emit(cloneKlines(mapped)); + this.emitSyntheticTicker(); + } + + private emitSyntheticTicker(): void { + if (!this.orderBook) return; + const bestBid = getBestPrice(this.orderBook.bids, "bid"); + const bestAsk = getBestPrice(this.orderBook.asks, "ask"); + if (bestBid == null && bestAsk == null) return; + const last = bestBid != null && bestAsk != null ? (bestBid + bestAsk) / 2 : (bestBid ?? bestAsk ?? 0); + const ticker: AsterTicker = { + symbol: this.displaySymbol, + eventType: "lighterSyntheticTicker", + eventTime: Date.now(), + lastPrice: last.toString(), + openPrice: (bestBid ?? last).toString(), + highPrice: (bestAsk ?? last).toString(), + lowPrice: (bestBid ?? last).toString(), + volume: "0", + quoteVolume: "0", + priceChange: undefined, + priceChangePercent: undefined, + weightedAvgPrice: undefined, + lastQty: undefined, + openTime: Date.now(), + closeTime: Date.now(), + firstId: undefined, + lastId: undefined, + count: undefined, + }; + this.tickerEvent.emit(ticker); } private mapCreateOrderParams(params: CreateOrderParams): Omit & { @@ -659,6 +765,18 @@ function cloneKlines(klines: AsterKline[]): AsterKline[] { return klines.map((kline) => ({ ...kline })); } +function getBestPrice(levels: LighterOrderBookLevel[] | Array | undefined, side: "bid" | "ask"): number | null { + if (!levels || !levels.length) return null; + const sorted = levels + .map((level) => { + if (Array.isArray(level)) return Number(level[0]); + return Number((level as LighterOrderBookLevel).price); + }) + .filter((price) => Number.isFinite(price)); + if (!sorted.length) return null; + return side === "bid" ? Math.max(...sorted) : Math.min(...sorted); +} + function mapOrderType(type: OrderType): number { switch (type) { case "MARKET": diff --git a/src/exchanges/lighter/http-client.ts b/src/exchanges/lighter/http-client.ts index b21ea8a..d50c28e 100644 --- a/src/exchanges/lighter/http-client.ts +++ b/src/exchanges/lighter/http-client.ts @@ -72,7 +72,7 @@ export interface LighterHttpClientOptions { export class LighterHttpClient { readonly baseUrl: string; - private readonly priceProtection: boolean; + private readonly priceProtection: boolean | undefined; private readonly fetcher: typeof fetch; constructor(options: LighterHttpClientOptions = {}) { @@ -82,7 +82,7 @@ export class LighterHttpClient { throw new Error(`Unknown Lighter environment: ${env}`); } this.baseUrl = host.replace(/\/$/, ""); - this.priceProtection = options.priceProtection ?? true; + this.priceProtection = options.priceProtection; this.fetcher = options.fetcher ?? globalThis.fetch.bind(globalThis); if (!this.fetcher) { throw new Error("Global fetch is not available; provide a custom fetch implementation"); @@ -195,7 +195,7 @@ export class LighterHttpClient { form.set("tx_type", String(txType)); form.set("tx_info", txInfo); const priceProtection = options.priceProtection ?? this.priceProtection; - if (priceProtection != null) form.set("price_protection", String(priceProtection)); + form.set("price_protection", String(priceProtection ?? false)); return this.postForm("/api/v1/sendTx", form, options.authToken); } diff --git a/src/exchanges/lighter/mappers.ts b/src/exchanges/lighter/mappers.ts index e3c87c7..b01d61e 100644 --- a/src/exchanges/lighter/mappers.ts +++ b/src/exchanges/lighter/mappers.ts @@ -171,9 +171,11 @@ function defaultAsset(details: LighterAccountDetails): AsterAccountAsset[] { function lighterPositionToAster(symbol: string, position: LighterPosition): AsterAccountPosition { const sign = position.sign ?? 0; const positionSide = sign > 0 ? "LONG" : sign < 0 ? "SHORT" : "BOTH"; + const magnitude = Number(position.position ?? 0); + const signed = sign < 0 ? -Math.abs(magnitude) : Math.abs(magnitude); return { - symbol: position.symbol ?? symbol, - positionAmt: position.position ?? "0", + symbol, + positionAmt: Number.isFinite(signed) ? signed.toString() : position.position ?? "0", entryPrice: position.avg_entry_price ?? "0", unrealizedProfit: position.unrealized_pnl ?? "0", positionSide, diff --git a/src/exchanges/lighter/nonce-manager.ts b/src/exchanges/lighter/nonce-manager.ts index a6806bf..a91b3b5 100644 --- a/src/exchanges/lighter/nonce-manager.ts +++ b/src/exchanges/lighter/nonce-manager.ts @@ -60,6 +60,8 @@ export class HttpNonceManager implements LighterNonceManager { async refresh(apiKeyIndex: number): Promise { const nonce = await this.http.getNextNonce(this.accountIndex, apiKeyIndex); + // eslint-disable-next-line no-console + console.debug("[LighterNonceManager] refresh", { apiKeyIndex, nonce: nonce.toString() }); this.slots.set(apiKeyIndex, { apiKeyIndex, next: nonce, lastIssued: null }); } diff --git a/src/exchanges/lighter/signer.ts b/src/exchanges/lighter/signer.ts index ec34ed7..ed91d29 100644 --- a/src/exchanges/lighter/signer.ts +++ b/src/exchanges/lighter/signer.ts @@ -1,34 +1,13 @@ -import { LighterPrivateKey, signatureToBytes } from "./crypto/schnorr"; -import { hashToQuinticExtension } from "./crypto/poseidon2"; -import { Fp } from "./crypto/goldilocks"; -import { Fp5 } from "./crypto/goldilocks-fp5"; -import { arrayFromCanonicalLittleEndianBytes } from "./field-array"; -import { bytesToHex } from "./bytes"; -import { DEFAULT_TRANSACTION_EXPIRY_BUFFER_MS, LIGHTER_TX_TYPE } from "./constants"; -import { safeNumberToUint32, toSafeNumber } from "./decimal"; - -const BASE64_ENCODE = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64"); -const AUTH_MAX_WINDOW_MS = 7 * 60 * 60 * 1000; // 7 hours - -function fpFromUint32(value: number): Fp { - const uint32 = safeNumberToUint32(value); - return Fp.fromUint32(uint32); -} - -function fpFromInt64(value: bigint | number): Fp { - const bigintValue = typeof value === "number" ? BigInt(Math.trunc(value)) : value; - return new Fp(bigintValue); -} - -interface KeySlot { - index: number; - key: LighterPrivateKey; -} +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import path from "node:path"; +import { createInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; export interface LighterSignerConfig { accountIndex: number | bigint; chainId: number; apiKeys: Record; + baseUrl: string; } interface BaseSignOptions { @@ -63,265 +42,238 @@ export interface CancelAllSignParams extends BaseSignOptions { export interface SignedTxPayload { txType: number; txInfo: string; - txHash: string; - signature: string; + txHash?: string; + signature?: string; +} + +type PendingResolver = { + resolve: (value: any) => void; + reject: (error: Error) => void; +}; + +class PythonSignerBridge { + private readonly child: ChildProcessWithoutNullStreams; + private readonly pending = new Map(); + private readonly scriptPath: string; + private seq = 0; + + constructor(scriptPath: string) { + this.scriptPath = scriptPath; + this.child = spawn("python3", [this.scriptPath], { + stdio: ["pipe", "pipe", "pipe"], + }); + + const rl = createInterface({ input: this.child.stdout }); + rl.on("line", (line) => this.onLine(line)); + this.child.on("error", (error) => { + this.rejectAll(new Error(`lighter signer bridge failed to start: ${String(error)}`)); + }); + this.child.on("exit", (code, signal) => { + this.rejectAll(new Error(`lighter signer bridge exited (code=${code}, signal=${signal ?? ""})`)); + }); + this.child.stderr.on("data", (chunk) => { + const message = chunk.toString().trim(); + if (message.length) { + console.error(`[LighterSignerBridge] ${message}`); + } + }); + } + + private onLine(line: string): void { + let payload: any; + try { + payload = JSON.parse(line); + } catch (error) { + console.error(`[LighterSignerBridge] invalid JSON: ${line}`, error); + return; + } + const { id, error } = payload; + const pending = this.pending.get(Number(id)); + if (!pending) { + if (error) { + console.error(`[LighterSignerBridge] error without pending request: ${error}`); + } + return; + } + this.pending.delete(Number(id)); + if (error) { + pending.reject(new Error(String(error))); + return; + } + pending.resolve(payload.result ?? null); + } + + private rejectAll(error: Error): void { + for (const { reject } of this.pending.values()) { + reject(error); + } + this.pending.clear(); + } + + async call(method: string, params: Record): Promise { + const id = ++this.seq; + const payload = JSON.stringify({ id, method, params }, (_key, value) => { + if (typeof value === "bigint") return value.toString(); + return value; + }); + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.child.stdin.write(payload + "\n", (err) => { + if (err) { + this.pending.delete(id); + reject(err); + } + }); + }); + } +} + +function resolveScriptPath(): string { + const current = path.dirname(fileURLToPath(import.meta.url)); + return path.resolve(current, "../../..", "scripts", "lighter_signer_bridge.py"); } export class LighterSigner { readonly accountIndex: bigint; readonly chainId: number; - private readonly keys = new Map(); - private readonly defaultKeyIndex: number; + readonly defaultKeyIndex: number; + private readonly baseUrl: string; + private readonly bridge: PythonSignerBridge; + private readonly ready: Promise; constructor(config: LighterSignerConfig) { - if (!config || typeof config !== "object") { - throw new Error("LighterSigner requires configuration"); + if (!config.baseUrl) { + throw new Error("LighterSigner requires baseUrl for signer bridge"); } - this.chainId = config.chainId >>> 0; this.accountIndex = typeof config.accountIndex === "number" ? BigInt(Math.trunc(config.accountIndex)) : config.accountIndex; - const entries = Object.entries(config.apiKeys ?? {}).map(([idx, hex]) => ({ - index: Number(idx), - key: LighterPrivateKey.fromHex(hex), - })); + this.chainId = config.chainId >>> 0; + this.baseUrl = config.baseUrl; + + const entries = Object.entries(config.apiKeys ?? {}); if (!entries.length) { throw new Error("At least one Lighter API private key must be provided"); } - for (const entry of entries) { - if (!Number.isInteger(entry.index) || entry.index < 0 || entry.index > 255) { - throw new Error(`Invalid API key index: ${entry.index}`); + this.defaultKeyIndex = Number(entries[0]![0]); + + this.bridge = new PythonSignerBridge(resolveScriptPath()); + this.ready = (async () => { + for (const [index, key] of entries) { + await this.bridge.call("create_client", { + apiKeyIndex: Number(index), + privateKey: key, + baseUrl: this.baseUrl, + chainId: this.chainId, + accountIndex: this.accountIndex.toString(), + }); } - this.keys.set(entry.index, entry.key); - } - this.defaultKeyIndex = entries[0]!.index; + })(); } - private resolveKey(apiKeyIndex?: number): KeySlot { - const index = apiKeyIndex ?? this.defaultKeyIndex; - const key = this.keys.get(index); - if (!key) { - throw new Error(`Missing private key for API key index ${index}`); - } - return { index, key }; + private async ensureReady(): Promise { + await this.ready; } - signCreateOrder(params: CreateOrderSignParams): SignedTxPayload { - const slot = this.resolveKey(params.apiKeyIndex); - const expiredAt = params.expiredAt ?? BigInt(Date.now() + DEFAULT_TRANSACTION_EXPIRY_BUFFER_MS); - const hash = hashCreateOrder({ - chainId: this.chainId, - accountIndex: this.accountIndex, - apiKeyIndex: slot.index, - nonce: params.nonce, - expiredAt, + async signCreateOrder(params: CreateOrderSignParams): Promise { + await this.ensureReady(); + const apiKeyIndex = params.apiKeyIndex ?? this.defaultKeyIndex; + + const result = await this.bridge.call("sign_create_order", { + apiKeyIndex, marketIndex: params.marketIndex, - clientOrderIndex: params.clientOrderIndex, - baseAmount: params.baseAmount, + clientOrderIndex: params.clientOrderIndex.toString(), + baseAmount: params.baseAmount.toString(), price: params.price, isAsk: params.isAsk, orderType: params.orderType, timeInForce: params.timeInForce, reduceOnly: params.reduceOnly, triggerPrice: params.triggerPrice, - orderExpiry: params.orderExpiry, + orderExpiry: params.orderExpiry.toString(), + nonce: params.nonce.toString(), }); - const signature = slot.key.signHashedMessage(hash); - const signatureBytes = signatureToBytes(signature); - const txInfo = JSON.stringify({ - AccountIndex: toSafeNumber(this.accountIndex), - ApiKeyIndex: slot.index, - OrderInfo: { - MarketIndex: params.marketIndex, - ClientOrderIndex: toSafeNumber(params.clientOrderIndex), - BaseAmount: toSafeNumber(params.baseAmount), - Price: safeNumberToUint32(params.price), - IsAsk: params.isAsk, - Type: params.orderType, - TimeInForce: params.timeInForce, - ReduceOnly: params.reduceOnly, - TriggerPrice: safeNumberToUint32(params.triggerPrice), - OrderExpiry: toSafeNumber(params.orderExpiry), - }, - ExpiredAt: toSafeNumber(expiredAt), - Nonce: toSafeNumber(params.nonce), - Sig: BASE64_ENCODE(signatureBytes), - }); + const txInfo = String(result); + let signature: string | undefined; + let txHash: string | undefined; + try { + const parsed = JSON.parse(txInfo); + if (typeof parsed?.Sig === "string") signature = parsed.Sig; + if (typeof parsed?.SignedHash === "string") txHash = parsed.SignedHash; + } catch { + // ignore parsing errors – txInfo still valid for sendTx + } return { - txType: LIGHTER_TX_TYPE.CREATE_ORDER, + txType: 14, txInfo, - txHash: bytesToHex(hash.toBytes()), - signature: BASE64_ENCODE(signatureBytes), + txHash, + signature, }; } - signCancelOrder(params: CancelOrderSignParams): SignedTxPayload { - const slot = this.resolveKey(params.apiKeyIndex); - const expiredAt = params.expiredAt ?? BigInt(Date.now() + DEFAULT_TRANSACTION_EXPIRY_BUFFER_MS); - const hash = hashCancelOrder({ - chainId: this.chainId, - accountIndex: this.accountIndex, - apiKeyIndex: slot.index, - nonce: params.nonce, - expiredAt, + async signCancelOrder(params: CancelOrderSignParams): Promise { + await this.ensureReady(); + const apiKeyIndex = params.apiKeyIndex ?? this.defaultKeyIndex; + + const result = await this.bridge.call("sign_cancel_order", { + apiKeyIndex, marketIndex: params.marketIndex, - orderIndex: params.orderIndex, - }); - const signature = slot.key.signHashedMessage(hash); - const signatureBytes = signatureToBytes(signature); - const txInfo = JSON.stringify({ - AccountIndex: toSafeNumber(this.accountIndex), - ApiKeyIndex: slot.index, - MarketIndex: params.marketIndex, - Index: toSafeNumber(params.orderIndex), - ExpiredAt: toSafeNumber(expiredAt), - Nonce: toSafeNumber(params.nonce), - Sig: BASE64_ENCODE(signatureBytes), + orderIndex: params.orderIndex.toString(), + nonce: params.nonce.toString(), }); + + const txInfo = String(result); + let signature: string | undefined; + try { + const parsed = JSON.parse(txInfo); + if (typeof parsed?.Sig === "string") signature = parsed.Sig; + } catch { + // ignore + } + return { - txType: LIGHTER_TX_TYPE.CANCEL_ORDER, + txType: 15, txInfo, - txHash: bytesToHex(hash.toBytes()), - signature: BASE64_ENCODE(signatureBytes), + signature, }; } - signCancelAll(params: CancelAllSignParams): SignedTxPayload { - const slot = this.resolveKey(params.apiKeyIndex); - const expiredAt = params.expiredAt ?? BigInt(Date.now() + DEFAULT_TRANSACTION_EXPIRY_BUFFER_MS); - const hash = hashCancelAll({ - chainId: this.chainId, - accountIndex: this.accountIndex, - apiKeyIndex: slot.index, - nonce: params.nonce, - expiredAt, + async signCancelAll(params: CancelAllSignParams): Promise { + await this.ensureReady(); + const apiKeyIndex = params.apiKeyIndex ?? this.defaultKeyIndex; + + const result = await this.bridge.call("sign_cancel_all", { + apiKeyIndex, timeInForce: params.timeInForce, - time: params.scheduledTime, - }); - const signature = slot.key.signHashedMessage(hash); - const signatureBytes = signatureToBytes(signature); - const txInfo = JSON.stringify({ - AccountIndex: toSafeNumber(this.accountIndex), - ApiKeyIndex: slot.index, - TimeInForce: params.timeInForce, - Time: toSafeNumber(params.scheduledTime), - ExpiredAt: toSafeNumber(expiredAt), - Nonce: toSafeNumber(params.nonce), - Sig: BASE64_ENCODE(signatureBytes), + scheduledTime: params.scheduledTime.toString(), + nonce: params.nonce.toString(), }); + + const txInfo = String(result); + let signature: string | undefined; + try { + const parsed = JSON.parse(txInfo); + if (typeof parsed?.Sig === "string") signature = parsed.Sig; + } catch { + // ignore + } + return { - txType: LIGHTER_TX_TYPE.CANCEL_ALL_ORDERS, + txType: 16, txInfo, - txHash: bytesToHex(hash.toBytes()), - signature: BASE64_ENCODE(signatureBytes), + signature, }; } - createAuthToken(deadlineMs: number, apiKeyIndex?: number): string { - if (!Number.isFinite(deadlineMs) || deadlineMs <= Date.now()) { - throw new Error("Auth token deadline must be in the future"); - } - if (deadlineMs - Date.now() > AUTH_MAX_WINDOW_MS) { - throw new Error("Auth token deadline must be within 7 hours"); - } - const slot = this.resolveKey(apiKeyIndex); - const deadlineSeconds = Math.floor(deadlineMs / 1000); - const message = `${deadlineSeconds}:${toSafeNumber(this.accountIndex)}:${slot.index}`; - const msgBytes = Buffer.from(message, "utf8"); - const preimage = arrayFromCanonicalLittleEndianBytes(msgBytes); - const hashed = hashToQuinticExtension(preimage); - const signature = slot.key.signHashedMessage(hashed); - const signatureHex = bytesToHex(signatureToBytes(signature)); - return `${message}:${signatureHex}`; + async createAuthToken(deadlineMs: number, apiKeyIndex?: number): Promise { + await this.ensureReady(); + const index = apiKeyIndex ?? this.defaultKeyIndex; + const result = await this.bridge.call("create_auth_token", { + apiKeyIndex: index, + deadlineMs: Math.floor(deadlineMs / 1000), + }); + return String(result ?? ""); } } - -interface CreateOrderHashInput { - chainId: number; - accountIndex: bigint; - apiKeyIndex: number; - nonce: bigint; - expiredAt: bigint; - marketIndex: number; - clientOrderIndex: bigint; - baseAmount: bigint; - price: number; - isAsk: number; - orderType: number; - timeInForce: number; - reduceOnly: number; - triggerPrice: number; - orderExpiry: bigint; -} - -function hashCreateOrder(input: CreateOrderHashInput): Fp5 { - const elements = [ - Fp.fromUint32(input.chainId >>> 0), - Fp.fromUint32(LIGHTER_TX_TYPE.CREATE_ORDER), - fpFromInt64(input.nonce), - fpFromInt64(input.expiredAt), - fpFromInt64(input.accountIndex), - fpFromUint32(input.apiKeyIndex), - fpFromUint32(input.marketIndex), - fpFromInt64(input.clientOrderIndex), - fpFromInt64(input.baseAmount), - fpFromUint32(input.price), - fpFromUint32(input.isAsk), - fpFromUint32(input.orderType), - fpFromUint32(input.timeInForce), - fpFromUint32(input.reduceOnly), - fpFromUint32(input.triggerPrice), - fpFromInt64(input.orderExpiry), - ]; - return hashToQuinticExtension(elements); -} - -interface CancelOrderHashInput { - chainId: number; - accountIndex: bigint; - apiKeyIndex: number; - nonce: bigint; - expiredAt: bigint; - marketIndex: number; - orderIndex: bigint; -} - -function hashCancelOrder(input: CancelOrderHashInput): Fp5 { - const elements = [ - Fp.fromUint32(input.chainId >>> 0), - Fp.fromUint32(LIGHTER_TX_TYPE.CANCEL_ORDER), - fpFromInt64(input.nonce), - fpFromInt64(input.expiredAt), - fpFromInt64(input.accountIndex), - fpFromUint32(input.apiKeyIndex), - fpFromUint32(input.marketIndex), - fpFromInt64(input.orderIndex), - ]; - return hashToQuinticExtension(elements); -} - -interface CancelAllHashInput { - chainId: number; - accountIndex: bigint; - apiKeyIndex: number; - nonce: bigint; - expiredAt: bigint; - timeInForce: number; - time: bigint; -} - -function hashCancelAll(input: CancelAllHashInput): Fp5 { - const elements = [ - Fp.fromUint32(input.chainId >>> 0), - Fp.fromUint32(LIGHTER_TX_TYPE.CANCEL_ALL_ORDERS), - fpFromInt64(input.nonce), - fpFromInt64(input.expiredAt), - fpFromInt64(input.accountIndex), - fpFromUint32(input.apiKeyIndex), - fpFromUint32(input.timeInForce), - fpFromInt64(input.time), - ]; - return hashToQuinticExtension(elements); -} diff --git a/src/strategy/trend-engine.ts b/src/strategy/trend-engine.ts index ba52d21..33a8b5a 100644 --- a/src/strategy/trend-engine.ts +++ b/src/strategy/trend-engine.ts @@ -99,6 +99,8 @@ export class TrendEngine { markPrice: null, }; private pendingRealized: { pnl: number; timestamp: number } | null = null; + private klineInsufficientLogged = false; + private klineReadyLogged = false; // 控制入场频率:同一分钟内最多入场一次 private lastEntryMinute: number | null = null; @@ -174,8 +176,16 @@ export class TrendEngine { this.exchange.watchOrders.bind(this.exchange), (orders) => { this.synchronizeLocks(orders); + const isActive = (status: string | undefined) => { + if (!status) return true; + const normalized = status.toLowerCase(); + return normalized !== "filled" && normalized !== "canceled" && normalized !== "cancelled"; + }; this.openOrders = Array.isArray(orders) - ? orders.filter((order) => order.type !== "MARKET" && order.symbol === this.config.symbol) + ? orders.filter( + (order) => + order.type !== "MARKET" && order.symbol === this.config.symbol && isActive(order.status) + ) : []; const currentIds = new Set(this.openOrders.map((order) => String(order.orderId))); for (const id of Array.from(this.pendingCancelOrders)) { @@ -226,6 +236,7 @@ export class TrendEngine { this.exchange.watchKlines.bind(this.exchange, this.config.symbol, this.config.klineInterval), (klines) => { this.klineSnapshot = Array.isArray(klines) ? klines : []; + this.logKlineSnapshot(); this.emitUpdate(); }, log, @@ -258,6 +269,31 @@ export class TrendEngine { ); } + private logKlineSnapshot(): void { + const minKlines = Math.max(30, this.config.bollingerLength); + const count = this.klineSnapshot.length; + if (count < minKlines) { + if (!this.klineInsufficientLogged) { + const closes = this.klineSnapshot.slice(-5).map((k) => Number(k.close).toFixed(2)); + this.tradeLog.push( + "info", + `K线不足 ${count}/${minKlines},最近收盘(${closes.length}): ${closes.join(", ")}` + ); + this.klineInsufficientLogged = true; + } + return; + } + if (!this.klineReadyLogged) { + const closes = this.klineSnapshot.slice(-5).map((k) => Number(k.close).toFixed(2)); + this.tradeLog.push( + "info", + `K线就绪 ${count} 根,可计算 SMA30。最近收盘: ${closes.join(", ")}` + ); + this.klineReadyLogged = true; + } + this.klineInsufficientLogged = false; + } + private async tick(): Promise { if (this.processing) return; this.processing = true;