mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-11 09:18:08 +00:00
feat: 添加 Lighter 签名桥接和公钥派生脚本,支持新的签名库和环境检测功能
This commit is contained in:
Binary file not shown.
@@ -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 <hex>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(derive(input));
|
||||||
@@ -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();
|
||||||
Executable
+265
@@ -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()
|
||||||
@@ -23,7 +23,16 @@ import type {
|
|||||||
LighterOrderBookSnapshot,
|
LighterOrderBookSnapshot,
|
||||||
LighterPosition,
|
LighterPosition,
|
||||||
} from "./types";
|
} 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 { decimalToScaled, scaledToDecimalString } from "./decimal";
|
||||||
import { lighterOrderToAster, toAccountSnapshot, toDepth, toKlines, toOrders, toTicker } from "./mappers";
|
import { lighterOrderToAster, toAccountSnapshot, toDepth, toKlines, toOrders, toTicker } from "./mappers";
|
||||||
|
|
||||||
@@ -58,6 +67,47 @@ function createEvent<T>(): SimpleEvent<T> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
interface Pollers {
|
||||||
ticker?: ReturnType<typeof setInterval>;
|
ticker?: ReturnType<typeof setInterval>;
|
||||||
klines: Map<string, ReturnType<typeof setInterval>>;
|
klines: Map<string, ReturnType<typeof setInterval>>;
|
||||||
@@ -112,6 +162,7 @@ export class LighterGateway {
|
|||||||
private readonly klinesEvent = createEvent<AsterKline[]>();
|
private readonly klinesEvent = createEvent<AsterKline[]>();
|
||||||
private readonly auth = { token: null as string | null, expiresAt: 0 };
|
private readonly auth = { token: null as string | null, expiresAt: 0 };
|
||||||
private readonly l1Address: string | null;
|
private readonly l1Address: string | null;
|
||||||
|
private loggedCreateOrderPayload = false;
|
||||||
|
|
||||||
private marketId: number | null = null;
|
private marketId: number | null = null;
|
||||||
private priceDecimals: number | null = null;
|
private priceDecimals: number | null = null;
|
||||||
@@ -125,6 +176,7 @@ export class LighterGateway {
|
|||||||
private accountDetails: LighterAccountDetails | null = null;
|
private accountDetails: LighterAccountDetails | null = null;
|
||||||
private positions: LighterPosition[] = [];
|
private positions: LighterPosition[] = [];
|
||||||
private orders: LighterOrder[] = [];
|
private orders: LighterOrder[] = [];
|
||||||
|
private readonly orderMap = new Map<string, LighterOrder>();
|
||||||
private orderBook: LighterOrderBookSnapshot | null = null;
|
private orderBook: LighterOrderBookSnapshot | null = null;
|
||||||
private ticker: LighterMarketStats | null = null;
|
private ticker: LighterMarketStats | null = null;
|
||||||
private initialized = false;
|
private initialized = false;
|
||||||
@@ -135,7 +187,7 @@ export class LighterGateway {
|
|||||||
constructor(options: LighterGatewayOptions) {
|
constructor(options: LighterGatewayOptions) {
|
||||||
this.displaySymbol = options.symbol;
|
this.displaySymbol = options.symbol;
|
||||||
this.marketSymbol = (options.marketSymbol ?? options.symbol).toUpperCase();
|
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;
|
const host = options.baseUrl ?? LIGHTER_HOSTS[this.environment]?.rest;
|
||||||
if (!host) {
|
if (!host) {
|
||||||
throw new Error(`Unknown Lighter environment ${this.environment}`);
|
throw new Error(`Unknown Lighter environment ${this.environment}`);
|
||||||
@@ -150,6 +202,7 @@ export class LighterGateway {
|
|||||||
accountIndex: options.accountIndex,
|
accountIndex: options.accountIndex,
|
||||||
chainId: options.chainId ?? (this.environment === "mainnet" ? 304 : 300),
|
chainId: options.chainId ?? (this.environment === "mainnet" ? 304 : 300),
|
||||||
apiKeys: options.apiKeys,
|
apiKeys: options.apiKeys,
|
||||||
|
baseUrl: host,
|
||||||
});
|
});
|
||||||
this.apiKeyIndices = options.apiKeyIndices ?? Object.keys(options.apiKeys).map(Number);
|
this.apiKeyIndices = options.apiKeyIndices ?? Object.keys(options.apiKeys).map(Number);
|
||||||
this.nonceManager = new HttpNonceManager({
|
this.nonceManager = new HttpNonceManager({
|
||||||
@@ -204,13 +257,21 @@ export class LighterGateway {
|
|||||||
const { baseAmountScaledString, priceScaledString, triggerPriceScaledString, ...signParams } = conversion;
|
const { baseAmountScaledString, priceScaledString, triggerPriceScaledString, ...signParams } = conversion;
|
||||||
const { apiKeyIndex, nonce } = this.nonceManager.next();
|
const { apiKeyIndex, nonce } = this.nonceManager.next();
|
||||||
try {
|
try {
|
||||||
const signed = this.signer.signCreateOrder({
|
const signed = await this.signer.signCreateOrder({
|
||||||
...signParams,
|
...signParams,
|
||||||
apiKeyIndex,
|
apiKeyIndex,
|
||||||
nonce,
|
nonce,
|
||||||
});
|
});
|
||||||
|
if (!this.loggedCreateOrderPayload) {
|
||||||
|
this.logger("createOrder.txInfo", signed.txInfo);
|
||||||
|
this.loggedCreateOrderPayload = true;
|
||||||
|
}
|
||||||
const auth = await this.ensureAuthToken();
|
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, {
|
return lighterOrderToAster(this.displaySymbol, {
|
||||||
order_index: Number(signParams.clientOrderIndex % 1_000_000_000n),
|
order_index: Number(signParams.clientOrderIndex % 1_000_000_000n),
|
||||||
client_order_index: Number(signParams.clientOrderIndex),
|
client_order_index: Number(signParams.clientOrderIndex),
|
||||||
@@ -228,6 +289,7 @@ export class LighterGateway {
|
|||||||
} as LighterOrder);
|
} as LighterOrder);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.nonceManager.acknowledgeFailure(apiKeyIndex);
|
this.nonceManager.acknowledgeFailure(apiKeyIndex);
|
||||||
|
this.logger("createOrder", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,7 +301,7 @@ export class LighterGateway {
|
|||||||
const indexValue = BigInt(typeof params.orderId === "string" ? Number(params.orderId) : params.orderId);
|
const indexValue = BigInt(typeof params.orderId === "string" ? Number(params.orderId) : params.orderId);
|
||||||
const { apiKeyIndex, nonce } = this.nonceManager.next();
|
const { apiKeyIndex, nonce } = this.nonceManager.next();
|
||||||
try {
|
try {
|
||||||
const signed = this.signer.signCancelOrder({
|
const signed = await this.signer.signCancelOrder({
|
||||||
marketIndex,
|
marketIndex,
|
||||||
orderIndex: indexValue,
|
orderIndex: indexValue,
|
||||||
nonce,
|
nonce,
|
||||||
@@ -259,7 +321,7 @@ export class LighterGateway {
|
|||||||
const time = params?.scheduleMs != null ? BigInt(params.scheduleMs) : 0n;
|
const time = params?.scheduleMs != null ? BigInt(params.scheduleMs) : 0n;
|
||||||
const { apiKeyIndex, nonce } = this.nonceManager.next();
|
const { apiKeyIndex, nonce } = this.nonceManager.next();
|
||||||
try {
|
try {
|
||||||
const signed = this.signer.signCancelAll({
|
const signed = await this.signer.signCancelAll({
|
||||||
timeInForce,
|
timeInForce,
|
||||||
scheduledTime: time,
|
scheduledTime: time,
|
||||||
nonce,
|
nonce,
|
||||||
@@ -479,8 +541,19 @@ export class LighterGateway {
|
|||||||
const ordersObject = message.orders ?? {};
|
const ordersObject = message.orders ?? {};
|
||||||
const buckets = Object.values(ordersObject) as unknown[];
|
const buckets = Object.values(ordersObject) as unknown[];
|
||||||
const allOrders: LighterOrder[] = buckets.flatMap((entry) => Array.isArray(entry) ? (entry as LighterOrder[]) : []);
|
const allOrders: LighterOrder[] = buckets.flatMap((entry) => Array.isArray(entry) ? (entry as LighterOrder[]) : []);
|
||||||
this.orders = allOrders;
|
const terminalStatuses = new Set(["filled", "canceled", "cancelled", "expired"]);
|
||||||
const mapped = toOrders(this.displaySymbol, allOrders);
|
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);
|
this.ordersEvent.emit(mapped);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,6 +561,7 @@ export class LighterGateway {
|
|||||||
if (!this.orderBook || this.marketId == null) return;
|
if (!this.orderBook || this.marketId == null) return;
|
||||||
const depth = toDepth(this.displaySymbol, this.orderBook);
|
const depth = toDepth(this.displaySymbol, this.orderBook);
|
||||||
this.depthEvent.emit(depth);
|
this.depthEvent.emit(depth);
|
||||||
|
this.emitSyntheticTicker();
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitAccount(): void {
|
private emitAccount(): void {
|
||||||
@@ -521,6 +595,7 @@ export class LighterGateway {
|
|||||||
if (!match) return;
|
if (!match) return;
|
||||||
const ticker = toTicker(this.displaySymbol, match);
|
const ticker = toTicker(this.displaySymbol, match);
|
||||||
this.tickerEvent.emit(ticker);
|
this.tickerEvent.emit(ticker);
|
||||||
|
this.loggedCreateOrderPayload = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger("refreshTicker", error);
|
this.logger("refreshTicker", error);
|
||||||
}
|
}
|
||||||
@@ -562,9 +637,40 @@ export class LighterGateway {
|
|||||||
startTimestamp: startTs,
|
startTimestamp: startTs,
|
||||||
setTimestampToEnd: true,
|
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.klineCache.set(interval, mapped);
|
||||||
this.klinesEvent.emit(cloneKlines(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<CreateOrderSignParams, "nonce"> & {
|
private mapCreateOrderParams(params: CreateOrderParams): Omit<CreateOrderSignParams, "nonce"> & {
|
||||||
@@ -659,6 +765,18 @@ function cloneKlines(klines: AsterKline[]): AsterKline[] {
|
|||||||
return klines.map((kline) => ({ ...kline }));
|
return klines.map((kline) => ({ ...kline }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getBestPrice(levels: LighterOrderBookLevel[] | Array<any> | undefined, side: "bid" | "ask"): number | null {
|
||||||
|
if (!levels || !levels.length) return null;
|
||||||
|
const sorted = levels
|
||||||
|
.map((level) => {
|
||||||
|
if (Array.isArray(level)) return Number(level[0]);
|
||||||
|
return Number((level as LighterOrderBookLevel).price);
|
||||||
|
})
|
||||||
|
.filter((price) => Number.isFinite(price));
|
||||||
|
if (!sorted.length) return null;
|
||||||
|
return side === "bid" ? Math.max(...sorted) : Math.min(...sorted);
|
||||||
|
}
|
||||||
|
|
||||||
function mapOrderType(type: OrderType): number {
|
function mapOrderType(type: OrderType): number {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "MARKET":
|
case "MARKET":
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export interface LighterHttpClientOptions {
|
|||||||
|
|
||||||
export class LighterHttpClient {
|
export class LighterHttpClient {
|
||||||
readonly baseUrl: string;
|
readonly baseUrl: string;
|
||||||
private readonly priceProtection: boolean;
|
private readonly priceProtection: boolean | undefined;
|
||||||
private readonly fetcher: typeof fetch;
|
private readonly fetcher: typeof fetch;
|
||||||
|
|
||||||
constructor(options: LighterHttpClientOptions = {}) {
|
constructor(options: LighterHttpClientOptions = {}) {
|
||||||
@@ -82,7 +82,7 @@ export class LighterHttpClient {
|
|||||||
throw new Error(`Unknown Lighter environment: ${env}`);
|
throw new Error(`Unknown Lighter environment: ${env}`);
|
||||||
}
|
}
|
||||||
this.baseUrl = host.replace(/\/$/, "");
|
this.baseUrl = host.replace(/\/$/, "");
|
||||||
this.priceProtection = options.priceProtection ?? true;
|
this.priceProtection = options.priceProtection;
|
||||||
this.fetcher = options.fetcher ?? globalThis.fetch.bind(globalThis);
|
this.fetcher = options.fetcher ?? globalThis.fetch.bind(globalThis);
|
||||||
if (!this.fetcher) {
|
if (!this.fetcher) {
|
||||||
throw new Error("Global fetch is not available; provide a custom fetch implementation");
|
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_type", String(txType));
|
||||||
form.set("tx_info", txInfo);
|
form.set("tx_info", txInfo);
|
||||||
const priceProtection = options.priceProtection ?? this.priceProtection;
|
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<SendTxResponse>("/api/v1/sendTx", form, options.authToken);
|
return this.postForm<SendTxResponse>("/api/v1/sendTx", form, options.authToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -171,9 +171,11 @@ function defaultAsset(details: LighterAccountDetails): AsterAccountAsset[] {
|
|||||||
function lighterPositionToAster(symbol: string, position: LighterPosition): AsterAccountPosition {
|
function lighterPositionToAster(symbol: string, position: LighterPosition): AsterAccountPosition {
|
||||||
const sign = position.sign ?? 0;
|
const sign = position.sign ?? 0;
|
||||||
const positionSide = sign > 0 ? "LONG" : sign < 0 ? "SHORT" : "BOTH";
|
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 {
|
return {
|
||||||
symbol: position.symbol ?? symbol,
|
symbol,
|
||||||
positionAmt: position.position ?? "0",
|
positionAmt: Number.isFinite(signed) ? signed.toString() : position.position ?? "0",
|
||||||
entryPrice: position.avg_entry_price ?? "0",
|
entryPrice: position.avg_entry_price ?? "0",
|
||||||
unrealizedProfit: position.unrealized_pnl ?? "0",
|
unrealizedProfit: position.unrealized_pnl ?? "0",
|
||||||
positionSide,
|
positionSide,
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export class HttpNonceManager implements LighterNonceManager {
|
|||||||
|
|
||||||
async refresh(apiKeyIndex: number): Promise<void> {
|
async refresh(apiKeyIndex: number): Promise<void> {
|
||||||
const nonce = await this.http.getNextNonce(this.accountIndex, apiKeyIndex);
|
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 });
|
this.slots.set(apiKeyIndex, { apiKeyIndex, next: nonce, lastIssued: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+190
-238
@@ -1,34 +1,13 @@
|
|||||||
import { LighterPrivateKey, signatureToBytes } from "./crypto/schnorr";
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
import { hashToQuinticExtension } from "./crypto/poseidon2";
|
import path from "node:path";
|
||||||
import { Fp } from "./crypto/goldilocks";
|
import { createInterface } from "node:readline";
|
||||||
import { Fp5 } from "./crypto/goldilocks-fp5";
|
import { fileURLToPath } from "node:url";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LighterSignerConfig {
|
export interface LighterSignerConfig {
|
||||||
accountIndex: number | bigint;
|
accountIndex: number | bigint;
|
||||||
chainId: number;
|
chainId: number;
|
||||||
apiKeys: Record<number, string>;
|
apiKeys: Record<number, string>;
|
||||||
|
baseUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BaseSignOptions {
|
interface BaseSignOptions {
|
||||||
@@ -63,265 +42,238 @@ export interface CancelAllSignParams extends BaseSignOptions {
|
|||||||
export interface SignedTxPayload {
|
export interface SignedTxPayload {
|
||||||
txType: number;
|
txType: number;
|
||||||
txInfo: string;
|
txInfo: string;
|
||||||
txHash: string;
|
txHash?: string;
|
||||||
signature: string;
|
signature?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PendingResolver = {
|
||||||
|
resolve: (value: any) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
class PythonSignerBridge {
|
||||||
|
private readonly child: ChildProcessWithoutNullStreams;
|
||||||
|
private readonly pending = new Map<number, PendingResolver>();
|
||||||
|
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<string, unknown>): Promise<any> {
|
||||||
|
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 {
|
export class LighterSigner {
|
||||||
readonly accountIndex: bigint;
|
readonly accountIndex: bigint;
|
||||||
readonly chainId: number;
|
readonly chainId: number;
|
||||||
private readonly keys = new Map<number, LighterPrivateKey>();
|
readonly defaultKeyIndex: number;
|
||||||
private readonly defaultKeyIndex: number;
|
private readonly baseUrl: string;
|
||||||
|
private readonly bridge: PythonSignerBridge;
|
||||||
|
private readonly ready: Promise<void>;
|
||||||
|
|
||||||
constructor(config: LighterSignerConfig) {
|
constructor(config: LighterSignerConfig) {
|
||||||
if (!config || typeof config !== "object") {
|
if (!config.baseUrl) {
|
||||||
throw new Error("LighterSigner requires configuration");
|
throw new Error("LighterSigner requires baseUrl for signer bridge");
|
||||||
}
|
}
|
||||||
this.chainId = config.chainId >>> 0;
|
|
||||||
this.accountIndex = typeof config.accountIndex === "number"
|
this.accountIndex = typeof config.accountIndex === "number"
|
||||||
? BigInt(Math.trunc(config.accountIndex))
|
? BigInt(Math.trunc(config.accountIndex))
|
||||||
: config.accountIndex;
|
: config.accountIndex;
|
||||||
const entries = Object.entries(config.apiKeys ?? {}).map(([idx, hex]) => ({
|
this.chainId = config.chainId >>> 0;
|
||||||
index: Number(idx),
|
this.baseUrl = config.baseUrl;
|
||||||
key: LighterPrivateKey.fromHex(hex),
|
|
||||||
}));
|
const entries = Object.entries(config.apiKeys ?? {});
|
||||||
if (!entries.length) {
|
if (!entries.length) {
|
||||||
throw new Error("At least one Lighter API private key must be provided");
|
throw new Error("At least one Lighter API private key must be provided");
|
||||||
}
|
}
|
||||||
for (const entry of entries) {
|
this.defaultKeyIndex = Number(entries[0]![0]);
|
||||||
if (!Number.isInteger(entry.index) || entry.index < 0 || entry.index > 255) {
|
|
||||||
throw new Error(`Invalid API key index: ${entry.index}`);
|
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 {
|
private async ensureReady(): Promise<void> {
|
||||||
const index = apiKeyIndex ?? this.defaultKeyIndex;
|
await this.ready;
|
||||||
const key = this.keys.get(index);
|
|
||||||
if (!key) {
|
|
||||||
throw new Error(`Missing private key for API key index ${index}`);
|
|
||||||
}
|
|
||||||
return { index, key };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
signCreateOrder(params: CreateOrderSignParams): SignedTxPayload {
|
async signCreateOrder(params: CreateOrderSignParams): Promise<SignedTxPayload> {
|
||||||
const slot = this.resolveKey(params.apiKeyIndex);
|
await this.ensureReady();
|
||||||
const expiredAt = params.expiredAt ?? BigInt(Date.now() + DEFAULT_TRANSACTION_EXPIRY_BUFFER_MS);
|
const apiKeyIndex = params.apiKeyIndex ?? this.defaultKeyIndex;
|
||||||
const hash = hashCreateOrder({
|
|
||||||
chainId: this.chainId,
|
const result = await this.bridge.call("sign_create_order", {
|
||||||
accountIndex: this.accountIndex,
|
apiKeyIndex,
|
||||||
apiKeyIndex: slot.index,
|
|
||||||
nonce: params.nonce,
|
|
||||||
expiredAt,
|
|
||||||
marketIndex: params.marketIndex,
|
marketIndex: params.marketIndex,
|
||||||
clientOrderIndex: params.clientOrderIndex,
|
clientOrderIndex: params.clientOrderIndex.toString(),
|
||||||
baseAmount: params.baseAmount,
|
baseAmount: params.baseAmount.toString(),
|
||||||
price: params.price,
|
price: params.price,
|
||||||
isAsk: params.isAsk,
|
isAsk: params.isAsk,
|
||||||
orderType: params.orderType,
|
orderType: params.orderType,
|
||||||
timeInForce: params.timeInForce,
|
timeInForce: params.timeInForce,
|
||||||
reduceOnly: params.reduceOnly,
|
reduceOnly: params.reduceOnly,
|
||||||
triggerPrice: params.triggerPrice,
|
triggerPrice: params.triggerPrice,
|
||||||
orderExpiry: params.orderExpiry,
|
orderExpiry: params.orderExpiry.toString(),
|
||||||
|
nonce: params.nonce.toString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const signature = slot.key.signHashedMessage(hash);
|
const txInfo = String(result);
|
||||||
const signatureBytes = signatureToBytes(signature);
|
let signature: string | undefined;
|
||||||
const txInfo = JSON.stringify({
|
let txHash: string | undefined;
|
||||||
AccountIndex: toSafeNumber(this.accountIndex),
|
try {
|
||||||
ApiKeyIndex: slot.index,
|
const parsed = JSON.parse(txInfo);
|
||||||
OrderInfo: {
|
if (typeof parsed?.Sig === "string") signature = parsed.Sig;
|
||||||
MarketIndex: params.marketIndex,
|
if (typeof parsed?.SignedHash === "string") txHash = parsed.SignedHash;
|
||||||
ClientOrderIndex: toSafeNumber(params.clientOrderIndex),
|
} catch {
|
||||||
BaseAmount: toSafeNumber(params.baseAmount),
|
// ignore parsing errors – txInfo still valid for sendTx
|
||||||
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),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
txType: LIGHTER_TX_TYPE.CREATE_ORDER,
|
txType: 14,
|
||||||
txInfo,
|
txInfo,
|
||||||
txHash: bytesToHex(hash.toBytes()),
|
txHash,
|
||||||
signature: BASE64_ENCODE(signatureBytes),
|
signature,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
signCancelOrder(params: CancelOrderSignParams): SignedTxPayload {
|
async signCancelOrder(params: CancelOrderSignParams): Promise<SignedTxPayload> {
|
||||||
const slot = this.resolveKey(params.apiKeyIndex);
|
await this.ensureReady();
|
||||||
const expiredAt = params.expiredAt ?? BigInt(Date.now() + DEFAULT_TRANSACTION_EXPIRY_BUFFER_MS);
|
const apiKeyIndex = params.apiKeyIndex ?? this.defaultKeyIndex;
|
||||||
const hash = hashCancelOrder({
|
|
||||||
chainId: this.chainId,
|
const result = await this.bridge.call("sign_cancel_order", {
|
||||||
accountIndex: this.accountIndex,
|
apiKeyIndex,
|
||||||
apiKeyIndex: slot.index,
|
|
||||||
nonce: params.nonce,
|
|
||||||
expiredAt,
|
|
||||||
marketIndex: params.marketIndex,
|
marketIndex: params.marketIndex,
|
||||||
orderIndex: params.orderIndex,
|
orderIndex: params.orderIndex.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,
|
|
||||||
MarketIndex: params.marketIndex,
|
|
||||||
Index: toSafeNumber(params.orderIndex),
|
|
||||||
ExpiredAt: toSafeNumber(expiredAt),
|
|
||||||
Nonce: toSafeNumber(params.nonce),
|
|
||||||
Sig: BASE64_ENCODE(signatureBytes),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
txType: LIGHTER_TX_TYPE.CANCEL_ORDER,
|
txType: 15,
|
||||||
txInfo,
|
txInfo,
|
||||||
txHash: bytesToHex(hash.toBytes()),
|
signature,
|
||||||
signature: BASE64_ENCODE(signatureBytes),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
signCancelAll(params: CancelAllSignParams): SignedTxPayload {
|
async signCancelAll(params: CancelAllSignParams): Promise<SignedTxPayload> {
|
||||||
const slot = this.resolveKey(params.apiKeyIndex);
|
await this.ensureReady();
|
||||||
const expiredAt = params.expiredAt ?? BigInt(Date.now() + DEFAULT_TRANSACTION_EXPIRY_BUFFER_MS);
|
const apiKeyIndex = params.apiKeyIndex ?? this.defaultKeyIndex;
|
||||||
const hash = hashCancelAll({
|
|
||||||
chainId: this.chainId,
|
const result = await this.bridge.call("sign_cancel_all", {
|
||||||
accountIndex: this.accountIndex,
|
apiKeyIndex,
|
||||||
apiKeyIndex: slot.index,
|
|
||||||
nonce: params.nonce,
|
|
||||||
expiredAt,
|
|
||||||
timeInForce: params.timeInForce,
|
timeInForce: params.timeInForce,
|
||||||
time: params.scheduledTime,
|
scheduledTime: params.scheduledTime.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,
|
|
||||||
TimeInForce: params.timeInForce,
|
|
||||||
Time: toSafeNumber(params.scheduledTime),
|
|
||||||
ExpiredAt: toSafeNumber(expiredAt),
|
|
||||||
Nonce: toSafeNumber(params.nonce),
|
|
||||||
Sig: BASE64_ENCODE(signatureBytes),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
txType: LIGHTER_TX_TYPE.CANCEL_ALL_ORDERS,
|
txType: 16,
|
||||||
txInfo,
|
txInfo,
|
||||||
txHash: bytesToHex(hash.toBytes()),
|
signature,
|
||||||
signature: BASE64_ENCODE(signatureBytes),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
createAuthToken(deadlineMs: number, apiKeyIndex?: number): string {
|
async createAuthToken(deadlineMs: number, apiKeyIndex?: number): Promise<string> {
|
||||||
if (!Number.isFinite(deadlineMs) || deadlineMs <= Date.now()) {
|
await this.ensureReady();
|
||||||
throw new Error("Auth token deadline must be in the future");
|
const index = apiKeyIndex ?? this.defaultKeyIndex;
|
||||||
}
|
const result = await this.bridge.call("create_auth_token", {
|
||||||
if (deadlineMs - Date.now() > AUTH_MAX_WINDOW_MS) {
|
apiKeyIndex: index,
|
||||||
throw new Error("Auth token deadline must be within 7 hours");
|
deadlineMs: Math.floor(deadlineMs / 1000),
|
||||||
}
|
});
|
||||||
const slot = this.resolveKey(apiKeyIndex);
|
return String(result ?? "");
|
||||||
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}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ export class TrendEngine {
|
|||||||
markPrice: null,
|
markPrice: null,
|
||||||
};
|
};
|
||||||
private pendingRealized: { pnl: number; timestamp: number } | null = null;
|
private pendingRealized: { pnl: number; timestamp: number } | null = null;
|
||||||
|
private klineInsufficientLogged = false;
|
||||||
|
private klineReadyLogged = false;
|
||||||
|
|
||||||
// 控制入场频率:同一分钟内最多入场一次
|
// 控制入场频率:同一分钟内最多入场一次
|
||||||
private lastEntryMinute: number | null = null;
|
private lastEntryMinute: number | null = null;
|
||||||
@@ -174,8 +176,16 @@ export class TrendEngine {
|
|||||||
this.exchange.watchOrders.bind(this.exchange),
|
this.exchange.watchOrders.bind(this.exchange),
|
||||||
(orders) => {
|
(orders) => {
|
||||||
this.synchronizeLocks(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)
|
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)));
|
const currentIds = new Set(this.openOrders.map((order) => String(order.orderId)));
|
||||||
for (const id of Array.from(this.pendingCancelOrders)) {
|
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),
|
this.exchange.watchKlines.bind(this.exchange, this.config.symbol, this.config.klineInterval),
|
||||||
(klines) => {
|
(klines) => {
|
||||||
this.klineSnapshot = Array.isArray(klines) ? klines : [];
|
this.klineSnapshot = Array.isArray(klines) ? klines : [];
|
||||||
|
this.logKlineSnapshot();
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
},
|
},
|
||||||
log,
|
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<void> {
|
private async tick(): Promise<void> {
|
||||||
if (this.processing) return;
|
if (this.processing) return;
|
||||||
this.processing = true;
|
this.processing = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user