mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
feat: 添加 Lighter 适配器及相关功能,支持 trailing stops 和新的交易逻辑
This commit is contained in:
@@ -160,6 +160,33 @@ function createAdapterOrThrow(symbol: string): ExchangeAdapter {
|
||||
aster: { apiKey, apiSecret },
|
||||
});
|
||||
}
|
||||
if (exchangeId === "lighter") {
|
||||
const accountIndex = parseInt(process.env.LIGHTER_ACCOUNT_INDEX ?? "", 10);
|
||||
const privateKey = process.env.LIGHTER_API_PRIVATE_KEY;
|
||||
if (!Number.isFinite(accountIndex) || !privateKey) {
|
||||
throw new Error("LIGHTER_ACCOUNT_INDEX and LIGHTER_API_PRIVATE_KEY environment variables are required for Lighter exchange");
|
||||
}
|
||||
const apiKeyIndex = process.env.LIGHTER_API_KEY_INDEX ? Number(process.env.LIGHTER_API_KEY_INDEX) : 0;
|
||||
const lighterSymbol = process.env.LIGHTER_SYMBOL ?? symbol;
|
||||
const marketId = process.env.LIGHTER_MARKET_ID ? Number(process.env.LIGHTER_MARKET_ID) : undefined;
|
||||
const priceDecimals = process.env.LIGHTER_PRICE_DECIMALS ? Number(process.env.LIGHTER_PRICE_DECIMALS) : undefined;
|
||||
const sizeDecimals = process.env.LIGHTER_SIZE_DECIMALS ? Number(process.env.LIGHTER_SIZE_DECIMALS) : undefined;
|
||||
return createExchangeAdapter({
|
||||
exchange: exchangeId,
|
||||
symbol,
|
||||
lighter: {
|
||||
marketSymbol: lighterSymbol,
|
||||
accountIndex,
|
||||
apiPrivateKey: privateKey,
|
||||
apiKeyIndex,
|
||||
baseUrl: process.env.LIGHTER_BASE_URL,
|
||||
environment: process.env.LIGHTER_ENV,
|
||||
marketId,
|
||||
priceDecimals,
|
||||
sizeDecimals,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return createExchangeAdapter({
|
||||
exchange: exchangeId,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -606,7 +606,7 @@ export class TrendEngine {
|
||||
await this.tryPlaceStopLoss(stopSide, roundDownToTick(stopPrice, this.config.priceTick), price);
|
||||
}
|
||||
|
||||
if (!currentTrailing) {
|
||||
if (!currentTrailing && this.exchange.supportsTrailingStops()) {
|
||||
await this.tryPlaceTrailingStop(
|
||||
stopSide,
|
||||
roundDownToTick(activationPrice, this.config.priceTick),
|
||||
@@ -824,6 +824,9 @@ export class TrendEngine {
|
||||
activationPrice: number,
|
||||
quantity: number
|
||||
): Promise<void> {
|
||||
if (!this.exchange.supportsTrailingStops()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await placeTrailingStopOrder(
|
||||
this.exchange,
|
||||
|
||||
@@ -48,6 +48,30 @@ export function MakerApp({ onExit }: MakerAppProps) {
|
||||
symbol: makerConfig.symbol,
|
||||
aster: { apiKey, apiSecret },
|
||||
});
|
||||
} else if (exchangeId === "lighter") {
|
||||
const accountIndexRaw = process.env.LIGHTER_ACCOUNT_INDEX;
|
||||
const apiPrivateKey = process.env.LIGHTER_API_PRIVATE_KEY;
|
||||
if (!accountIndexRaw || !apiPrivateKey) {
|
||||
setError(new Error("缺少 LIGHTER_ACCOUNT_INDEX 或 LIGHTER_API_PRIVATE_KEY 环境变量"));
|
||||
return;
|
||||
}
|
||||
adapter = createExchangeAdapter({
|
||||
exchange: exchangeId,
|
||||
symbol: makerConfig.symbol,
|
||||
lighter: {
|
||||
displaySymbol: makerConfig.symbol,
|
||||
accountIndex: parseInt(accountIndexRaw, 10),
|
||||
apiPrivateKey,
|
||||
apiKeyIndex: process.env.LIGHTER_API_KEY_INDEX ? Number(process.env.LIGHTER_API_KEY_INDEX) : 0,
|
||||
environment: process.env.LIGHTER_ENV,
|
||||
baseUrl: process.env.LIGHTER_BASE_URL,
|
||||
marketId: process.env.LIGHTER_MARKET_ID ? Number(process.env.LIGHTER_MARKET_ID) : undefined,
|
||||
marketSymbol: process.env.LIGHTER_SYMBOL,
|
||||
priceDecimals: process.env.LIGHTER_PRICE_DECIMALS ? Number(process.env.LIGHTER_PRICE_DECIMALS) : undefined,
|
||||
sizeDecimals: process.env.LIGHTER_SIZE_DECIMALS ? Number(process.env.LIGHTER_SIZE_DECIMALS) : undefined,
|
||||
l1Address: process.env.LIGHTER_L1_ADDRESS,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
adapter = createExchangeAdapter({
|
||||
exchange: exchangeId,
|
||||
|
||||
Reference in New Issue
Block a user