Files
ritmex-bot/src/exchanges/lighter/signer.ts
T
DisneyandGitHub 4014a86223 Enhance CLI command mode for ritmex-bot (#23)
- Introduced a new command mode for `ritmex-bot`, allowing agent-friendly structured trading operations without entering the Ink interactive menu.
- Updated `package.json` to include versioning and set the project as public with a new CLI entry point.
- Enhanced documentation in both English and Chinese to provide comprehensive usage instructions for the new command mode.
- Added a new executable script for `ritmex-bot` to facilitate command execution.
- Improved error handling and command parsing for better user experience and clarity in command execution.
2026-02-27 12:42:20 +08:00

307 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<number, string>;
baseUrl?: string; // optional for offline signing/tests
}
interface BaseSignOptions {
apiKeyIndex?: number;
nonce: bigint;
expiredAt?: bigint;
}
export interface CreateOrderSignParams extends BaseSignOptions {
marketIndex: number;
clientOrderIndex: bigint;
baseAmount: bigint;
price: number;
isAsk: number;
orderType: number;
timeInForce: number;
reduceOnly: number;
triggerPrice: number;
orderExpiry: bigint;
}
export interface CancelOrderSignParams extends BaseSignOptions {
marketIndex: number;
orderIndex: bigint;
}
export interface CancelAllSignParams extends BaseSignOptions {
timeInForce: number;
scheduledTime: bigint;
}
export interface SignedTxPayload {
txType: number;
txInfo: 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<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;
}
if (
Object.prototype.hasOwnProperty.call(payload, "txHash") ||
Object.prototype.hasOwnProperty.call(payload, "messageToSign")
) {
pending.resolve(payload);
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, "./lighter_signer_bridge.py");
}
export class LighterSigner {
readonly accountIndex: bigint;
readonly chainId: number;
readonly defaultKeyIndex: number;
private readonly baseUrl: string;
private readonly bridge: PythonSignerBridge;
private readonly ready: Promise<void>;
constructor(config: LighterSignerConfig) {
this.accountIndex = typeof config.accountIndex === "number"
? BigInt(Math.trunc(config.accountIndex))
: config.accountIndex;
this.chainId = config.chainId >>> 0;
// Default to localhost to allow offline signing in tests
this.baseUrl = config.baseUrl ?? "http://localhost";
const entries = Object.entries(config.apiKeys ?? {});
if (!entries.length) {
throw new Error("At least one Lighter API private key must be provided");
}
this.defaultKeyIndex = Number(entries[0]![0]);
const bridge = new PythonSignerBridge(resolveScriptPath());
this.bridge = bridge;
const baseUrl = this.baseUrl;
const chainId = this.chainId;
const accountIndexStr = this.accountIndex.toString();
this.ready = (async () => {
for (const [index, key] of entries) {
await bridge.call("create_client", {
apiKeyIndex: Number(index),
privateKey: key,
baseUrl,
chainId,
accountIndex: accountIndexStr,
});
}
})();
}
private async ensureReady(): Promise<void> {
await this.ready;
}
async signCreateOrder(params: CreateOrderSignParams): Promise<SignedTxPayload> {
await this.ensureReady();
const apiKeyIndex = params.apiKeyIndex ?? this.defaultKeyIndex;
const bridgePayload = await this.bridge.call("sign_create_order", {
apiKeyIndex,
marketIndex: params.marketIndex,
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.toString(),
expiredAt: params.expiredAt?.toString(),
nonce: params.nonce.toString(),
accountIndex: this.accountIndex.toString(),
});
const txInfo = this.resolveTxInfo(bridgePayload);
let signature: string | undefined;
let txHash: string | undefined = this.resolveTxHash(bridgePayload);
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: 14,
txInfo,
txHash,
signature,
};
}
async signCancelOrder(params: CancelOrderSignParams): Promise<SignedTxPayload> {
await this.ensureReady();
const apiKeyIndex = params.apiKeyIndex ?? this.defaultKeyIndex;
const bridgePayload = await this.bridge.call("sign_cancel_order", {
apiKeyIndex,
marketIndex: params.marketIndex,
orderIndex: params.orderIndex.toString(),
nonce: params.nonce.toString(),
accountIndex: this.accountIndex.toString(),
});
const txInfo = this.resolveTxInfo(bridgePayload);
let signature: string | undefined;
try {
const parsed = JSON.parse(txInfo);
if (typeof parsed?.Sig === "string") signature = parsed.Sig;
} catch {
// ignore
}
return {
txType: 15,
txInfo,
signature,
};
}
async signCancelAll(params: CancelAllSignParams): Promise<SignedTxPayload> {
await this.ensureReady();
const apiKeyIndex = params.apiKeyIndex ?? this.defaultKeyIndex;
const bridgePayload = await this.bridge.call("sign_cancel_all", {
apiKeyIndex,
timeInForce: params.timeInForce,
scheduledTime: params.scheduledTime.toString(),
nonce: params.nonce.toString(),
accountIndex: this.accountIndex.toString(),
});
const txInfo = this.resolveTxInfo(bridgePayload);
let signature: string | undefined;
try {
const parsed = JSON.parse(txInfo);
if (typeof parsed?.Sig === "string") signature = parsed.Sig;
} catch {
// ignore
}
return {
txType: 16,
txInfo,
signature,
};
}
async createAuthToken(deadlineMs: number, apiKeyIndex?: number): Promise<string> {
await this.ensureReady();
const index = apiKeyIndex ?? this.defaultKeyIndex;
const result = await this.bridge.call("create_auth_token", {
apiKeyIndex: index,
deadlineMs: Math.floor(deadlineMs / 1000),
accountIndex: this.accountIndex.toString(),
});
return String(result ?? "");
}
private resolveTxInfo(payload: unknown): string {
if (payload && typeof payload === "object" && "result" in payload) {
return String((payload as { result?: unknown }).result ?? "");
}
return String(payload ?? "");
}
private resolveTxHash(payload: unknown): string | undefined {
if (!payload || typeof payload !== "object") return undefined;
const hash = (payload as { txHash?: unknown }).txHash;
return typeof hash === "string" && hash.length > 0 ? hash : undefined;
}
}