feat: 添加 Lighter 适配器及相关功能,支持 trailing stops 和新的交易逻辑

This commit is contained in:
discountry
2025-09-30 22:08:05 +08:00
parent e83bdfccf9
commit c8b0ab1c8e
101 changed files with 90621 additions and 3 deletions
+1
View File
@@ -29,6 +29,7 @@ export interface KlineListener {
export interface ExchangeAdapter {
readonly id: string;
supportsTrailingStops(): boolean;
watchAccount(cb: AccountListener): void;
watchOrders(cb: OrderListener): void;
watchDepth(symbol: string, cb: DepthListener): void;
+4
View File
@@ -31,6 +31,10 @@ export class AsterExchangeAdapter implements ExchangeAdapter {
this.symbol = (credentials.symbol ?? process.env.TRADE_SYMBOL ?? "BTCUSDT").toUpperCase();
}
supportsTrailingStops(): boolean {
return true;
}
private safeInvoke<T extends (...args: any[]) => void>(context: string, cb: T): T {
const wrapped = ((...args: any[]) => {
try {
+10 -2
View File
@@ -1,15 +1,17 @@
import type { ExchangeAdapter } from "./adapter";
import { AsterExchangeAdapter, type AsterCredentials } from "./aster-adapter";
import { GrvtExchangeAdapter, type GrvtCredentials } from "./grvt/adapter";
import { LighterExchangeAdapter, type LighterCredentials } from "./lighter/adapter";
export interface ExchangeFactoryOptions {
symbol: string;
exchange?: string;
aster?: AsterCredentials;
grvt?: GrvtCredentials;
lighter?: LighterCredentials;
}
export type SupportedExchangeId = "aster" | "grvt";
export type SupportedExchangeId = "aster" | "grvt" | "lighter";
export function resolveExchangeId(value?: string | null): SupportedExchangeId {
const fallback = (value ?? process.env.EXCHANGE ?? process.env.TRADE_EXCHANGE ?? "aster")
@@ -17,11 +19,14 @@ export function resolveExchangeId(value?: string | null): SupportedExchangeId {
.trim()
.toLowerCase();
if (fallback === "grvt") return "grvt";
if (fallback === "lighter") return "lighter";
return "aster";
}
export function getExchangeDisplayName(id: SupportedExchangeId): string {
return id === "grvt" ? "GRVT" : "AsterDex";
if (id === "grvt") return "GRVT";
if (id === "lighter") return "Lighter";
return "AsterDex";
}
export function createExchangeAdapter(options: ExchangeFactoryOptions): ExchangeAdapter {
@@ -29,5 +34,8 @@ export function createExchangeAdapter(options: ExchangeFactoryOptions): Exchange
if (id === "grvt") {
return new GrvtExchangeAdapter({ ...options.grvt, symbol: options.symbol });
}
if (id === "lighter") {
return new LighterExchangeAdapter({ ...options.lighter, displaySymbol: options.symbol });
}
return new AsterExchangeAdapter({ ...options.aster, symbol: options.symbol });
}
+4
View File
@@ -93,6 +93,10 @@ export class GrvtExchangeAdapter implements ExchangeAdapter {
});
}
supportsTrailingStops(): boolean {
return false;
}
watchAccount(cb: AccountListener): void {
void this.ensureInitialized("watchAccount");
this.gateway.onAccount(this.safeInvoke("watchAccount", cb));
+160
View File
@@ -0,0 +1,160 @@
import type {
AccountListener,
DepthListener,
ExchangeAdapter,
KlineListener,
OrderListener,
TickerListener,
} from "../adapter";
import type { AsterOrder, CreateOrderParams } from "../types";
import { extractMessage } from "../../utils/errors";
import { LighterGateway, type LighterGatewayOptions } from "./gateway";
export interface LighterCredentials {
displaySymbol?: string;
marketSymbol?: string;
symbol?: string; // fallback alias
accountIndex?: number;
l1Address?: string;
apiKeys?: Record<number, string>;
apiPrivateKey?: string;
apiKeyIndex?: number;
environment?: string;
baseUrl?: string;
marketId?: number;
priceDecimals?: number;
sizeDecimals?: number;
chainId?: number;
tickerPollMs?: number;
klinePollMs?: number;
}
export class LighterExchangeAdapter implements ExchangeAdapter {
readonly id = "lighter";
private readonly gateway: LighterGateway;
private initPromise: Promise<void> | null = null;
private readonly initContexts = new Set<string>();
constructor(credentials: LighterCredentials = {}) {
const displaySymbolSource = credentials.displaySymbol ?? process.env.TRADE_SYMBOL ?? "";
if (!displaySymbolSource) {
throw new Error("TRADE_SYMBOL environment variable is required");
}
const displaySymbol = displaySymbolSource;
const marketSymbolSource = credentials.marketSymbol ?? credentials.symbol ?? process.env.LIGHTER_SYMBOL ?? displaySymbol;
const marketSymbol = marketSymbolSource.toUpperCase();
const accountIndex = credentials.accountIndex ?? parseInt(process.env.LIGHTER_ACCOUNT_INDEX ?? "", 10);
if (!Number.isFinite(accountIndex)) {
throw new Error("LIGHTER_ACCOUNT_INDEX environment variable is required");
}
const apiKeys = resolveApiKeys(credentials);
const environment = credentials.environment ?? process.env.LIGHTER_ENV;
const marketId = credentials.marketId ?? (process.env.LIGHTER_MARKET_ID ? Number(process.env.LIGHTER_MARKET_ID) : undefined);
const priceDecimals = credentials.priceDecimals ?? (process.env.LIGHTER_PRICE_DECIMALS ? Number(process.env.LIGHTER_PRICE_DECIMALS) : undefined);
const sizeDecimals = credentials.sizeDecimals ?? (process.env.LIGHTER_SIZE_DECIMALS ? Number(process.env.LIGHTER_SIZE_DECIMALS) : undefined);
const l1Address = credentials.l1Address ?? process.env.LIGHTER_L1_ADDRESS ?? null;
const gatewayOptions: LighterGatewayOptions = {
symbol: displaySymbol,
marketSymbol,
accountIndex,
apiKeys,
baseUrl: credentials.baseUrl ?? process.env.LIGHTER_BASE_URL,
environment: environment as LighterGatewayOptions["environment"],
marketId,
priceDecimals,
sizeDecimals,
chainId: credentials.chainId ?? (process.env.LIGHTER_CHAIN_ID ? Number(process.env.LIGHTER_CHAIN_ID) : undefined),
tickerPollMs: credentials.tickerPollMs,
klinePollMs: credentials.klinePollMs,
logger: (context, error) => this.logError(context, error),
l1Address: l1Address ?? undefined,
};
this.gateway = new LighterGateway(gatewayOptions);
}
supportsTrailingStops(): boolean {
return false;
}
watchAccount(handler: AccountListener): void {
void this.ensureInitialized("watchAccount");
this.gateway.onAccount(handler);
}
watchOrders(handler: OrderListener): void {
void this.ensureInitialized("watchOrders");
this.gateway.onOrders(handler);
}
watchDepth(_symbol: string, handler: DepthListener): void {
void this.ensureInitialized("watchDepth");
this.gateway.onDepth(handler);
}
watchTicker(_symbol: string, handler: TickerListener): void {
void this.ensureInitialized("watchTicker");
this.gateway.onTicker(handler);
}
watchKlines(_symbol: string, interval: string, handler: KlineListener): void {
void this.ensureInitialized(`watchKlines:${interval}`);
this.gateway.watchKlines(interval, handler);
}
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
await this.ensureInitialized("createOrder");
return this.gateway.createOrder(params);
}
async cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void> {
await this.ensureInitialized("cancelOrder");
await this.gateway.cancelOrder({ orderId: params.orderId });
}
async cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void> {
await this.ensureInitialized("cancelOrders");
for (const orderId of params.orderIdList) {
await this.gateway.cancelOrder({ orderId });
}
}
async cancelAllOrders(_params: { symbol: string }): Promise<void> {
await this.ensureInitialized("cancelAllOrders");
await this.gateway.cancelAllOrders();
}
private ensureInitialized(context?: string): Promise<void> {
if (!this.initPromise) {
this.initContexts.clear();
this.initPromise = this.gateway.ensureInitialized().catch((error) => {
this.logError("initialize", error);
this.initPromise = null;
throw error;
});
}
if (context && !this.initContexts.has(context)) {
this.initContexts.add(context);
this.initPromise.catch((error) => this.logError(context, error));
}
return this.initPromise;
}
private logError(context: string, error: unknown): void {
console.error(`[LighterExchangeAdapter] ${context} failed: ${extractMessage(error)}`);
}
}
function resolveApiKeys(credentials: LighterCredentials): Record<number, string> {
if (credentials.apiKeys && Object.keys(credentials.apiKeys).length) {
return credentials.apiKeys;
}
const privateKey = credentials.apiPrivateKey ?? process.env.LIGHTER_API_PRIVATE_KEY;
if (!privateKey) {
throw new Error("LIGHTER_API_PRIVATE_KEY environment variable is required");
}
const apiKeyIndex = credentials.apiKeyIndex ?? (process.env.LIGHTER_API_KEY_INDEX ? Number(process.env.LIGHTER_API_KEY_INDEX) : 0);
if (!Number.isInteger(apiKeyIndex) || apiKeyIndex < 0) {
throw new Error("Invalid LIGHTER_API_KEY_INDEX value");
}
return { [apiKeyIndex]: privateKey };
}
+28
View File
@@ -0,0 +1,28 @@
export function normalizeHex(input: string): string {
const trimmed = input.trim();
return trimmed.startsWith("0x") || trimmed.startsWith("0X") ? trimmed.slice(2) : trimmed;
}
export function hexToBytes(hex: string): Uint8Array {
const normalized = normalizeHex(hex);
if (normalized.length % 2 !== 0) {
throw new Error("Hex string must have even length");
}
const out = new Uint8Array(normalized.length / 2);
for (let i = 0; i < normalized.length; i += 2) {
const byte = normalized.slice(i, i + 2);
const value = Number.parseInt(byte, 16);
if (Number.isNaN(value)) {
throw new Error(`Invalid hex byte: ${byte}`);
}
out[i / 2] = value;
}
return out;
}
export function bytesToHex(bytes: Uint8Array, withPrefix = false): string {
const hex = Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
return withPrefix ? `0x${hex}` : hex;
}
+77
View File
@@ -0,0 +1,77 @@
export type LighterEnvironment = "mainnet" | "testnet" | "staging" | "dev";
export interface LighterHostConfig {
rest: string;
ws: string;
}
export const LIGHTER_HOSTS: Record<LighterEnvironment, LighterHostConfig> = {
mainnet: {
rest: "https://mainnet.zklighter.elliot.ai",
ws: "wss://mainnet.zklighter.elliot.ai/stream",
},
testnet: {
rest: "https://testnet.zklighter.elliot.ai",
ws: "wss://testnet.zklighter.elliot.ai/stream",
},
staging: {
rest: "https://staging.zklighter.elliot.ai",
ws: "wss://staging.zklighter.elliot.ai/stream",
},
dev: {
rest: "https://dev.zklighter.elliot.ai",
ws: "wss://dev.zklighter.elliot.ai/stream",
},
};
export const LIGHTER_CHAIN_IDS: Record<LighterEnvironment, number> = {
mainnet: 304,
testnet: 300,
staging: 300,
dev: 300,
};
export const DEFAULT_LIGHTER_ENVIRONMENT: LighterEnvironment = "testnet";
export const DEFAULT_TRANSACTION_EXPIRY_BUFFER_MS = 10 * 60 * 1000 - 1000; // 10 min minus 1s
export const DEFAULT_AUTH_TOKEN_HORIZON_MS = 10 * 60 * 1000; // server default is 10 minutes
export const DEFAULT_AUTH_TOKEN_BUFFER_MS = 60 * 1000; // refresh one minute before expiry
export const DEFAULT_ORDER_EXPIRY_PLACEHOLDER = -1; // signer converts -1 -> 28 days
export const IMMEDIATE_OR_CANCEL_EXPIRY_PLACEHOLDER = 0; // signer treats 0 as immediate
export const LIGHTER_ORDER_TYPE = {
LIMIT: 0,
MARKET: 1,
STOP_LOSS: 2,
STOP_LOSS_LIMIT: 3,
TAKE_PROFIT: 4,
TAKE_PROFIT_LIMIT: 5,
TWAP: 6,
} as const;
export const LIGHTER_TIME_IN_FORCE = {
IMMEDIATE_OR_CANCEL: 0,
GOOD_TILL_TIME: 1,
POST_ONLY: 2,
} as const;
export const LIGHTER_CANCEL_ALL_TIME_IN_FORCE = {
IMMEDIATE: 0,
SCHEDULED: 1,
ABORT: 2,
} as const;
export type LighterOrderType = (typeof LIGHTER_ORDER_TYPE)[keyof typeof LIGHTER_ORDER_TYPE];
export type LighterTimeInForce = (typeof LIGHTER_TIME_IN_FORCE)[keyof typeof LIGHTER_TIME_IN_FORCE];
export type LighterCancelAllTimeInForce =
(typeof LIGHTER_CANCEL_ALL_TIME_IN_FORCE)[keyof typeof LIGHTER_CANCEL_ALL_TIME_IN_FORCE];
export const LIGHTER_TX_TYPE = {
CREATE_ORDER: 14,
CANCEL_ORDER: 15,
CANCEL_ALL_ORDERS: 16,
} as const;
export const NIL_TRIGGER_PRICE = 0;
export const NIL_CLIENT_ORDER_INDEX = 0;
+144
View File
@@ -0,0 +1,144 @@
import { Fp } from "./goldilocks";
import {
Fp5,
FP5_ZERO,
FP5_ONE,
FP5_TWO,
FP5_W,
fp5Add,
fp5Mul,
fp5ScalarMul,
fp5Double,
fp5InverseOrZero,
} from "./goldilocks-fp5";
import { Scalar } from "./scalar";
const B1 = 263n;
const B = Fp5.fromUint64Array([0n, B1, 0n, 0n, 0n]);
const B_MUL2 = Fp5.fromUint64Array([0n, 2n * B1, 0n, 0n, 0n]);
const B_MUL4 = Fp5.fromUint64Array([0n, 4n * B1, 0n, 0n, 0n]);
const B_MUL16 = Fp5.fromUint64Array([0n, 16n * B1, 0n, 0n, 0n]);
const A = Fp5.fromUint64Array([2n, 0n, 0n, 0n, 0n]);
const FOUR_CONST = Fp5.fromUint64Array([4n, 0n, 0n, 0n, 0n]);
export class ECPoint {
constructor(
readonly x: Fp5,
readonly z: Fp5,
readonly u: Fp5,
readonly t: Fp5,
) {}
clone(): ECPoint {
return new ECPoint(this.x.clone(), this.z.clone(), this.u.clone(), this.t.clone());
}
static neutral(): ECPoint {
return new ECPoint(FP5_ZERO, FP5_ONE, FP5_ZERO, FP5_ONE);
}
static generator(): ECPoint {
return new ECPoint(
Fp5.fromUint64Array([
12883135586176881569n,
4356519642755055268n,
5248930565894896907n,
2165973894480315022n,
2448410071095648785n,
]),
FP5_ONE,
FP5_ONE,
Fp5.fromUint64Array([4n, 0n, 0n, 0n, 0n]),
);
}
isNeutral(): boolean {
return this.u.equals(FP5_ZERO);
}
encode(): Fp5 {
return fp5Mul(this.t, fp5InverseOrZero(this.u));
}
add(other: ECPoint): ECPoint {
if (this.isNeutral()) return other.clone();
if (other.isNeutral()) return this.clone();
const x1 = this.x;
const z1 = this.z;
const u1 = this.u;
const t1 = this.t;
const x2 = other.x;
const z2 = other.z;
const u2 = other.u;
const t2 = other.t;
const t1_ = fp5Mul(x1, x2);
const t2_ = fp5Mul(z1, z2);
const t3 = fp5Mul(u1, u2);
const t4 = fp5Mul(t1, t2);
const t5 = fp5SubMul(fp5Add(x1, z1), fp5Add(x2, z2), fp5Add(t1_, t2_));
const t6 = fp5SubMul(fp5Add(u1, t1), fp5Add(u2, t2), fp5Add(t3, t4));
const t7 = fp5Add(t1_, fp5Mul(t2_, B));
const t8 = fp5Mul(t4, t7);
const t9 = fp5Mul(t3, fp5Add(fp5Mul(t5, B_MUL2), fp5Double(t7)));
const t10 = fp5Mul(fp5Add(t4, fp5Double(t3)), fp5Add(t5, t7));
const xNew = fp5Mul(fp5Sub(t10, t8), B);
const zNew = fp5Sub(t8, t9);
const uNew = fp5Mul(t6, fp5Sub(fp5Mul(t2_, B), t1_));
const tNew = fp5Add(t8, t9);
return new ECPoint(xNew, zNew, uNew, tNew);
}
double(): ECPoint {
if (this.isNeutral()) return this.clone();
const x = this.x;
const z = this.z;
const u = this.u;
const t = this.t;
const t1 = fp5Mul(z, t);
const t2 = fp5Mul(t1, t);
const x1 = fp5Mul(t2, t2);
const z1 = fp5Mul(t1, u);
const t3 = fp5Mul(u, u);
const w1 = fp5Sub(t2, fp5Mul(fp5Double(fp5Add(x, z)), t3));
const t4 = fp5Mul(z1, z1);
const xNew = fp5Mul(t4, B_MUL4);
const zNew = fp5Mul(w1, w1);
const uNew = fp5Sub(fp5Mul(fp5Add(w1, z1), fp5Add(w1, z1)), fp5Add(t4, zNew));
const tNew = fp5Sub(
fp5Double(x1),
fp5Add(fp5Mul(t4, FOUR_CONST), zNew),
);
return new ECPoint(xNew, zNew, uNew, tNew);
}
mul(scalar: Scalar): ECPoint {
let result = ECPoint.neutral();
let addend = this.clone();
let k = scalar.toBigInt();
while (k > 0n) {
if (k & 1n) {
result = result.add(addend);
}
addend = addend.double();
k >>= 1n;
}
return result;
}
}
function fp5Sub(a: Fp5, b: Fp5): Fp5 {
return a.sub(b);
}
function fp5SubMul(a: Fp5, b: Fp5, subtract: Fp5): Fp5 {
return fp5Mul(a, b).sub(subtract);
}
@@ -0,0 +1,460 @@
import { Fp, GOLDILOCKS_MODULUS, addMany, powers as fpPowers } from "./goldilocks";
export type Fp5Tuple = [Fp, Fp, Fp, Fp, Fp];
function expectLength(bytes: Uint8Array, expected: number): void {
if (bytes.length !== expected) {
throw new Error(`Expected ${expected} bytes, got ${bytes.length}`);
}
}
const BYTE_LENGTH = 5 * 8;
export const FP5_W = new Fp(3n);
export const FP5_DTH_ROOT = new Fp(1041288259238279555n);
const THREE = new Fp(3n);
export class Fp5 {
readonly c0: Fp;
readonly c1: Fp;
readonly c2: Fp;
readonly c3: Fp;
readonly c4: Fp;
constructor(coeffs: Fp5Tuple) {
[this.c0, this.c1, this.c2, this.c3, this.c4] = coeffs;
}
static ZERO = new Fp5([Fp.ZERO, Fp.ZERO, Fp.ZERO, Fp.ZERO, Fp.ZERO]);
static ONE = new Fp5([Fp.ONE, Fp.ZERO, Fp.ZERO, Fp.ZERO, Fp.ZERO]);
static fromFp(value: Fp): Fp5 {
return new Fp5([value.clone(), Fp.ZERO, Fp.ZERO, Fp.ZERO, Fp.ZERO]);
}
static fromUint64Array(values: ArrayLike<bigint | number>): Fp5 {
if (values.length !== 5) {
throw new Error("Fp5.fromUint64Array expects 5 values");
}
const get = (index: number): bigint | number => {
const value = values[index];
if (value === undefined) {
throw new Error("Fp5.fromUint64Array missing value");
}
return value;
};
const arr: [bigint | number, bigint | number, bigint | number, bigint | number, bigint | number] = [
get(0),
get(1),
get(2),
get(3),
get(4),
];
return new Fp5([
new Fp(arr[0]),
new Fp(arr[1]),
new Fp(arr[2]),
new Fp(arr[3]),
new Fp(arr[4]),
]);
}
toTuple(): Fp5Tuple {
return [this.c0, this.c1, this.c2, this.c3, this.c4];
}
toBytes(): Uint8Array {
const out = new Uint8Array(BYTE_LENGTH);
out.set(this.c0.toBytesLE(), 0);
out.set(this.c1.toBytesLE(), 8);
out.set(this.c2.toBytesLE(), 16);
out.set(this.c3.toBytesLE(), 24);
out.set(this.c4.toBytesLE(), 32);
return out;
}
static fromBytes(bytes: Uint8Array): Fp5 {
expectLength(bytes, BYTE_LENGTH);
return new Fp5([
Fp.fromBytesLE(bytes.subarray(0, 8)),
Fp.fromBytesLE(bytes.subarray(8, 16)),
Fp.fromBytesLE(bytes.subarray(16, 24)),
Fp.fromBytesLE(bytes.subarray(24, 32)),
Fp.fromBytesLE(bytes.subarray(32, 40)),
]);
}
equals(other: Fp5): boolean {
return (
this.c0.equals(other.c0) &&
this.c1.equals(other.c1) &&
this.c2.equals(other.c2) &&
this.c3.equals(other.c3) &&
this.c4.equals(other.c4)
);
}
isZero(): boolean {
return this.equals(Fp5.ZERO);
}
clone(): Fp5 {
return new Fp5([
this.c0.clone(),
this.c1.clone(),
this.c2.clone(),
this.c3.clone(),
this.c4.clone(),
]);
}
add(other: Fp5): Fp5 {
return new Fp5([
this.c0.add(other.c0),
this.c1.add(other.c1),
this.c2.add(other.c2),
this.c3.add(other.c3),
this.c4.add(other.c4),
]);
}
sub(other: Fp5): Fp5 {
return new Fp5([
this.c0.sub(other.c0),
this.c1.sub(other.c1),
this.c2.sub(other.c2),
this.c3.sub(other.c3),
this.c4.sub(other.c4),
]);
}
neg(): Fp5 {
return new Fp5([
this.c0.neg(),
this.c1.neg(),
this.c2.neg(),
this.c3.neg(),
this.c4.neg(),
]);
}
mul(other: Fp5): Fp5 {
const w = FP5_W;
const a0b0 = this.c0.mul(other.c0);
const a1b4 = this.c1.mul(other.c4);
const a2b3 = this.c2.mul(other.c3);
const a3b2 = this.c3.mul(other.c2);
const a4b1 = this.c4.mul(other.c1);
const added0 = addMany([a1b4, a2b3, a3b2, a4b1]);
const muld0 = added0.mul(w);
const c0 = a0b0.add(muld0);
const a0b1 = this.c0.mul(other.c1);
const a1b0 = this.c1.mul(other.c0);
const a2b4 = this.c2.mul(other.c4);
const a3b3 = this.c3.mul(other.c3);
const a4b2 = this.c4.mul(other.c2);
const added1 = addMany([a2b4, a3b3, a4b2]);
const muld1 = added1.mul(w);
const c1 = addMany([a0b1, a1b0, muld1]);
const a0b2 = this.c0.mul(other.c2);
const a1b1 = this.c1.mul(other.c1);
const a2b0 = this.c2.mul(other.c0);
const a3b4 = this.c3.mul(other.c4);
const a4b3 = this.c4.mul(other.c3);
const added2 = addMany([a3b4, a4b3]);
const muld2 = added2.mul(w);
const c2 = addMany([a0b2, a1b1, a2b0, muld2]);
const a0b3 = this.c0.mul(other.c3);
const a1b2 = this.c1.mul(other.c2);
const a2b1 = this.c2.mul(other.c1);
const a3b0 = this.c3.mul(other.c0);
const a4b4 = this.c4.mul(other.c4);
const muld3 = a4b4.mul(w);
const c3 = addMany([a0b3, a1b2, a2b1, a3b0, muld3]);
const a0b4 = this.c0.mul(other.c4);
const a1b3 = this.c1.mul(other.c3);
const a2b2 = this.c2.mul(other.c2);
const a3b1 = this.c3.mul(other.c1);
const a4b0 = this.c4.mul(other.c0);
const c4 = addMany([a0b4, a1b3, a2b2, a3b1, a4b0]);
return new Fp5([c0, c1, c2, c3, c4]);
}
square(): Fp5 {
const w = FP5_W;
const doubleW = FP5_W.add(FP5_W);
const a0s = this.c0.mul(this.c0);
const a1a4 = this.c1.mul(this.c4);
const a2a3 = this.c2.mul(this.c3);
const added0 = addMany([a1a4, a2a3]);
const muld0 = added0.mul(doubleW);
const c0 = a0s.add(muld0);
const a0Double = this.c0.add(this.c0);
const a0Doublea1 = a0Double.mul(this.c1);
const a2a4DoubleW = this.c2.mul(this.c4).mul(doubleW);
const a3a3w = this.c3.mul(this.c3).mul(w);
const c1 = addMany([a0Doublea1, a2a4DoubleW, a3a3w]);
const a0Doublea2 = a0Double.mul(this.c2);
const a1Square = this.c1.mul(this.c1);
const a4a3DoubleW = this.c4.mul(this.c3).mul(doubleW);
const c2 = addMany([a0Doublea2, a1Square, a4a3DoubleW]);
const a1Double = this.c1.add(this.c1);
const a0Doublea3 = a0Double.mul(this.c3);
const a1Doublea2 = a1Double.mul(this.c2);
const a4SquareW = this.c4.mul(this.c4).mul(w);
const c3 = addMany([a0Doublea3, a1Doublea2, a4SquareW]);
const a0Doublea4 = a0Double.mul(this.c4);
const a1Doublea3 = a1Double.mul(this.c3);
const a2Square = this.c2.mul(this.c2);
const c4 = addMany([a0Doublea4, a1Doublea3, a2Square]);
return new Fp5([c0, c1, c2, c3, c4]);
}
expPowerOf2(power: number): Fp5 {
let result = this.clone();
for (let i = 0; i < power; i++) {
result = result.square();
}
return result;
}
double(): Fp5 {
return this.add(this);
}
triple(): Fp5 {
return new Fp5([
this.c0.mul(THREE),
this.c1.mul(THREE),
this.c2.mul(THREE),
this.c3.mul(THREE),
this.c4.mul(THREE),
]);
}
scalarMul(scalar: Fp): Fp5 {
return new Fp5([
this.c0.mul(scalar),
this.c1.mul(scalar),
this.c2.mul(scalar),
this.c3.mul(scalar),
this.c4.mul(scalar),
]);
}
inverseOrZero(): Fp5 {
if (this.isZero()) return Fp5.ZERO;
const d = this.frobenius();
const e = this.mul(d.frobenius());
const f = e.mul(e.repeatedFrobenius(2));
const a0b0 = this.c0.mul(f.c0);
const a1b4 = this.c1.mul(f.c4);
const a2b3 = this.c2.mul(f.c3);
const a3b2 = this.c3.mul(f.c2);
const a4b1 = this.c4.mul(f.c1);
const added = addMany([a1b4, a2b3, a3b2, a4b1]);
const muld = added.mul(FP5_W);
const g = a0b0.add(muld);
const gInv = g.inverse();
return f.scalarMul(gInv);
}
div(other: Fp5): Fp5 {
const inv = other.inverseOrZero();
if (inv.isZero()) {
throw new Error("Division by zero in Fp5");
}
return this.mul(inv);
}
frobenius(): Fp5 {
return this.repeatedFrobenius(1);
}
repeatedFrobenius(count: number): Fp5 {
if (count === 0) return this;
const reduced = count % 5;
if (reduced === 0) return this;
let z0 = FP5_DTH_ROOT;
for (let i = 1; i < reduced; i++) {
z0 = z0.mul(FP5_DTH_ROOT);
}
const powerArray = fpPowers(z0, 5) as [Fp, Fp, Fp, Fp, Fp];
const [p0, p1, p2, p3, p4] = powerArray;
return new Fp5([
this.c0.mul(p0),
this.c1.mul(p1),
this.c2.mul(p2),
this.c3.mul(p3),
this.c4.mul(p4),
]);
}
legendre(): Fp {
const frob1 = this.frobenius();
const frob2 = frob1.frobenius();
const frob1TimesFrob2 = frob1.mul(frob2);
const frob2Frob1TimesFrob2 = frob1TimesFrob2.repeatedFrobenius(2);
const xrExt = this.mul(frob1TimesFrob2).mul(frob2Frob1TimesFrob2);
const xr = new Fp(xrExt.c0.toBigInt());
const xr31 = xr.pow(1n << 31n);
const xr31Inv = xr31.isZero() ? Fp.ZERO : xr31.inverse();
const xr63 = xr31.pow(1n << 32n);
return xr63.mul(xr31Inv);
}
sqrt(): { value: Fp5; exists: boolean } {
const v = this.expPowerOf2(31);
const d = this.mul(v.expPowerOf2(32)).mul(v.inverseOrZero());
const e = d.mul(d.repeatedFrobenius(2)).frobenius();
const f = e.square();
const x1f4 = this.c1.mul(f.c4);
const x2f3 = this.c2.mul(f.c3);
const x3f2 = this.c3.mul(f.c2);
const x4f1 = this.c4.mul(f.c1);
const added = addMany([x1f4, x2f3, x3f2, x4f1]);
const muld = added.mul(THREE);
const x0f0 = this.c0.mul(f.c0);
const g = x0f0.add(muld);
const s = sqrtFp(g);
if (!s) {
return { value: Fp5.ZERO, exists: false };
}
const eInv = e.inverseOrZero();
const sFp5 = Fp5.fromFp(s);
return { value: sFp5.mul(eInv), exists: true };
}
canonicalSqrt(): { value: Fp5; exists: boolean } {
const { value, exists } = this.sqrt();
if (!exists) return { value: Fp5.ZERO, exists: false };
return { value: sgn0(value) ? value.neg() : value, exists: true };
}
}
function sgn0(x: Fp5): boolean {
let sign = false;
let zero = true;
for (const limb of [x.c0, x.c1, x.c2, x.c3, x.c4]) {
const limbSign = (limb.toBigInt() & 1n) === 0n;
const limbZero = limb.isZero();
sign = sign || (zero && limbSign);
zero = zero && limbZero;
}
return sign;
}
function sqrtFp(value: Fp): Fp | null {
if (value.isZero()) return Fp.ZERO;
const p = GOLDILOCKS_MODULUS;
const leg = value.pow((p - 1n) / 2n);
if (leg.isZero()) return Fp.ZERO;
const legVal = leg.toBigInt();
if (legVal === p - 1n) return null;
let q = p - 1n;
let s = 0n;
while ((q & 1n) === 0n) {
q >>= 1n;
s += 1n;
}
let z = 2n;
while (true) {
const zLeg = new Fp(z).pow((p - 1n) / 2n).toBigInt();
if (zLeg === p - 1n) break;
z += 1n;
}
let c = new Fp(z).pow(q);
let x = value.pow((q + 1n) >> 1n);
let t = value.pow(q);
let m = s;
while (t.toBigInt() !== 1n) {
let i = 1n;
let t2i = t.mul(t);
while (t2i.toBigInt() !== 1n) {
t2i = t2i.mul(t2i);
i += 1n;
if (i === m) return null;
}
const b = c.pow(1n << (m - i - 1n));
x = x.mul(b);
c = b.mul(b);
t = t.mul(c);
m = i;
}
return x;
}
export const FP5_ZERO = Fp5.ZERO;
export const FP5_ONE = Fp5.ONE;
export const FP5_TWO = Fp5.fromFp(new Fp(2n));
export function fp5Add(a: Fp5, ...others: Fp5[]): Fp5 {
let acc = a;
for (const other of others) {
acc = acc.add(other);
}
return acc;
}
export function fp5Sub(a: Fp5, b: Fp5): Fp5 {
return a.sub(b);
}
export function fp5Mul(a: Fp5, b: Fp5): Fp5 {
return a.mul(b);
}
export function fp5Square(a: Fp5): Fp5 {
return a.square();
}
export function fp5Double(a: Fp5): Fp5 {
return a.double();
}
export function fp5ScalarMul(a: Fp5, scalar: Fp): Fp5 {
return a.scalarMul(scalar);
}
export function fp5InverseOrZero(a: Fp5): Fp5 {
return a.inverseOrZero();
}
export function fp5Frobenius(a: Fp5): Fp5 {
return a.frobenius();
}
export function fp5RepeatedFrobenius(a: Fp5, count: number): Fp5 {
return a.repeatedFrobenius(count);
}
export function fp5Legendre(a: Fp5): Fp {
return a.legendre();
}
export function fp5CanonicalSqrt(a: Fp5): { value: Fp5; exists: boolean } {
return a.canonicalSqrt();
}
+179
View File
@@ -0,0 +1,179 @@
import { randomBytes } from "crypto";
const TWO_POW_32 = 1n << 32n;
const TWO_POW_64 = 1n << 64n;
export const GOLDILOCKS_MODULUS = TWO_POW_64 - TWO_POW_32 + 1n;
const BYTE_LENGTH = 8;
function mod(value: bigint): bigint {
let v = value % GOLDILOCKS_MODULUS;
if (v < 0n) v += GOLDILOCKS_MODULUS;
return v;
}
export class Fp {
readonly value: bigint;
constructor(value: bigint | number) {
this.value = mod(typeof value === "number" ? BigInt(value) : value);
}
static readonly ZERO = new Fp(0n);
static readonly ONE = new Fp(1n);
static fromBytesLE(bytes: Uint8Array): Fp {
if (bytes.length !== BYTE_LENGTH) {
throw new Error(`Goldilocks element expects 8 bytes, got ${bytes.length}`);
}
let acc = 0n;
for (let i = 0; i < BYTE_LENGTH; i++) {
const byte = bytes[i];
if (byte === undefined) throw new Error("Unexpected undefined byte when reading Goldilocks element");
acc |= BigInt(byte) << BigInt(8 * i);
}
return new Fp(acc);
}
toBytesLE(): Uint8Array {
let v = this.value;
const out = new Uint8Array(BYTE_LENGTH);
for (let i = 0; i < BYTE_LENGTH; i++) {
out[i] = Number(v & 0xffn);
v >>= 8n;
}
return out;
}
toBigInt(): bigint {
return this.value;
}
toNumber(): number {
return Number(this.value);
}
add(...others: Fp[]): Fp {
let acc = this.value;
for (const other of others) acc += other.value;
return new Fp(acc);
}
sub(other: Fp): Fp {
return new Fp(this.value - other.value);
}
neg(): Fp {
return new Fp(this.value === 0n ? 0n : GOLDILOCKS_MODULUS - this.value);
}
mul(...others: Fp[]): Fp {
let acc = this.value;
for (const other of others) acc = mod(acc * other.value);
return new Fp(acc);
}
square(): Fp {
return new Fp(mod(this.value * this.value));
}
double(): Fp {
return new Fp(this.value << 1n);
}
inverse(): Fp {
if (this.isZero()) {
throw new Error("Cannot invert zero in Goldilocks field");
}
return this.pow(GOLDILOCKS_MODULUS - 2n);
}
pow(exponent: bigint): Fp {
let result = 1n;
let base = this.value;
let exp = exponent;
while (exp > 0n) {
if (exp & 1n) result = mod(result * base);
base = mod(base * base);
exp >>= 1n;
}
return new Fp(result);
}
isZero(): boolean {
return this.value === 0n;
}
isOne(): boolean {
return this.value === 1n;
}
equals(other: Fp): boolean {
return this.value === other.value;
}
clone(): Fp {
return new Fp(this.value);
}
static random(): Fp {
while (true) {
const buf = randomBytes(BYTE_LENGTH);
let acc = 0n;
for (let i = 0; i < BYTE_LENGTH; i++) {
const byte = buf[i];
if (byte === undefined) throw new Error("Unexpected undefined byte when sampling Goldilocks element");
acc |= BigInt(byte) << BigInt(8 * i);
}
if (acc < GOLDILOCKS_MODULUS) {
return new Fp(acc);
}
}
}
static fromUint32(value: number): Fp {
return new Fp(BigInt(value >>> 0));
}
static fromUint64(value: bigint | number): Fp {
return new Fp(value);
}
}
export function addMany(elements: readonly Fp[]): Fp {
let acc = 0n;
for (const el of elements) acc += el.value;
return new Fp(acc);
}
export function arrayToBytesLE(elements: readonly Fp[]): Uint8Array {
const out = new Uint8Array(elements.length * BYTE_LENGTH);
elements.forEach((elem, idx) => {
out.set(elem.toBytesLE(), idx * BYTE_LENGTH);
});
return out;
}
export function arrayFromBytesLE(bytes: Uint8Array): Fp[] {
if (bytes.length % BYTE_LENGTH !== 0) {
throw new Error("Goldilocks array bytes length must be multiple of 8");
}
const out: Fp[] = [];
for (let i = 0; i < bytes.length; i += BYTE_LENGTH) {
out.push(Fp.fromBytesLE(bytes.subarray(i, i + BYTE_LENGTH)));
}
return out;
}
export function powers(base: Fp, count: number): Fp[] {
if (count <= 0) return [];
const result = new Array<Fp>(count);
result[0] = Fp.ONE;
for (let i = 1; i < count; i++) {
const prev = result[i - 1];
if (!prev) throw new Error("unexpected undefined in goldilocks powers");
result[i] = prev.mul(base);
}
return result;
}
export const GOLDILOCKS_BYTE_LENGTH = BYTE_LENGTH;
+320
View File
@@ -0,0 +1,320 @@
import { Fp } from "./goldilocks";
import { Fp5 } from "./goldilocks-fp5";
const WIDTH = 12;
const RATE = 8;
const ROUNDS_F = 8;
const ROUNDS_F_HALF = 4;
const ROUNDS_P = 22;
const EXTERNAL_CONSTANTS: Fp[][] = [
[
new Fp(15492826721047263190n),
new Fp(11728330187201910315n),
new Fp(8836021247773420868n),
new Fp(16777404051263952451n),
new Fp(5510875212538051896n),
new Fp(6173089941271892285n),
new Fp(2927757366422211339n),
new Fp(10340958981325008808n),
new Fp(8541987352684552425n),
new Fp(9739599543776434497n),
new Fp(15073950188101532019n),
new Fp(12084856431752384512n),
],
[
new Fp(4584713381960671270n),
new Fp(8807052963476652830n),
new Fp(54136601502601741n),
new Fp(4872702333905478703n),
new Fp(5551030319979516287n),
new Fp(12889366755535460989n),
new Fp(16329242193178844328n),
new Fp(412018088475211848n),
new Fp(10505784623379650541n),
new Fp(9758812378619434837n),
new Fp(7421979329386275117n),
new Fp(375240370024755551n),
],
[
new Fp(3331431125640721931n),
new Fp(15684937309956309981n),
new Fp(578521833432107983n),
new Fp(14379242000670861838n),
new Fp(17922409828154900976n),
new Fp(8153494278429192257n),
new Fp(15904673920630731971n),
new Fp(11217863998460634216n),
new Fp(3301540195510742136n),
new Fp(9937973023749922003n),
new Fp(3059102938155026419n),
new Fp(1895288289490976132n),
],
[
new Fp(5580912693628927540n),
new Fp(10064804080494788323n),
new Fp(9582481583369602410n),
new Fp(10186259561546797986n),
new Fp(247426333829703916n),
new Fp(13193193905461376067n),
new Fp(6386232593701758044n),
new Fp(17954717245501896472n),
new Fp(1531720443376282699n),
new Fp(2455761864255501970n),
new Fp(11234429217864304495n),
new Fp(4746959618548874102n),
],
[
new Fp(13571697342473846203n),
new Fp(17477857865056504753n),
new Fp(15963032953523553760n),
new Fp(16033593225279635898n),
new Fp(14252634232868282405n),
new Fp(8219748254835277737n),
new Fp(7459165569491914711n),
new Fp(15855939513193752003n),
new Fp(16788866461340278896n),
new Fp(7102224659693946577n),
new Fp(3024718005636976471n),
new Fp(13695468978618890430n),
],
[
new Fp(8214202050877825436n),
new Fp(2670727992739346204n),
new Fp(16259532062589659211n),
new Fp(11869922396257088411n),
new Fp(3179482916972760137n),
new Fp(13525476046633427808n),
new Fp(3217337278042947412n),
new Fp(14494689598654046340n),
new Fp(15837379330312175383n),
new Fp(8029037639801151344n),
new Fp(2153456285263517937n),
new Fp(8301106462311849241n),
],
[
new Fp(13294194396455217955n),
new Fp(17394768489610594315n),
new Fp(12847609130464867455n),
new Fp(14015739446356528640n),
new Fp(5879251655839607853n),
new Fp(9747000124977436185n),
new Fp(8950393546890284269n),
new Fp(10765765936405694368n),
new Fp(14695323910334139959n),
new Fp(16366254691123000864n),
new Fp(15292774414889043182n),
new Fp(10910394433429313384n),
],
[
new Fp(17253424460214596184n),
new Fp(3442854447664030446n),
new Fp(3005570425335613727n),
new Fp(10859158614900201063n),
new Fp(9763230642109343539n),
new Fp(6647722546511515039n),
new Fp(909012944955815706n),
new Fp(18101204076790399111n),
new Fp(11588128829349125809n),
new Fp(15863878496612806566n),
new Fp(5201119062417750399n),
new Fp(176665553780565743n),
],
];
const INTERNAL_CONSTANTS: Fp[] = [
new Fp(11921381764981422944n),
new Fp(10318423381711320787n),
new Fp(8291411502347000766n),
new Fp(229948027109387563n),
new Fp(9152521390190983261n),
new Fp(7129306032690285515n),
new Fp(15395989607365232011n),
new Fp(8641397269074305925n),
new Fp(17256848792241043600n),
new Fp(6046475228902245682n),
new Fp(12041608676381094092n),
new Fp(12785542378683951657n),
new Fp(14546032085337914034n),
new Fp(3304199118235116851n),
new Fp(16499627707072547655n),
new Fp(10386478025625759321n),
new Fp(13475579315436919170n),
new Fp(16042710511297532028n),
new Fp(1411266850385657080n),
new Fp(9024840976168649958n),
new Fp(14047056970978379368n),
new Fp(838728605080212101n),
];
const MATRIX_DIAG: Fp[] = [
new Fp(0xc3b6c08e23ba9300n),
new Fp(0xd84b5de94a324fb6n),
new Fp(0x0d0c371c5b35b84fn),
new Fp(0x7964f570e7188037n),
new Fp(0x5daf18bbd996604bn),
new Fp(0x6743bc47b9595257n),
new Fp(0x5528b9362c59bb70n),
new Fp(0xac45e25b7127b68bn),
new Fp(0xa2077d7dfbb606b5n),
new Fp(0xf3faac6faee378aen),
new Fp(0x0c6388b51545e883n),
new Fp(0xd27dbb6944917b60n),
];
export function hashToFp5(values: Fp[]): Fp5 {
const result = hashNToMNoPad(values, 5);
const [c0, c1, c2, c3, c4] = result as [Fp, Fp, Fp, Fp, Fp];
return new Fp5([c0, c1, c2, c3, c4]);
}
export function hashTwoToOne(a: Fp[], b: Fp[]): Fp[] {
return hashNToMNoPad([...a, ...b], 4);
}
export function hashNToMNoPad(input: Fp[], outputCount: number): Fp[] {
const state: Fp[] = Array.from({ length: WIDTH }, () => Fp.ZERO);
for (let offset = 0; offset < input.length; offset += RATE) {
for (let j = 0; j < RATE && offset + j < input.length; j++) {
const current = state[j]!;
const value = input[offset + j];
if (value === undefined) throw new Error("poseidon input missing element");
state[j] = current.add(value);
}
permute(state);
}
const outputs: Fp[] = [];
while (outputs.length < outputCount) {
for (let i = 0; i < RATE && outputs.length < outputCount; i++) {
outputs.push(state[i]!);
}
if (outputs.length < outputCount) {
permute(state);
}
}
return outputs;
}
function permute(state: Fp[]): void {
externalLinearLayer(state);
fullRounds(state, 0);
partialRounds(state);
fullRounds(state, ROUNDS_F_HALF);
}
function fullRounds(state: Fp[], startRound: number): void {
for (let r = startRound; r < startRound + ROUNDS_F_HALF; r++) {
addRoundConstants(state, r);
sbox(state);
externalLinearLayer(state);
}
}
function partialRounds(state: Fp[]): void {
for (let r = 0; r < ROUNDS_P; r++) {
addInternalConstant(state, r);
sboxP(state, 0);
internalLinearLayer(state);
}
}
function addRoundConstants(state: Fp[], round: number): void {
const constants = EXTERNAL_CONSTANTS[round];
if (!constants) {
throw new Error(`poseidon round constant missing for round ${round}`);
}
for (let i = 0; i < WIDTH; i++) {
state[i] = state[i]!.add(constants[i]!);
}
}
function addInternalConstant(state: Fp[], round: number): void {
const constant = INTERNAL_CONSTANTS[round];
if (!constant) {
throw new Error(`poseidon internal constant missing for round ${round}`);
}
state[0] = state[0]!.add(constant);
}
function sbox(state: Fp[]): void {
for (let i = 0; i < WIDTH; i++) {
sboxP(state, i);
}
}
function sboxP(state: Fp[], index: number): void {
const x = state[index];
if (!x) throw new Error(`poseidon state missing at index ${index}`);
const x2 = x.square();
const x3 = x2.mul(x);
const x6 = x3.square();
state[index] = x6.mul(x);
}
function externalLinearLayer(state: Fp[]): void {
for (let block = 0; block < 3; block++) {
const base = block * 4;
const s0 = state[base]!;
const s1 = state[base + 1]!;
const s2 = state[base + 2]!;
const s3 = state[base + 3]!;
const t0 = s0.add(s1);
const t1 = s2.add(s3);
const t2 = t0.add(t1);
const t3 = t2.add(s1);
const t4 = t2.add(s3);
const t5 = s0.double();
const t6 = s2.double();
state[base] = t3.add(t0);
state[base + 1] = t6.add(t3);
state[base + 2] = t1.add(t4);
state[base + 3] = t5.add(t4);
}
const sums: Fp[] = [Fp.ZERO, Fp.ZERO, Fp.ZERO, Fp.ZERO];
for (let k = 0; k < 4; k++) {
for (let j = 0; j < WIDTH; j += 4) {
const currentSum = sums[k];
if (currentSum === undefined) throw new Error("poseidon sums missing value");
sums[k] = currentSum.add(state[j + k]!);
}
}
for (let i = 0; i < WIDTH; i++) {
state[i] = state[i]!.add(sums[i % 4]!);
}
}
function internalLinearLayer(state: Fp[]): void {
let sum = state[0]!;
for (let i = 1; i < WIDTH; i++) {
sum = sum.add(state[i]!);
}
for (let i = 0; i < WIDTH; i++) {
state[i] = state[i]!.mul(MATRIX_DIAG[i]!).add(sum);
}
}
export function hashToQuinticExtension(preimage: Fp[]): Fp5 {
return hashToFp5(preimage);
}
export function hashFp5Pair(a: Fp5, b: Fp5): Fp5 {
const inputs: Fp[] = [...a.toTuple(), ...b.toTuple()];
return hashToQuinticExtension(inputs);
}
export function mergeFp5WithHash(fp5Values: Fp5[]): Fp5 {
if (fp5Values.length === 0) return Fp5.ZERO;
const first = fp5Values[0];
if (!first) throw new Error("mergeFp5WithHash received empty array");
let acc = first;
for (let i = 1; i < fp5Values.length; i++) {
const next = fp5Values[i];
if (!next) throw new Error("mergeFp5WithHash encountered undefined value");
acc = hashFp5Pair(acc, next);
}
return acc;
}
+159
View File
@@ -0,0 +1,159 @@
import { randomBytes } from "crypto";
import { Fp } from "./goldilocks";
import { Fp5 } from "./goldilocks-fp5";
const ORDER = BigInt("1067993516717146951041484916571792702745057740581727230159139685185762082554198619328292418486241");
const BYTE_LENGTH = 40;
const FOUR_BIT_LIMBS = 80;
const BIT_LENGTH = 319;
function modOrder(value: bigint): bigint {
let v = value % ORDER;
if (v < 0n) v += ORDER;
return v;
}
export class Scalar {
readonly value: bigint;
constructor(value: bigint | number) {
this.value = modOrder(typeof value === "number" ? BigInt(value) : value);
}
static readonly ZERO = new Scalar(0n);
static readonly ONE = new Scalar(1n);
static fromBytesLE(bytes: Uint8Array): Scalar {
if (bytes.length !== BYTE_LENGTH) {
throw new Error(`Scalar expects ${BYTE_LENGTH} bytes, got ${bytes.length}`);
}
let acc = 0n;
for (let i = 0; i < BYTE_LENGTH; i++) {
const byte = bytes[i];
if (byte === undefined) throw new Error("Unexpected undefined byte when reading scalar");
acc |= BigInt(byte) << BigInt(8 * i);
}
return new Scalar(acc);
}
toBytesLE(): Uint8Array {
let v = this.value;
const out = new Uint8Array(BYTE_LENGTH);
for (let i = 0; i < BYTE_LENGTH; i++) {
out[i] = Number(v & 0xffn);
v >>= 8n;
}
return out;
}
toBigInt(): bigint {
return this.value;
}
add(other: Scalar): Scalar {
return new Scalar(this.value + other.value);
}
sub(other: Scalar): Scalar {
return new Scalar(this.value - other.value);
}
neg(): Scalar {
return new Scalar(this.value === 0n ? 0n : ORDER - this.value);
}
mul(other: Scalar): Scalar {
return new Scalar(this.value * other.value);
}
square(): Scalar {
return this.mul(this);
}
pow(exponent: bigint): Scalar {
let result = 1n;
let base = this.value;
let exp = exponent;
while (exp > 0n) {
if (exp & 1n) result = modOrder(result * base);
base = modOrder(base * base);
exp >>= 1n;
}
return new Scalar(result);
}
isZero(): boolean {
return this.value === 0n;
}
equals(other: Scalar): boolean {
return this.value === other.value;
}
clone(): Scalar {
return new Scalar(this.value);
}
splitTo4BitLimbs(): Uint8Array {
const limbs = new Uint8Array(FOUR_BIT_LIMBS);
let tmp = this.value;
for (let i = 0; i < FOUR_BIT_LIMBS; i++) {
limbs[i] = Number(tmp & 0xfn);
tmp >>= 4n;
}
return limbs;
}
recodeSigned(window: number): Int32Array {
const length = Math.ceil(BIT_LENGTH / window);
const digits = new Int32Array(length);
const twoPowW = 1n << BigInt(window);
const twoPowWMinus1 = 1n << BigInt(window - 1);
let k = this.value;
let i = 0;
while (k > 0n && i < length) {
if (k & 1n) {
let remainder = Number(k % twoPowW);
if (remainder >= Number(twoPowWMinus1)) {
remainder -= Number(twoPowW);
}
digits[i] = remainder;
k -= BigInt(remainder);
}
k >>= 1n;
i++;
}
// remaining digits already zero
return digits;
}
static random(): Scalar {
while (true) {
const buf = randomBytes(BYTE_LENGTH);
let acc = 0n;
for (let i = 0; i < BYTE_LENGTH; i++) {
const byte = buf[i];
if (byte === undefined) throw new Error("Unexpected undefined byte when sampling scalar");
acc |= BigInt(byte) << BigInt(8 * i);
}
if (acc < ORDER) {
return new Scalar(acc);
}
}
}
static fromFp5(element: Fp5): Scalar {
const coeffs = element.toTuple();
const entries = [coeffs[0], coeffs[1], coeffs[2], coeffs[3], coeffs[4]];
let acc = 0n;
for (let i = entries.length - 1; i >= 0; i--) {
const limb = entries[i];
if (!limb) throw new Error("Fp5 tuple missing limb");
acc <<= 64n;
acc |= limb.toBigInt();
}
return new Scalar(acc);
}
}
export const SCALAR_ORDER = ORDER;
+64
View File
@@ -0,0 +1,64 @@
import { randomBytes } from "crypto";
import { Fp } from "./goldilocks";
import { Fp5 } from "./goldilocks-fp5";
import { hashToQuinticExtension } from "./poseidon2";
import { Scalar } from "./scalar";
import { ECPoint } from "./curve";
export interface SchnorrSignature {
s: Scalar;
e: Scalar;
}
export class LighterPrivateKey {
readonly scalar: Scalar;
constructor(scalar: Scalar) {
this.scalar = scalar;
}
static fromBytes(bytes: Uint8Array): LighterPrivateKey {
if (bytes.length !== 40) {
throw new Error("Lighter private key must be 40 bytes");
}
return new LighterPrivateKey(Scalar.fromBytesLE(bytes));
}
static fromHex(hex: string): LighterPrivateKey {
const normalized = hex.startsWith("0x") ? hex.slice(2) : hex;
if (normalized.length !== 80) {
throw new Error("Lighter private key hex must encode 40 bytes");
}
const bytes = Buffer.from(normalized, "hex");
return LighterPrivateKey.fromBytes(bytes);
}
toBytes(): Uint8Array {
return this.scalar.toBytesLE();
}
publicKey(): Fp5 {
const point = ECPoint.generator().mul(this.scalar);
return point.encode();
}
signHashedMessage(hashed: Fp5): SchnorrSignature {
const k = Scalar.random();
const rPoint = ECPoint.generator().mul(k);
const rEncoded = rPoint.encode();
const preimage: Fp[] = [...rEncoded.toTuple(), ...hashed.toTuple()];
const hash = hashToQuinticExtension(preimage);
const e = Scalar.fromFp5(hash);
const s = k.sub(e.mul(this.scalar));
return { s, e };
}
}
export function signatureToBytes(sig: SchnorrSignature): Uint8Array {
const sBytes = sig.s.toBytesLE();
const eBytes = sig.e.toBytesLE();
const out = new Uint8Array(80);
out.set(sBytes, 0);
out.set(eBytes, 40);
return out;
}
+103
View File
@@ -0,0 +1,103 @@
function normalizeDecimalInput(value: number | string | bigint): { sign: 1 | -1; digits: string; fractionLength: number } {
if (typeof value === "bigint") {
const sign = value < 0n ? -1 : 1;
const digits = (sign === -1 ? -value : value).toString();
return { sign, digits, fractionLength: 0 };
}
if (typeof value === "number") {
if (!Number.isFinite(value)) {
throw new Error(`Invalid decimal input: ${value}`);
}
value = value.toString();
}
const trimmed = value.trim();
if (!trimmed.length) throw new Error("Decimal input cannot be empty");
const sign: 1 | -1 = trimmed[0] === "-" ? -1 : 1;
const unsigned = sign === -1 ? trimmed.slice(1) : trimmed;
if (!/^\d*(?:\.\d*)?$/.test(unsigned)) {
throw new Error(`Invalid decimal format: ${value}`);
}
if (unsigned === "" || unsigned === ".") {
return { sign: 1, digits: "0", fractionLength: 0 };
}
const parts = unsigned.split(".");
const integerPartRaw = parts[0] ?? "";
const fractionRaw = parts[1] ?? "";
const integerPart = integerPartRaw.replace(/^0+(?=\d)/, "");
const fractionPart = fractionRaw.replace(/0+$/, "");
if (!integerPart && !fractionPart) {
return { sign: 1, digits: "0", fractionLength: 0 };
}
const digits = `${integerPart || "0"}${fractionPart}`;
return { sign, digits, fractionLength: fractionPart.length };
}
export function decimalToScaled(value: number | string | bigint, decimals: number): bigint {
if (decimals < 0) throw new Error("Decimals must be non-negative");
const { sign, digits, fractionLength } = normalizeDecimalInput(value);
if (fractionLength > decimals) {
const trimLength = fractionLength - decimals;
const truncated = digits.slice(0, digits.length - trimLength) || "0";
const result = BigInt(truncated);
return sign === -1 ? -result : result;
}
const padded = digits.padEnd(digits.length + (decimals - fractionLength), "0");
const result = BigInt(padded);
return sign === -1 ? -result : result;
}
export function scaledToDecimalString(value: bigint | number | string, decimals: number): string {
if (typeof value === "number") {
if (!Number.isFinite(value)) throw new Error(`Invalid scaled number: ${value}`);
value = BigInt(Math.trunc(value));
} else if (typeof value === "string") {
if (!/^[-]?\d+$/.test(value.trim())) {
throw new Error(`Invalid scaled string: ${value}`);
}
value = BigInt(value.trim());
}
const negative = value < 0n;
let abs = negative ? -value : value;
let digits = abs.toString();
if (decimals === 0) {
return negative ? `-${digits}` : digits;
}
if (digits.length <= decimals) {
digits = digits.padStart(decimals + 1, "0");
}
const splitIndex = digits.length - decimals;
const integerPart = digits.slice(0, splitIndex) || "0";
const fractionPart = digits.slice(splitIndex);
const result = `${integerPart}.${fractionPart}`.replace(/\.0+$/, "").replace(/\.$/, "");
return negative ? `-${result}` : result;
}
export function clampToInt64(value: bigint): bigint {
const max = (1n << 63n) - 1n;
const min = -(1n << 63n);
if (value > max || value < min) {
throw new Error("Value exceeds int64 bounds");
}
return value;
}
export function toInt64(value: bigint): number {
const bounded = clampToInt64(value);
return Number(bounded);
}
export function safeNumberToUint32(value: number | bigint): number {
const val = typeof value === "bigint" ? Number(value) : value;
if (!Number.isFinite(val) || val < 0 || val > 0xffffffff) {
throw new Error(`Value ${value} is out of uint32 range`);
}
return Math.trunc(val);
}
export function toSafeNumber(value: bigint | number): number {
const numeric = typeof value === "bigint" ? Number(value) : value;
if (!Number.isSafeInteger(numeric)) {
throw new Error(`Value ${value.toString()} exceeds safe integer range`);
}
return numeric;
}
+17
View File
@@ -0,0 +1,17 @@
import { Fp, GOLDILOCKS_BYTE_LENGTH } from "./crypto/goldilocks";
const CHUNK_SIZE = GOLDILOCKS_BYTE_LENGTH;
export function arrayFromCanonicalLittleEndianBytes(bytes: Uint8Array): Fp[] {
if (!bytes.length) return [];
const remainder = bytes.length % CHUNK_SIZE;
const paddedLength = remainder === 0 ? bytes.length : bytes.length + (CHUNK_SIZE - remainder);
const padded = new Uint8Array(paddedLength);
padded.set(bytes, 0);
const result: Fp[] = [];
for (let offset = 0; offset < padded.length; offset += CHUNK_SIZE) {
const chunk = padded.subarray(offset, offset + CHUNK_SIZE);
result.push(Fp.fromBytesLE(chunk));
}
return result;
}
+683
View File
@@ -0,0 +1,683 @@
import { setInterval, clearInterval, setTimeout, clearTimeout } from "timers";
import WebSocket from "ws";
import type {
AccountListener,
DepthListener,
KlineListener,
OrderListener,
TickerListener,
} from "../adapter";
import type { AsterAccountSnapshot, AsterDepth, AsterKline, AsterOrder, AsterTicker, CreateOrderParams } from "../types";
import { extractMessage } from "../../utils/errors";
import type { OrderSide, OrderType } from "../types";
import { LighterHttpClient } from "./http-client";
import { HttpNonceManager } from "./nonce-manager";
import { LighterSigner, type CreateOrderSignParams } from "./signer";
import { bytesToHex } from "./bytes";
import type {
LighterAccountDetails,
LighterKline,
LighterMarketStats,
LighterOrder,
LighterOrderBookLevel,
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 { decimalToScaled, scaledToDecimalString } from "./decimal";
import { lighterOrderToAster, toAccountSnapshot, toDepth, toKlines, toOrders, toTicker } from "./mappers";
interface SimpleEvent<T> {
add(handler: (value: T) => void): void;
remove(handler: (value: T) => void): void;
emit(value: T): void;
listenerCount(): number;
}
function createEvent<T>(): SimpleEvent<T> {
const listeners = new Set<(value: T) => void>();
return {
add(handler) {
listeners.add(handler);
},
remove(handler) {
listeners.delete(handler);
},
emit(value) {
for (const handler of Array.from(listeners)) {
try {
handler(value);
} catch (error) {
console.error("[LighterGateway] listener error", error);
}
}
},
listenerCount() {
return listeners.size;
},
};
}
interface Pollers {
ticker?: ReturnType<typeof setInterval>;
klines: Map<string, ReturnType<typeof setInterval>>;
}
const KLINE_DEFAULT_COUNT = 120;
const DEFAULT_TICKER_POLL_MS = 3000;
const DEFAULT_KLINE_POLL_MS = 15000;
const RESOLUTION_MS: Record<string, number> = {
"1m": 60_000,
"5m": 300_000,
"15m": 900_000,
"1h": 3_600_000,
"4h": 14_400_000,
"1d": 86_400_000,
};
export interface LighterGatewayOptions {
symbol: string; // display symbol used by strategy logging
marketSymbol?: string; // actual Lighter order book symbol (e.g., BTC)
accountIndex: number;
apiKeys: Record<number, string>;
baseUrl?: string;
environment?: keyof typeof LIGHTER_HOSTS;
marketId?: number;
priceDecimals?: number;
sizeDecimals?: number;
chainId?: number;
apiKeyIndices?: number[];
tickerPollMs?: number;
klinePollMs?: number;
logger?: (context: string, error: unknown) => void;
l1Address?: string;
}
export class LighterGateway {
private readonly displaySymbol: string;
private readonly marketSymbol: string;
private readonly http: LighterHttpClient;
private readonly signer: LighterSigner;
private readonly nonceManager: HttpNonceManager;
private readonly logger: (context: string, error: unknown) => void;
private readonly apiKeyIndices: number[];
private readonly environment: keyof typeof LIGHTER_HOSTS;
private readonly pollers: Pollers = { ticker: undefined, klines: new Map() };
private readonly klineCache = new Map<string, AsterKline[]>();
private readonly accountEvent = createEvent<AsterAccountSnapshot>();
private readonly ordersEvent = createEvent<AsterOrder[]>();
private readonly depthEvent = createEvent<AsterDepth>();
private readonly tickerEvent = createEvent<AsterTicker>();
private readonly klinesEvent = createEvent<AsterKline[]>();
private readonly auth = { token: null as string | null, expiresAt: 0 };
private readonly l1Address: string | null;
private marketId: number | null = null;
private priceDecimals: number | null = null;
private sizeDecimals: number | null = null;
private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private readonly wsUrl: string;
private connectPromise: Promise<void> | null = null;
private accountDetails: LighterAccountDetails | null = null;
private positions: LighterPosition[] = [];
private orders: LighterOrder[] = [];
private orderBook: LighterOrderBookSnapshot | null = null;
private ticker: LighterMarketStats | null = null;
private initialized = false;
private readonly tickerPollMs: number;
private readonly klinePollMs: number;
constructor(options: LighterGatewayOptions) {
this.displaySymbol = options.symbol;
this.marketSymbol = (options.marketSymbol ?? options.symbol).toUpperCase();
this.environment = options.environment ?? DEFAULT_LIGHTER_ENVIRONMENT;
const host = options.baseUrl ?? LIGHTER_HOSTS[this.environment]?.rest;
if (!host) {
throw new Error(`Unknown Lighter environment ${this.environment}`);
}
const wsHost = LIGHTER_HOSTS[this.environment]?.ws;
if (!wsHost) {
throw new Error(`WebSocket endpoint not configured for env ${this.environment}`);
}
this.wsUrl = wsHost;
this.http = new LighterHttpClient({ baseUrl: host });
this.signer = new LighterSigner({
accountIndex: options.accountIndex,
chainId: options.chainId ?? (this.environment === "mainnet" ? 304 : 300),
apiKeys: options.apiKeys,
});
this.apiKeyIndices = options.apiKeyIndices ?? Object.keys(options.apiKeys).map(Number);
this.nonceManager = new HttpNonceManager({
accountIndex: options.accountIndex,
apiKeyIndices: this.apiKeyIndices,
http: this.http,
});
this.logger = options.logger ?? ((context, error) => console.error(`[LighterGateway] ${context}`, error));
this.marketId = options.marketId != null ? Number(options.marketId) : null;
this.priceDecimals = options.priceDecimals ?? null;
this.sizeDecimals = options.sizeDecimals ?? null;
this.tickerPollMs = options.tickerPollMs ?? DEFAULT_TICKER_POLL_MS;
this.klinePollMs = options.klinePollMs ?? DEFAULT_KLINE_POLL_MS;
this.l1Address = options.l1Address ?? null;
}
async ensureInitialized(): Promise<void> {
if (this.initialized) return;
if (!this.connectPromise) {
this.connectPromise = this.initialize().catch((error) => {
this.connectPromise = null;
throw error;
});
}
await this.connectPromise;
this.initialized = true;
}
onAccount(handler: AccountListener): void {
this.accountEvent.add(handler);
}
onOrders(handler: OrderListener): void {
this.ordersEvent.add(handler);
}
onDepth(handler: DepthListener): void {
this.depthEvent.add(handler);
}
onTicker(handler: TickerListener): void {
this.tickerEvent.add(handler);
}
onKlines(handler: KlineListener): void {
this.klinesEvent.add(handler);
}
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
await this.ensureInitialized();
const conversion = this.mapCreateOrderParams(params);
const { baseAmountScaledString, priceScaledString, triggerPriceScaledString, ...signParams } = conversion;
const { apiKeyIndex, nonce } = this.nonceManager.next();
try {
const signed = this.signer.signCreateOrder({
...signParams,
apiKeyIndex,
nonce,
});
const auth = await this.ensureAuthToken();
await this.http.sendTransaction(signed.txType, signed.txInfo, { authToken: auth });
return lighterOrderToAster(this.displaySymbol, {
order_index: Number(signParams.clientOrderIndex % 1_000_000_000n),
client_order_index: Number(signParams.clientOrderIndex),
market_index: signParams.marketIndex,
initial_base_amount: baseAmountScaledString,
remaining_base_amount: baseAmountScaledString,
price: priceScaledString,
trigger_price: triggerPriceScaledString,
is_ask: signParams.isAsk === 1,
side: signParams.isAsk === 1 ? "sell" : "buy",
type: params.type?.toLowerCase(),
reduce_only: signParams.reduceOnly === 1,
status: "NEW",
created_at: Date.now(),
} as LighterOrder);
} catch (error) {
this.nonceManager.acknowledgeFailure(apiKeyIndex);
throw error;
}
}
async cancelOrder(params: { marketIndex?: number; orderId: number | string; apiKeyIndex?: number }): Promise<void> {
await this.ensureInitialized();
const marketIndex = params.marketIndex ?? this.marketId;
if (marketIndex == null) throw new Error("Market index unknown");
const indexValue = BigInt(typeof params.orderId === "string" ? Number(params.orderId) : params.orderId);
const { apiKeyIndex, nonce } = this.nonceManager.next();
try {
const signed = this.signer.signCancelOrder({
marketIndex,
orderIndex: indexValue,
nonce,
apiKeyIndex,
});
const auth = await this.ensureAuthToken();
await this.http.sendTransaction(signed.txType, signed.txInfo, { authToken: auth });
} catch (error) {
this.nonceManager.acknowledgeFailure(apiKeyIndex);
throw error;
}
}
async cancelAllOrders(params?: { timeInForce?: number; scheduleMs?: number; apiKeyIndex?: number }): Promise<void> {
await this.ensureInitialized();
const timeInForce = params?.timeInForce ?? 0;
const time = params?.scheduleMs != null ? BigInt(params.scheduleMs) : 0n;
const { apiKeyIndex, nonce } = this.nonceManager.next();
try {
const signed = this.signer.signCancelAll({
timeInForce,
scheduledTime: time,
nonce,
apiKeyIndex,
});
const auth = await this.ensureAuthToken();
await this.http.sendTransaction(signed.txType, signed.txInfo, { authToken: auth });
} catch (error) {
this.nonceManager.acknowledgeFailure(apiKeyIndex);
throw error;
}
}
private async initialize(): Promise<void> {
await this.loadMetadata();
await this.nonceManager.init(true);
await this.refreshAccountSnapshot();
await this.openWebSocket();
// Emit an initial empty orders snapshot so strategies depending on an order
// snapshot at startup can proceed even if the websocket does not publish
// orders until there is activity.
this.emitOrders();
this.startPolling();
}
private async loadMetadata(): Promise<void> {
if (this.marketId != null && this.priceDecimals != null && this.sizeDecimals != null) return;
const books = await this.http.getOrderBooks();
const desiredSymbol = this.marketSymbol;
let target = books.find((book) => (book.symbol ? String(book.symbol).toUpperCase() : "") === desiredSymbol);
if (!target && this.marketId != null) {
target = books.find((book) => Number(book.market_id) === Number(this.marketId));
}
if (!target) {
if (this.marketId != null && this.priceDecimals != null && this.sizeDecimals != null) {
return;
}
throw new Error(`Symbol ${desiredSymbol} not listed on Lighter order books`);
}
this.marketId = Number(target.market_id);
if (this.priceDecimals == null) {
this.priceDecimals = target.supported_price_decimals;
}
if (this.sizeDecimals == null) {
this.sizeDecimals = target.supported_size_decimals;
}
}
private async refreshAccountSnapshot(): Promise<void> {
try {
const auth = await this.ensureAuthToken();
let details: LighterAccountDetails | null = null;
if (this.l1Address) {
details = await this.http.getAccountDetails(Number(this.signer.accountIndex), auth, {
by: "l1_address",
value: this.l1Address,
});
}
if (!details) {
details = await this.http.getAccountDetails(Number(this.signer.accountIndex), auth, {
by: "index",
value: Number(this.signer.accountIndex),
});
}
if (details) {
this.accountDetails = details;
this.emitAccount();
} else {
// Fallback: emit an empty account snapshot so strategies can proceed
this.accountDetails = {
account_index: Number(this.signer.accountIndex),
status: 1,
collateral: "0",
available_balance: "0",
} as LighterAccountDetails;
this.positions = [];
this.emitAccount();
}
} catch (error) {
this.logger("refreshAccount", error);
}
}
private async openWebSocket(): Promise<void> {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
return;
}
await new Promise<void>((resolve, reject) => {
const ws = new WebSocket(this.wsUrl);
this.ws = ws;
const cleanup = () => {
ws.removeAllListeners();
};
ws.on("open", async () => {
try {
await this.subscribeChannels();
resolve();
} catch (error) {
reject(error);
}
});
ws.on("message", (data) => this.handleMessage(data));
ws.on("close", (code, reason) => {
cleanup();
this.scheduleReconnect();
});
ws.on("error", (error) => {
cleanup();
this.logger("ws:error", error);
});
});
}
private async subscribeChannels(): Promise<void> {
const ws = this.ws;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const marketId = this.marketId;
if (marketId == null) throw new Error("Market ID unknown");
ws.send(JSON.stringify({ type: "subscribe", channel: `order_book/${marketId}` }));
ws.send(JSON.stringify({ type: "subscribe", channel: `account_all/${Number(this.signer.accountIndex)}` }));
const auth = await this.ensureAuthToken();
ws.send(
JSON.stringify({
type: "subscribe",
channel: `account_all_orders/${Number(this.signer.accountIndex)}`,
auth,
})
);
}
private async ensureAuthToken(): Promise<string> {
const now = Date.now();
if (this.auth.token && now < this.auth.expiresAt - DEFAULT_AUTH_TOKEN_BUFFER_MS) {
return this.auth.token;
}
const deadline = now + 10 * 60 * 1000; // 10 minutes horizon
const token = this.signer.createAuthToken(deadline);
this.auth.token = token;
this.auth.expiresAt = deadline;
return token;
}
private scheduleReconnect(): void {
if (this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.openWebSocket().catch((error) => this.logger("reconnect", error));
}, 2000);
}
private handleMessage(data: WebSocket.RawData): void {
try {
const text = typeof data === "string" ? data : data.toString("utf8");
const message = JSON.parse(text);
const type = message?.type;
switch (type) {
case "connected":
break;
case "subscribed/order_book":
this.handleOrderBookSnapshot(message);
break;
case "update/order_book":
this.handleOrderBookUpdate(message);
break;
case "subscribed/account_all":
case "update/account_all":
this.handleAccountAll(message);
break;
case "subscribed/account_all_orders":
case "update/account_all_orders":
this.handleAccountOrders(message);
break;
default:
break;
}
} catch (error) {
this.logger("ws:message", error);
}
}
private handleOrderBookSnapshot(message: any): void {
if (!message?.order_book) return;
const snapshot: LighterOrderBookSnapshot = {
market_id: this.marketId ?? 0,
offset: message.order_book.offset ?? Date.now(),
bids: message.order_book.bids ?? [],
asks: message.order_book.asks ?? [],
};
this.orderBook = snapshot;
this.emitDepth();
}
private handleOrderBookUpdate(message: any): void {
if (!this.orderBook) return;
const update = message?.order_book;
if (!update) return;
if (Array.isArray(update.asks)) {
this.orderBook.asks = mergeLevels(this.orderBook.asks ?? [], update.asks);
}
if (Array.isArray(update.bids)) {
this.orderBook.bids = mergeLevels(this.orderBook.bids ?? [], update.bids);
}
this.orderBook.offset = update.offset ?? this.orderBook.offset;
this.emitDepth();
}
private handleAccountAll(message: any): void {
if (!message) return;
const positionsObject = message.positions ?? {};
const positions: LighterPosition[] = Object.values(positionsObject) as LighterPosition[];
this.positions = positions;
this.emitAccount();
}
private handleAccountOrders(message: any): void {
if (!message) return;
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);
this.ordersEvent.emit(mapped);
}
private emitDepth(): void {
if (!this.orderBook || this.marketId == null) return;
const depth = toDepth(this.displaySymbol, this.orderBook);
this.depthEvent.emit(depth);
}
private emitAccount(): void {
if (!this.accountDetails) return;
const snapshot = toAccountSnapshot(this.displaySymbol, this.accountDetails, this.positions);
this.accountEvent.emit(snapshot);
}
private emitOrders(): void {
const mapped = toOrders(this.displaySymbol, this.orders ?? []);
this.ordersEvent.emit(mapped);
}
private startPolling(): void {
if (!this.pollers.ticker) {
this.pollers.ticker = setInterval(() => {
this.refreshTicker().catch((error) => this.logger("ticker", error));
}, this.tickerPollMs);
void this.refreshTicker();
}
}
private async refreshTicker(): Promise<void> {
try {
const stats = await this.http.getExchangeStats();
const marketId = this.marketId;
if (marketId == null) return;
const match = stats.find(
(entry) => Number(entry.market_id) === marketId || (entry.symbol ? entry.symbol.toUpperCase() : "") === this.marketSymbol
);
if (!match) return;
const ticker = toTicker(this.displaySymbol, match);
this.tickerEvent.emit(ticker);
} catch (error) {
this.logger("refreshTicker", error);
}
}
watchKlines(interval: string, handler: KlineListener): void {
this.klinesEvent.add(handler);
const cached = this.klineCache.get(interval);
if (cached) {
handler(cloneKlines(cached));
}
const existing = this.pollers.klines.get(interval);
if (!existing) {
const poll = () => {
void this.refreshKlines(interval).catch((error) => this.logger("klines", error));
};
const timer = setInterval(poll, this.klinePollMs);
this.pollers.klines.set(interval, timer);
poll();
}
}
private async refreshKlines(interval: string): Promise<void> {
await this.ensureInitialized();
const marketId = this.marketId;
if (marketId == null) return;
const resolutionMs = RESOLUTION_MS[interval];
if (!resolutionMs) return;
const end = Date.now();
const count = Math.max(KLINE_DEFAULT_COUNT, 200);
const start = end - resolutionMs * count;
const startTs = Math.max(0, Math.floor(start));
const endTs = Math.max(startTs + resolutionMs, Math.floor(end));
const raw = await this.http.getCandlesticks({
marketId,
resolution: interval,
countBack: count,
endTimestamp: endTs,
startTimestamp: startTs,
setTimestampToEnd: true,
});
const mapped = toKlines(this.displaySymbol, interval, raw as LighterKline[]);
this.klineCache.set(interval, mapped);
this.klinesEvent.emit(cloneKlines(mapped));
}
private mapCreateOrderParams(params: CreateOrderParams): Omit<CreateOrderSignParams, "nonce"> & {
baseAmountScaledString: string;
priceScaledString: string;
triggerPriceScaledString: string;
clientOrderIndex: bigint;
} {
if (this.marketId == null || this.priceDecimals == null || this.sizeDecimals == null) {
throw new Error("Lighter market metadata not initialized");
}
if (params.quantity == null || !Number.isFinite(params.quantity)) {
throw new Error("Lighter orders require quantity");
}
const side = params.side;
const isAsk = side === "SELL" ? 1 : 0;
const baseAmount = decimalToScaled(params.quantity, this.sizeDecimals);
const baseAmountScaledString = scaledToDecimalString(baseAmount, this.sizeDecimals);
const clientOrderIndex = BigInt(Date.now() % Number.MAX_SAFE_INTEGER);
let priceScaled = params.price != null ? decimalToScaled(params.price, this.priceDecimals) : null;
if (params.type === "MARKET" && priceScaled == null) {
priceScaled = decimalToScaled(this.estimateMarketPrice(side), this.priceDecimals);
}
if (priceScaled == null) {
throw new Error("Lighter order requires price");
}
const reduceOnly = params.reduceOnly === "true" || params.closePosition === "true" ? 1 : 0;
const resultType = mapOrderType(params.type ?? "LIMIT");
const resultTimeInForce = mapTimeInForce(params.timeInForce, params.type ?? "LIMIT");
let triggerPriceScaled = 0n;
if (params.stopPrice != null) {
triggerPriceScaled = decimalToScaled(params.stopPrice, this.priceDecimals);
}
const orderExpiry = resultTimeInForce === LIGHTER_TIME_IN_FORCE.IMMEDIATE_OR_CANCEL
? BigInt(IMMEDIATE_OR_CANCEL_EXPIRY_PLACEHOLDER)
: BigInt(DEFAULT_ORDER_EXPIRY_PLACEHOLDER);
return {
marketIndex: this.marketId,
clientOrderIndex,
baseAmount,
baseAmountScaledString,
price: Number(priceScaled),
priceScaledString: scaledToDecimalString(priceScaled, this.priceDecimals),
isAsk,
orderType: resultType,
timeInForce: resultTimeInForce,
reduceOnly,
triggerPrice: Number(triggerPriceScaled),
triggerPriceScaledString: scaledToDecimalString(triggerPriceScaled, this.priceDecimals),
orderExpiry,
expiredAt: BigInt(Date.now() + 10 * 60 * 1000),
};
}
private estimateMarketPrice(side: OrderSide): number {
if (this.orderBook) {
const levels = side === "SELL" ? this.orderBook.bids : this.orderBook.asks;
if (levels && levels.length) {
const sorted = [...levels].sort((a, b) => {
const aPrice = Number(a.price);
const bPrice = Number(b.price);
return side === "SELL" ? bPrice - aPrice : aPrice - bPrice;
});
const level = sorted[0];
if (level) return Number(level.price);
}
}
if (this.ticker) {
return Number(this.ticker.last_trade_price);
}
throw new Error("Unable to determine market price for order");
}
}
function mergeLevels(existing: LighterOrderBookLevel[], updates: LighterOrderBookLevel[]): LighterOrderBookLevel[] {
const map = new Map<string, string>();
for (const level of existing) {
map.set(level.price, level.size);
}
for (const update of updates) {
if (Number(update.size) <= 0) {
map.delete(update.price);
} else {
map.set(update.price, update.size);
}
}
return Array.from(map.entries()).map(([price, size]) => ({ price, size } as LighterOrderBookLevel));
}
function cloneKlines(klines: AsterKline[]): AsterKline[] {
return klines.map((kline) => ({ ...kline }));
}
function mapOrderType(type: OrderType): number {
switch (type) {
case "MARKET":
return LIGHTER_ORDER_TYPE.MARKET;
case "STOP_MARKET":
return LIGHTER_ORDER_TYPE.STOP_LOSS;
default:
return LIGHTER_ORDER_TYPE.LIMIT;
}
}
function mapTimeInForce(timeInForce: string | undefined, type: OrderType): number {
const value = (timeInForce ?? (type === "MARKET" ? "IOC" : "GTC")).toUpperCase();
switch (value) {
case "IOC":
return LIGHTER_TIME_IN_FORCE.IMMEDIATE_OR_CANCEL;
case "GTX":
return LIGHTER_TIME_IN_FORCE.POST_ONLY;
default:
return LIGHTER_TIME_IN_FORCE.GOOD_TILL_TIME;
}
}
+291
View File
@@ -0,0 +1,291 @@
import type {
LighterAccountDetails,
LighterKline,
LighterMarketStats,
LighterOrderBookMetadata,
} from "./types";
import { DEFAULT_LIGHTER_ENVIRONMENT, LIGHTER_HOSTS } from "./constants";
interface ApiResponseBase {
code: number;
message?: string | null;
}
interface OrderBooksResponse extends ApiResponseBase {
order_books?: LighterOrderBookMetadata[];
}
interface ExchangeStatsResponse extends ApiResponseBase {
order_book_stats?: Array<{
market_id: number;
index_price?: string;
mark_price?: string;
last_trade_price: string;
open_interest?: string;
daily_base_token_volume?: number;
daily_quote_token_volume?: number;
daily_price_low?: number;
daily_price_high?: number;
daily_price_change?: number;
symbol: string;
funding_rate?: string;
funding_timestamp?: number;
current_funding_rate?: string;
}>;
}
interface NextNonceResponse extends ApiResponseBase {
nonce: number;
}
interface SendTxResponse extends ApiResponseBase {
tx_hash: string;
predicted_execution_time_ms?: number;
}
interface CandlesticksResponse extends ApiResponseBase {
resolution: string;
candlesticks: Array<{
start_timestamp?: number;
end_timestamp?: number;
timestamp?: number;
open: number | string;
high: number | string;
low: number | string;
close: number | string;
base_token_volume: number | string;
quote_token_volume: number | string;
trades?: number;
}>;
}
interface AccountResponse extends ApiResponseBase {
account?: LighterAccountDetails;
}
export interface LighterHttpClientOptions {
baseUrl?: string;
environment?: keyof typeof LIGHTER_HOSTS;
priceProtection?: boolean;
fetcher?: typeof fetch;
}
export class LighterHttpClient {
readonly baseUrl: string;
private readonly priceProtection: boolean;
private readonly fetcher: typeof fetch;
constructor(options: LighterHttpClientOptions = {}) {
const env = options.environment ?? DEFAULT_LIGHTER_ENVIRONMENT;
const host = options.baseUrl ?? LIGHTER_HOSTS[env]?.rest;
if (!host) {
throw new Error(`Unknown Lighter environment: ${env}`);
}
this.baseUrl = host.replace(/\/$/, "");
this.priceProtection = options.priceProtection ?? true;
this.fetcher = options.fetcher ?? globalThis.fetch.bind(globalThis);
if (!this.fetcher) {
throw new Error("Global fetch is not available; provide a custom fetch implementation");
}
}
async getOrderBooks(): Promise<LighterOrderBookMetadata[]> {
const response = await this.get<OrderBooksResponse>("/api/v1/orderBooks");
return response.order_books ?? [];
}
async getExchangeStats(): Promise<LighterMarketStats[]> {
const response = await this.get<ExchangeStatsResponse>("/api/v1/exchangeStats");
const stats = response.order_book_stats ?? [];
return stats.map((entry) => ({
market_id: entry.market_id,
symbol: entry.symbol,
index_price: entry.index_price ?? entry.mark_price ?? entry.last_trade_price,
mark_price: entry.mark_price ?? entry.last_trade_price,
last_trade_price: entry.last_trade_price,
open_interest: entry.open_interest ?? "0",
daily_base_token_volume: entry.daily_base_token_volume,
daily_quote_token_volume: entry.daily_quote_token_volume,
daily_price_low: entry.daily_price_low,
daily_price_high: entry.daily_price_high,
daily_price_change: entry.daily_price_change,
current_funding_rate: entry.current_funding_rate,
funding_rate: entry.funding_rate,
funding_timestamp: entry.funding_timestamp,
}));
}
async getAccountDetails(
accountIndex: number,
authToken?: string,
options: { by?: "index" | "l1_address"; value?: string | number } = {}
): Promise<LighterAccountDetails | null> {
const query: Record<string, unknown> = {};
if (authToken) {
query.auth = authToken;
}
const by = options.by ?? "index";
query.by = by;
if (options.value !== undefined) {
query.value = options.value;
} else if (by === "index") {
query.value = accountIndex;
}
const response = await this.get<AccountResponse>("/api/v1/account", {
query,
headers: authToken ? { Authorization: authToken } : undefined,
tolerateNotFound: true,
});
return response.account ?? null;
}
async getCandlesticks(params: {
marketId: number;
resolution: string;
countBack: number;
startTimestamp: number;
endTimestamp: number;
setTimestampToEnd?: boolean;
}): Promise<LighterKline[]> {
const response = await this.get<CandlesticksResponse>("/api/v1/candlesticks", {
query: {
market_id: params.marketId,
resolution: params.resolution,
count_back: params.countBack,
start_timestamp: params.startTimestamp,
end_timestamp: params.endTimestamp,
set_timestamp_to_end: params.setTimestampToEnd ?? true,
},
});
return (response.candlesticks ?? []).map((entry) => ({
start_timestamp: entry.start_timestamp ?? entry.timestamp ?? 0,
end_timestamp:
entry.end_timestamp ??
(entry.start_timestamp ?? entry.timestamp ?? 0) + 1,
open: String(entry.open ?? 0),
high: String(entry.high ?? 0),
low: String(entry.low ?? 0),
close: String(entry.close ?? 0),
base_token_volume: String(entry.base_token_volume ?? 0),
quote_token_volume: String(entry.quote_token_volume ?? 0),
trades: entry.trades,
}));
}
async getNextNonce(accountIndex: number, apiKeyIndex: number): Promise<bigint> {
const response = await this.get<NextNonceResponse>("/api/v1/nextNonce", {
query: {
account_index: accountIndex,
api_key_index: apiKeyIndex,
},
});
if (typeof response.nonce !== "number") {
throw new Error("Lighter nextNonce response missing nonce");
}
return BigInt(response.nonce);
}
async sendTransaction(
txType: number,
txInfo: string,
options: { priceProtection?: boolean; authToken?: string } = {}
): Promise<SendTxResponse> {
const form = new FormData();
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));
return this.postForm<SendTxResponse>("/api/v1/sendTx", form, options.authToken);
}
private async get<T extends ApiResponseBase>(
path: string,
options: {
query?: Record<string, unknown>;
headers?: Record<string, string | undefined>;
tolerateNotFound?: boolean;
} = {}
): Promise<T> {
const url = new URL(path, `${this.baseUrl}`);
if (options.query) {
for (const [key, value] of Object.entries(options.query)) {
if (value === undefined || value === null) continue;
url.searchParams.set(key, String(value));
}
}
const requestUrl = url.toString();
const response = await this.fetcher(requestUrl, {
method: "GET",
headers: {
"Accept": "application/json",
...this.cleanHeaders(options.headers),
},
});
if (options.tolerateNotFound && response.status === 404) {
return { code: 404 } as T;
}
return this.parseResponse<T>(response, requestUrl);
}
private async post<T extends ApiResponseBase>(path: string, body: unknown, authToken?: string): Promise<T> {
const requestUrl = new URL(path, `${this.baseUrl}`).toString();
const response = await this.fetcher(requestUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...(authToken ? { Authorization: authToken } : {}),
},
body: JSON.stringify(body),
});
return this.parseResponse<T>(response, requestUrl);
}
private async postForm<T extends ApiResponseBase>(path: string, form: FormData, authToken?: string): Promise<T> {
const requestUrl = new URL(path, `${this.baseUrl}`).toString();
const response = await this.fetcher(requestUrl, {
method: "POST",
headers: {
Accept: "application/json",
...(authToken ? { Authorization: authToken } : {}),
},
body: form as any,
});
return this.parseResponse<T>(response, requestUrl);
}
private cleanHeaders(headers?: Record<string, string | undefined>): Record<string, string> | undefined {
if (!headers) return undefined;
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
if (value) result[key] = value;
}
return Object.keys(result).length ? result : undefined;
}
private async parseResponse<T extends ApiResponseBase>(response: Response, requestUrl: string): Promise<T> {
const text = await response.text();
if (!response.ok) {
const snippet = text ? truncateBody(text) : response.statusText;
throw new Error(`Lighter HTTP ${response.status} ${response.statusText} (${requestUrl}): ${snippet}`);
}
if (!text) {
throw new Error(`Empty Lighter response body (${requestUrl})`);
}
let parsed: T;
try {
parsed = JSON.parse(text) as T;
} catch (error) {
throw new Error(`Failed to parse Lighter response (${requestUrl}): ${String(error)}. Body: ${truncateBody(text)}`);
}
if (typeof parsed.code === "number" && parsed.code !== 200) {
throw new Error(parsed.message ?? `Lighter API returned code ${parsed.code} (${requestUrl})`);
}
return parsed;
}
}
function truncateBody(body: string, limit = 200): string {
return body.length > limit ? `${body.slice(0, limit)}` : body;
}
+187
View File
@@ -0,0 +1,187 @@
import type {
AsterAccountAsset,
AsterAccountPosition,
AsterAccountSnapshot,
AsterDepth,
AsterDepthLevel,
AsterKline,
AsterOrder,
AsterTicker,
OrderSide,
OrderType,
} from "../types";
import type {
LighterAccountDetails,
LighterKline,
LighterMarketStats,
LighterOrder,
LighterOrderBookLevel,
LighterOrderBookSnapshot,
LighterPosition,
} from "./types";
export function toDepth(symbol: string, snapshot: LighterOrderBookSnapshot): AsterDepth {
const toLevels = (levels: LighterOrderBookLevel[]): AsterDepthLevel[] =>
levels.map((level) => [level.price, level.size]);
return {
symbol,
lastUpdateId: snapshot.offset ?? Date.now(),
bids: toLevels(snapshot.bids ?? []),
asks: toLevels(snapshot.asks ?? []),
eventTime: Date.now(),
eventType: "lighterDepth",
};
}
export function toTicker(symbol: string, stats: LighterMarketStats): AsterTicker {
return {
symbol,
eventType: "lighterTicker",
eventTime: Date.now(),
lastPrice: stats.last_trade_price,
openPrice: stats.daily_price_low != null ? String(stats.daily_price_low) : stats.last_trade_price,
highPrice: stats.daily_price_high != null ? String(stats.daily_price_high) : stats.last_trade_price,
lowPrice: stats.daily_price_low != null ? String(stats.daily_price_low) : stats.last_trade_price,
volume: stats.daily_base_token_volume != null ? String(stats.daily_base_token_volume) : "0",
quoteVolume: stats.daily_quote_token_volume != null ? String(stats.daily_quote_token_volume) : "0",
priceChange: stats.daily_price_change != null ? String(stats.daily_price_change) : undefined,
markPrice: undefined,
weightedAvgPrice: undefined,
} as AsterTicker;
}
export function toKlines(symbol: string, interval: string, klines: LighterKline[]): AsterKline[] {
return klines.map((entry) => ({
symbol,
eventType: "lighterKline",
eventTime: Date.now(),
interval,
openTime: entry.start_timestamp,
closeTime: entry.end_timestamp,
open: entry.open,
high: entry.high,
low: entry.low,
close: entry.close,
volume: entry.base_token_volume,
quoteAssetVolume: entry.quote_token_volume,
numberOfTrades: entry.trades ?? 0,
isClosed: true,
}));
}
export function toOrders(symbol: string, orders: LighterOrder[]): AsterOrder[] {
return orders.map((order) => lighterOrderToAster(symbol, order));
}
export function lighterOrderToAster(symbol: string, order: LighterOrder): AsterOrder {
const side: OrderSide = order.is_ask || order.side?.toLowerCase() === "sell" || order.side?.toLowerCase() === "ask"
? "SELL"
: "BUY";
return {
orderId: order.order_index,
clientOrderId: String(order.client_order_index ?? order.order_index ?? ""),
symbol,
side,
type: mapOrderType(order.type),
status: order.status ?? order.trigger_status ?? "UNKNOWN",
price: order.price ?? "0",
origQty: order.initial_base_amount ?? "0",
executedQty: computeExecutedQty(order),
stopPrice: order.trigger_price ?? "0",
time: order.created_at ?? Date.now(),
updateTime: order.updated_at ?? Date.now(),
reduceOnly: Boolean(order.reduce_only),
closePosition: Boolean(order.reduce_only ?? order.owner_account_index === undefined ? false : order.is_ask),
workingType: "MARK_PRICE",
activationPrice: order.trigger_price,
};
}
function computeExecutedQty(order: LighterOrder): string {
if (order.filled_base_amount) return order.filled_base_amount;
if (order.initial_base_amount && order.remaining_base_amount) {
try {
const initial = Number(order.initial_base_amount);
const remaining = Number(order.remaining_base_amount);
if (Number.isFinite(initial) && Number.isFinite(remaining)) {
return (initial - remaining).toString();
}
} catch (_) {
// fall through
}
}
return "0";
}
function mapOrderType(value?: string): OrderType {
if (!value) return "LIMIT";
const normalized = value.toLowerCase();
switch (normalized) {
case "limit":
return "LIMIT";
case "market":
return "MARKET";
case "stop_loss":
case "stop_loss_market":
return "STOP_MARKET";
case "stop_loss_limit":
return "LIMIT";
case "take_profit":
case "take_profit_market":
return "STOP_MARKET";
case "take_profit_limit":
return "LIMIT";
default:
return "LIMIT";
}
}
export function toAccountSnapshot(
symbol: string,
details: LighterAccountDetails,
positions: LighterPosition[] = [],
assets: AsterAccountAsset[] = []
): AsterAccountSnapshot {
const transformedPositions = positions.map((position) => lighterPositionToAster(symbol, position));
const aggregateUnrealized = transformedPositions.reduce((acc, pos) => acc + Number(pos.unrealizedProfit ?? 0), 0);
const assetList = assets.length ? assets : defaultAsset(details);
return {
canTrade: details.status !== 0,
canDeposit: true,
canWithdraw: true,
updateTime: Date.now(),
totalWalletBalance: details.collateral ?? "0",
totalUnrealizedProfit: aggregateUnrealized.toFixed(8),
positions: transformedPositions,
assets: assetList,
};
}
function defaultAsset(details: LighterAccountDetails): AsterAccountAsset[] {
return [
{
asset: "USDC",
walletBalance: details.collateral ?? "0",
availableBalance: details.available_balance ?? details.collateral ?? "0",
updateTime: Date.now(),
},
];
}
function lighterPositionToAster(symbol: string, position: LighterPosition): AsterAccountPosition {
const sign = position.sign ?? 0;
const positionSide = sign > 0 ? "LONG" : sign < 0 ? "SHORT" : "BOTH";
return {
symbol: position.symbol ?? symbol,
positionAmt: position.position ?? "0",
entryPrice: position.avg_entry_price ?? "0",
unrealizedProfit: position.unrealized_pnl ?? "0",
positionSide,
updateTime: Date.now(),
liquidationPrice: position.liquidation_price,
maintMargin: undefined,
initialMargin: position.allocated_margin,
marginType: position.margin_mode === 1 ? "ISOLATED" : "CROSS",
markPrice: undefined,
};
}
+89
View File
@@ -0,0 +1,89 @@
import { LighterHttpClient } from "./http-client";
import type { LighterNonceManager } from "./types";
interface NonceSlot {
apiKeyIndex: number;
next: bigint;
lastIssued: bigint | null;
}
export interface NonceManagerOptions {
accountIndex: number;
apiKeyIndices: number[];
http: LighterHttpClient;
}
export class HttpNonceManager implements LighterNonceManager {
private readonly accountIndex: number;
private readonly apiKeyIndices: number[];
private readonly http: LighterHttpClient;
private readonly slots = new Map<number, NonceSlot>();
private pointer = 0;
private initPromise: Promise<void> | null = null;
constructor(options: NonceManagerOptions) {
if (!options.apiKeyIndices.length) {
throw new Error("Nonce manager requires at least one API key index");
}
this.accountIndex = options.accountIndex;
this.apiKeyIndices = Array.from(new Set(options.apiKeyIndices)).sort((a, b) => a - b);
this.http = options.http;
}
async init(force = false): Promise<void> {
if (!this.initPromise || force) {
this.initPromise = this.refreshAll(force).catch((error) => {
this.initPromise = null;
throw error;
});
}
await this.initPromise;
}
next(): { apiKeyIndex: number; nonce: bigint } {
if (!this.slots.size) {
throw new Error("Nonce manager not initialized");
}
const slot = this.pickSlot();
const nonce = slot.next;
slot.lastIssued = nonce;
slot.next = nonce + 1n;
return { apiKeyIndex: slot.apiKeyIndex, nonce };
}
acknowledgeFailure(apiKeyIndex: number): void {
const slot = this.slots.get(apiKeyIndex);
if (!slot || slot.lastIssued === null) return;
slot.next = slot.lastIssued;
slot.lastIssued = null;
}
async refresh(apiKeyIndex: number): Promise<void> {
const nonce = await this.http.getNextNonce(this.accountIndex, apiKeyIndex);
this.slots.set(apiKeyIndex, { apiKeyIndex, next: nonce, lastIssued: null });
}
private async refreshAll(force: boolean): Promise<void> {
await Promise.all(
this.apiKeyIndices.map(async (index) => {
if (!force && this.slots.has(index)) return;
await this.refresh(index);
})
);
this.pointer = 0;
}
private pickSlot(): NonceSlot {
const total = this.apiKeyIndices.length;
if (total === 0) {
throw new Error("Nonce manager not initialized");
}
const index = this.apiKeyIndices[this.pointer % total]!;
this.pointer = (this.pointer + 1) % total;
const slot = this.slots.get(index);
if (!slot) {
throw new Error(`Nonce slot for API key index ${index} is not initialized`);
}
return slot;
}
}
+327
View File
@@ -0,0 +1,327 @@
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;
}
export interface LighterSignerConfig {
accountIndex: number | bigint;
chainId: number;
apiKeys: Record<number, string>;
}
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;
}
export class LighterSigner {
readonly accountIndex: bigint;
readonly chainId: number;
private readonly keys = new Map<number, LighterPrivateKey>();
private readonly defaultKeyIndex: number;
constructor(config: LighterSignerConfig) {
if (!config || typeof config !== "object") {
throw new Error("LighterSigner requires configuration");
}
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),
}));
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.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 };
}
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,
marketIndex: params.marketIndex,
clientOrderIndex: params.clientOrderIndex,
baseAmount: params.baseAmount,
price: params.price,
isAsk: params.isAsk,
orderType: params.orderType,
timeInForce: params.timeInForce,
reduceOnly: params.reduceOnly,
triggerPrice: params.triggerPrice,
orderExpiry: params.orderExpiry,
});
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),
});
return {
txType: LIGHTER_TX_TYPE.CREATE_ORDER,
txInfo,
txHash: bytesToHex(hash.toBytes()),
signature: BASE64_ENCODE(signatureBytes),
};
}
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,
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),
});
return {
txType: LIGHTER_TX_TYPE.CANCEL_ORDER,
txInfo,
txHash: bytesToHex(hash.toBytes()),
signature: BASE64_ENCODE(signatureBytes),
};
}
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,
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),
});
return {
txType: LIGHTER_TX_TYPE.CANCEL_ALL_ORDERS,
txInfo,
txHash: bytesToHex(hash.toBytes()),
signature: BASE64_ENCODE(signatureBytes),
};
}
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}`;
}
}
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);
}
+194
View File
@@ -0,0 +1,194 @@
export type LighterSide = "buy" | "sell" | "ask" | "bid" | string;
export type LighterOrderType =
| "limit"
| "market"
| "stop_loss"
| "stop_loss_limit"
| "take_profit"
| "take_profit_limit"
| string;
export interface LighterOrder {
order_index: number;
client_order_index: number;
order_id?: string;
client_order_id?: string;
market_index: number;
owner_account_index?: number;
initial_base_amount: string;
remaining_base_amount: string;
filled_base_amount?: string;
filled_quote_amount?: string;
price: string;
nonce?: number;
is_ask?: boolean;
side?: LighterSide;
type?: LighterOrderType;
time_in_force?: string;
trigger_price?: string;
reduce_only?: boolean;
status?: string;
trigger_status?: string;
trigger_time?: number;
updated_at?: number;
created_at?: number;
}
export interface LighterPosition {
market_id: number;
symbol: string;
sign: number;
position: string;
avg_entry_price: string;
position_value: string;
unrealized_pnl: string;
realized_pnl: string;
liquidation_price?: string;
initial_margin_fraction?: string;
margin_mode?: number;
allocated_margin?: string;
}
export interface LighterAccountDetails {
collateral: string;
available_balance?: string;
total_order_count?: number;
positions?: LighterPosition[];
status?: number;
account_index: number;
l1_address?: string;
name?: string;
description?: string;
pending_order_count?: number;
}
export interface LighterAuthToken {
token: string;
expiresAt: number;
}
export interface LighterOrderBookLevel {
price: string;
size: string;
}
export interface LighterOrderBookSnapshot {
market_id: number;
symbol?: string;
offset?: number;
bids: LighterOrderBookLevel[];
asks: LighterOrderBookLevel[];
}
export interface LighterMarketStats {
market_id: number;
index_price: string;
mark_price: string;
open_interest: string;
last_trade_price: string;
symbol?: string;
current_funding_rate?: string;
funding_rate?: string;
funding_timestamp?: number;
daily_base_token_volume?: number;
daily_quote_token_volume?: number;
daily_price_low?: number;
daily_price_high?: number;
daily_price_change?: number;
}
export interface LighterKline {
start_timestamp: number;
end_timestamp: number;
open: string;
high: string;
low: string;
close: string;
base_token_volume: string;
quote_token_volume: string;
trades?: number;
}
export interface LighterOrderBookMetadata {
symbol: string;
market_id: number;
maker_fee: string;
taker_fee: string;
min_base_amount: string;
min_quote_amount: string;
supported_size_decimals: number;
supported_price_decimals: number;
supported_quote_decimals: number;
status: "inactive" | "frozen" | "active" | string;
}
export interface LighterAccountMarketUpdate {
account: number;
orders?: LighterOrder[];
position?: LighterPosition;
trades?: Array<Record<string, unknown>>;
funding_history?: Array<Record<string, unknown>>;
channel: string;
}
export interface LighterClientOptions {
baseUrl: string;
accountIndex: number;
apiKeyIndex: number;
apiPrivateKey: string;
maxApiKeyIndex?: number;
orderMarketId?: number;
symbol: string;
signerLibraryPath?: string;
signerOverrides?: Partial<LighterSignerBinding>;
priceDecimals?: number;
sizeDecimals?: number;
authExpiryBufferMs?: number;
}
export interface LighterSignerBinding {
createClient(
url: string,
apiPrivateKey: string,
chainId: number,
apiKeyIndex: number,
accountIndex: number
): string | null;
switchApiKey(apiKeyIndex: number): string | null;
createAuthToken(deadlineSeconds: number): StrOrErr;
signCreateOrder(params: {
marketIndex: number;
clientOrderIndex: number;
baseAmount: bigint;
price: bigint;
isAsk: number;
orderType: number;
timeInForce: number;
reduceOnly: number;
triggerPrice: bigint;
orderExpiry: bigint;
nonce: bigint;
}): StrOrErr;
signCancelOrder(params: {
marketIndex: number;
orderIndex: bigint;
nonce: bigint;
}): StrOrErr;
signCancelAllOrders(params: {
timeInForce: number;
time: bigint;
nonce: bigint;
}): StrOrErr;
}
export interface StrOrErr {
value: string | null;
error?: string | null;
}
export interface LighterNonceManager {
init(force?: boolean): Promise<void>;
next(): { apiKeyIndex: number; nonce: bigint };
acknowledgeFailure(apiKeyIndex: number): void;
refresh(apiKeyIndex: number): Promise<void>;
}