mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-10 16:58:08 +00:00
feat(lighter): add Robinhood Chain venue support
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const signerConfigs: Array<{ chainId: number; baseUrl?: string; accountIndex: number | bigint }> = [];
|
||||
|
||||
// Keeps the real signer (and its python bridge subprocess) out of these wiring tests.
|
||||
vi.mock("../../src/exchanges/lighter/signer", () => ({
|
||||
LighterSigner: class {
|
||||
readonly accountIndex: bigint;
|
||||
readonly chainId: number;
|
||||
readonly defaultKeyIndex = 0;
|
||||
constructor(config: { chainId: number; baseUrl?: string; accountIndex: number | bigint }) {
|
||||
signerConfigs.push(config);
|
||||
this.accountIndex = BigInt(config.accountIndex);
|
||||
this.chainId = config.chainId;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const { LighterGateway } = await import("../../src/exchanges/lighter/gateway");
|
||||
|
||||
const LIGHTER_ENV_KEYS = [
|
||||
"LIGHTER_ENV",
|
||||
"LIGHTER_BASE_URL",
|
||||
"LIGHTER_WS_URL",
|
||||
"LIGHTER_MARKET_ID",
|
||||
"LIGHTER_MARKET_TYPE",
|
||||
] as const;
|
||||
|
||||
let savedEnv: Record<string, string | undefined> = {};
|
||||
|
||||
const build = (options: Record<string, unknown> = {}) =>
|
||||
new LighterGateway({
|
||||
symbol: "BTCUSDT",
|
||||
marketSymbol: "BTC",
|
||||
accountIndex: 7,
|
||||
apiKeys: { 0: "0xdeadbeef" },
|
||||
...options,
|
||||
} as any);
|
||||
|
||||
const lastSigner = () => signerConfigs[signerConfigs.length - 1]!;
|
||||
|
||||
describe("LighterGateway venue wiring", () => {
|
||||
beforeEach(() => {
|
||||
savedEnv = Object.fromEntries(LIGHTER_ENV_KEYS.map((key) => [key, process.env[key]]));
|
||||
for (const key of LIGHTER_ENV_KEYS) delete process.env[key];
|
||||
signerConfigs.length = 0;
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const [key, value] of Object.entries(savedEnv)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("wires rest, websocket and chain id from a single environment name", () => {
|
||||
const gateway = build({ environment: "rh" }) as any;
|
||||
expect(gateway.wsUrl).toBe("wss://api.rh.lighter.xyz/stream");
|
||||
expect(gateway.network.restUrl).toBe("https://api.rh.lighter.xyz");
|
||||
expect(lastSigner().chainId).toBe(466324);
|
||||
expect(lastSigner().baseUrl).toBe("https://api.rh.lighter.xyz");
|
||||
});
|
||||
|
||||
it("accepts an alias from LIGHTER_ENV", () => {
|
||||
process.env.LIGHTER_ENV = "robinhood";
|
||||
const gateway = build() as any;
|
||||
expect(gateway.environment).toBe("rh");
|
||||
expect(lastSigner().chainId).toBe(466324);
|
||||
});
|
||||
|
||||
it("follows the base url instead of defaulting the websocket to testnet", () => {
|
||||
const gateway = build({ baseUrl: "https://api.rh.lighter.xyz" }) as any;
|
||||
expect(gateway.wsUrl).toBe("wss://api.rh.lighter.xyz/stream");
|
||||
expect(lastSigner().chainId).toBe(466324);
|
||||
});
|
||||
|
||||
it("keeps mainnet unaffected", () => {
|
||||
const gateway = build({ environment: "mainnet" }) as any;
|
||||
expect(gateway.wsUrl).toBe("wss://mainnet.zklighter.elliot.ai/stream");
|
||||
expect(lastSigner().chainId).toBe(304);
|
||||
});
|
||||
|
||||
it("honours an explicit websocket override", () => {
|
||||
process.env.LIGHTER_WS_URL = "wss://custom.example/stream";
|
||||
const gateway = build({ environment: "rh" }) as any;
|
||||
expect(gateway.wsUrl).toBe("wss://custom.example/stream");
|
||||
});
|
||||
|
||||
it("does not discard an explicit market id or decimals", () => {
|
||||
const gateway = build({ environment: "rh", marketId: 16, priceDecimals: 2, sizeDecimals: 4 }) as any;
|
||||
expect(gateway.marketId).toBe(16);
|
||||
expect(gateway.priceDecimals).toBe(2);
|
||||
expect(gateway.sizeDecimals).toBe(4);
|
||||
});
|
||||
|
||||
it("reads a market id from the environment when none is passed", () => {
|
||||
process.env.LIGHTER_MARKET_ID = "21";
|
||||
const gateway = build({ environment: "rh" }) as any;
|
||||
expect(gateway.marketId).toBe(21);
|
||||
});
|
||||
|
||||
it("applies the spot preset of the resolved venue only", () => {
|
||||
const rh = build({ environment: "rh", marketSymbol: "ETH/USDG" }) as any;
|
||||
expect(rh.marketId).toBe(2048);
|
||||
expect(rh.quoteAssetSymbol).toBe("USDG");
|
||||
expect(rh.marketType).toBe("spot");
|
||||
|
||||
// The mainnet preset key must not leak into the rh venue.
|
||||
const rhWithMainnetSymbol = build({ environment: "rh", marketSymbol: "ETHUSDC" }) as any;
|
||||
expect(rhWithMainnetSymbol.marketId).toBeNull();
|
||||
});
|
||||
|
||||
it("infers the venue from a spot-only symbol when nothing else is configured", () => {
|
||||
const mainnet = build({ marketSymbol: "ETHUSDC" }) as any;
|
||||
expect(mainnet.environment).toBe("mainnet");
|
||||
expect(mainnet.marketId).toBe(2048);
|
||||
expect(lastSigner().chainId).toBe(304);
|
||||
|
||||
const rh = build({ marketSymbol: "ETHUSDG" }) as any;
|
||||
expect(rh.environment).toBe("rh");
|
||||
expect(lastSigner().chainId).toBe(466324);
|
||||
});
|
||||
|
||||
it("announces the resolved venue once", () => {
|
||||
build({ environment: "rh" });
|
||||
const banner = (console.error as unknown as { mock: { calls: unknown[][] } }).mock.calls
|
||||
.map((args) => String(args[0]))
|
||||
.find((line) => line.startsWith("[Lighter] env="));
|
||||
expect(banner).toContain("env=rh");
|
||||
expect(banner).toContain("rest=https://api.rh.lighter.xyz");
|
||||
expect(banner).toContain("ws=wss://api.rh.lighter.xyz/stream");
|
||||
expect(banner).toContain("chainId=466324");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { LighterGateway } from "../../src/exchanges/lighter/gateway";
|
||||
import type { LighterMarketStats, LighterOrderBookMetadata } from "../../src/exchanges/lighter/types";
|
||||
|
||||
/**
|
||||
* The gateway constructor spawns the signer bridge, so these exercise the individual methods
|
||||
* against a stub `this` — the same approach as order-book-choice.test.ts.
|
||||
*/
|
||||
const callOn = <T>(method: string, context: Record<string, unknown>, ...args: unknown[]): T =>
|
||||
(LighterGateway.prototype as any)[method].apply(context, args);
|
||||
|
||||
const book = (overrides: Partial<LighterOrderBookMetadata>): LighterOrderBookMetadata =>
|
||||
({
|
||||
symbol: "ETH/USDG",
|
||||
market_id: 2048,
|
||||
market_type: "spot",
|
||||
supported_price_decimals: 2,
|
||||
supported_size_decimals: 4,
|
||||
...overrides,
|
||||
}) as LighterOrderBookMetadata;
|
||||
|
||||
describe("assertUnitMultiplier", () => {
|
||||
const context = () => ({ logger: () => {} });
|
||||
|
||||
it("accepts a missing or unit multiplier", () => {
|
||||
expect(() => callOn("assertUnitMultiplier", context(), book({}))).not.toThrow();
|
||||
expect(() =>
|
||||
callOn("assertUnitMultiplier", context(), book({ multiplier: "1.000000000000000000" }))
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("refuses a market whose multiplier would skew order sizing", () => {
|
||||
expect(() =>
|
||||
callOn("assertUnitMultiplier", context(), book({ symbol: "SGOV/USDG", multiplier: "1.002981519346766532" }))
|
||||
).toThrow(/multiplier/);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.LIGHTER_ALLOW_NON_UNIT_MULTIPLIER;
|
||||
});
|
||||
|
||||
it("can be overridden explicitly", () => {
|
||||
process.env.LIGHTER_ALLOW_NON_UNIT_MULTIPLIER = "1";
|
||||
const warnings: unknown[] = [];
|
||||
const ctx = { logger: (_: string, message: unknown) => warnings.push(message) };
|
||||
expect(() =>
|
||||
callOn("assertUnitMultiplier", ctx, book({ symbol: "SGOV/USDG", multiplier: "1.0029" }))
|
||||
).not.toThrow();
|
||||
expect(warnings).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshTicker symbol matching", () => {
|
||||
// Robinhood Chain omits market_id from exchangeStats, so matching falls back to the symbol.
|
||||
const stats: LighterMarketStats[] = [
|
||||
{ symbol: "ETH", last_trade_price: "3000", index_price: "3000" } as LighterMarketStats,
|
||||
{ symbol: "ETH/USDG", last_trade_price: "3001", index_price: "3001" } as LighterMarketStats,
|
||||
];
|
||||
|
||||
const makeContext = (overrides: Record<string, unknown>) => {
|
||||
const emitted: unknown[] = [];
|
||||
const context = {
|
||||
http: { getExchangeStats: async () => stats },
|
||||
tickerEvent: { emit: (value: unknown) => emitted.push(value) },
|
||||
logger: () => {},
|
||||
displaySymbol: "ETHUSDG",
|
||||
marketId: 2048,
|
||||
ticker: null as LighterMarketStats | null,
|
||||
staleReason: null,
|
||||
...overrides,
|
||||
};
|
||||
return { context, emitted };
|
||||
};
|
||||
|
||||
it("matches the spot market by its exact venue symbol, not by base asset", async () => {
|
||||
const { context, emitted } = makeContext({
|
||||
resolvedMarketSymbol: "ETH/USDG",
|
||||
marketSymbol: "ETHUSDG",
|
||||
});
|
||||
await callOn<Promise<void>>("refreshTicker", context);
|
||||
expect(emitted).toHaveLength(1);
|
||||
expect((emitted[0] as { lastPrice: string }).lastPrice).toBe("3001");
|
||||
expect(context.ticker?.symbol).toBe("ETH/USDG");
|
||||
});
|
||||
|
||||
it("matches the perp when that is the resolved market", async () => {
|
||||
const { context, emitted } = makeContext({
|
||||
resolvedMarketSymbol: "ETH",
|
||||
marketSymbol: "ETH",
|
||||
});
|
||||
await callOn<Promise<void>>("refreshTicker", context);
|
||||
expect((emitted[0] as { lastPrice: string }).lastPrice).toBe("3000");
|
||||
});
|
||||
|
||||
it("still matches by market_id when the venue provides one", async () => {
|
||||
const withIds: LighterMarketStats[] = [
|
||||
{ symbol: "SOMETHING-ELSE", market_id: 2048, last_trade_price: "42", index_price: "42" } as LighterMarketStats,
|
||||
];
|
||||
const { context, emitted } = makeContext({
|
||||
http: { getExchangeStats: async () => withIds },
|
||||
resolvedMarketSymbol: "ETH/USDG",
|
||||
marketSymbol: "ETHUSDG",
|
||||
});
|
||||
await callOn<Promise<void>>("refreshTicker", context);
|
||||
expect((emitted[0] as { lastPrice: string }).lastPrice).toBe("42");
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyNetworkIdentity", () => {
|
||||
const rhInfo = {
|
||||
code: 200,
|
||||
l1_providers: [{ chainId: 4663 }],
|
||||
contract_addresses: [{ name: "ZkLighterContract", address: "0x94bAB9693Ba2f6358507eFfcbd372b0660AFfF9d" }],
|
||||
};
|
||||
|
||||
const makeContext = (network: Record<string, unknown>, info: unknown = rhInfo) => ({
|
||||
networkVerified: false,
|
||||
logger: () => {},
|
||||
environment: "rh",
|
||||
http: { getLayer1BasicInfo: async () => info },
|
||||
network: {
|
||||
restUrl: "https://api.rh.lighter.xyz",
|
||||
chainId: 466324,
|
||||
expectedL1ChainId: 4663,
|
||||
expectedZkLighterContract: "0x94bAB9693Ba2f6358507eFfcbd372b0660AFfF9d",
|
||||
...network,
|
||||
},
|
||||
});
|
||||
|
||||
it("passes when the deployment fingerprint matches", async () => {
|
||||
const context = makeContext({});
|
||||
await callOn<Promise<void>>("verifyNetworkIdentity", context);
|
||||
expect(context.networkVerified).toBe(true);
|
||||
});
|
||||
|
||||
it("fails closed when the host belongs to another deployment", async () => {
|
||||
const context = makeContext({ expectedL1ChainId: 1, expectedZkLighterContract: null });
|
||||
await expect(callOn<Promise<void>>("verifyNetworkIdentity", context)).rejects.toThrow(
|
||||
/network mismatch/i
|
||||
);
|
||||
});
|
||||
|
||||
it("catches a contract mismatch even when the L1 chain id collides", async () => {
|
||||
// rh-testnet and zklighter testnet both report L1 chain id 123456.
|
||||
const info = {
|
||||
code: 200,
|
||||
l1_providers: [{ chainId: 123456 }],
|
||||
contract_addresses: [{ name: "ZkLighterContract", address: "0xe034801BC49cCDC79FB683022dA0591C86077261" }],
|
||||
};
|
||||
const context = makeContext(
|
||||
{
|
||||
expectedL1ChainId: 123456,
|
||||
expectedZkLighterContract: "0x8413Cd5B9856B6D156A8A1066D778885FeaE38F8",
|
||||
},
|
||||
info
|
||||
);
|
||||
await expect(callOn<Promise<void>>("verifyNetworkIdentity", context)).rejects.toThrow(
|
||||
/ZkLighter contract/
|
||||
);
|
||||
});
|
||||
|
||||
it("tolerates the endpoint being unavailable", async () => {
|
||||
const context = makeContext({});
|
||||
context.http = {
|
||||
getLayer1BasicInfo: async () => {
|
||||
throw new Error("offline");
|
||||
},
|
||||
};
|
||||
await expect(callOn<Promise<void>>("verifyNetworkIdentity", context)).resolves.toBeUndefined();
|
||||
expect(context.networkVerified).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
deriveWebSocketUrl,
|
||||
detectEnvironmentFromUrl,
|
||||
normalizeEnvironmentName,
|
||||
resolveLighterNetwork,
|
||||
} from "../../src/exchanges/lighter/network";
|
||||
|
||||
describe("normalizeEnvironmentName", () => {
|
||||
it("accepts canonical names and aliases regardless of case", () => {
|
||||
expect(normalizeEnvironmentName("rh")).toBe("rh");
|
||||
expect(normalizeEnvironmentName("RH")).toBe("rh");
|
||||
expect(normalizeEnvironmentName(" Robinhood ")).toBe("rh");
|
||||
expect(normalizeEnvironmentName("robinhoodchain")).toBe("rh");
|
||||
expect(normalizeEnvironmentName("rh-testnet")).toBe("rh-testnet");
|
||||
expect(normalizeEnvironmentName("prod")).toBe("mainnet");
|
||||
});
|
||||
|
||||
it("returns null for empty input", () => {
|
||||
expect(normalizeEnvironmentName(undefined)).toBeNull();
|
||||
expect(normalizeEnvironmentName("")).toBeNull();
|
||||
});
|
||||
|
||||
it("throws instead of silently falling back on a typo", () => {
|
||||
expect(() => normalizeEnvironmentName("rhh")).toThrow(/Unknown Lighter environment/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectEnvironmentFromUrl", () => {
|
||||
it("matches the Robinhood hosts before the testnet substring rule", () => {
|
||||
expect(detectEnvironmentFromUrl("https://api.rh.lighter.xyz")).toBe("rh");
|
||||
// Contains "testnet" but must not resolve to the zklighter testnet.
|
||||
expect(detectEnvironmentFromUrl("https://api.rh-testnet.lighter.xyz")).toBe("rh-testnet");
|
||||
});
|
||||
|
||||
it("matches the zklighter hosts", () => {
|
||||
expect(detectEnvironmentFromUrl("https://mainnet.zklighter.elliot.ai")).toBe("mainnet");
|
||||
expect(detectEnvironmentFromUrl("https://testnet.zklighter.elliot.ai")).toBe("testnet");
|
||||
});
|
||||
|
||||
it("returns null for an unrelated host", () => {
|
||||
expect(detectEnvironmentFromUrl("https://proxy.internal.example")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveWebSocketUrl", () => {
|
||||
it("swaps the scheme and appends the stream path", () => {
|
||||
expect(deriveWebSocketUrl("https://proxy.example")).toBe("wss://proxy.example/stream");
|
||||
expect(deriveWebSocketUrl("http://localhost:8080/")).toBe("ws://localhost:8080/stream");
|
||||
expect(deriveWebSocketUrl("https://proxy.example/stream")).toBe("wss://proxy.example/stream");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLighterNetwork", () => {
|
||||
it("binds rest, websocket and chain id together for Robinhood Chain", () => {
|
||||
const resolved = resolveLighterNetwork({ environment: "rh" });
|
||||
expect(resolved.restUrl).toBe("https://api.rh.lighter.xyz");
|
||||
expect(resolved.wsUrl).toBe("wss://api.rh.lighter.xyz/stream");
|
||||
expect(resolved.chainId).toBe(466324);
|
||||
expect(resolved.expectedL1ChainId).toBe(4663);
|
||||
expect(resolved.defaultQuoteAsset).toBe("USDG");
|
||||
});
|
||||
|
||||
it("keeps mainnet on its own chain id", () => {
|
||||
const resolved = resolveLighterNetwork({ environment: "mainnet" });
|
||||
expect(resolved.chainId).toBe(304);
|
||||
expect(resolved.wsUrl).toBe("wss://mainnet.zklighter.elliot.ai/stream");
|
||||
expect(resolved.defaultQuoteAsset).toBe("USDC");
|
||||
});
|
||||
|
||||
it("derives the websocket from a base url instead of falling back to the default env", () => {
|
||||
const resolved = resolveLighterNetwork({ baseUrl: "https://api.rh.lighter.xyz" });
|
||||
expect(resolved.environment).toBe("rh");
|
||||
expect(resolved.wsUrl).toBe("wss://api.rh.lighter.xyz/stream");
|
||||
expect(resolved.chainId).toBe(466324);
|
||||
});
|
||||
|
||||
it("does not mistake the rh testnet host for the zklighter testnet", () => {
|
||||
const resolved = resolveLighterNetwork({ baseUrl: "https://api.rh-testnet.lighter.xyz" });
|
||||
expect(resolved.environment).toBe("rh-testnet");
|
||||
expect(resolved.wsUrl).toBe("wss://api.rh-testnet.lighter.xyz/stream");
|
||||
});
|
||||
|
||||
it("defaults to testnet when nothing is configured", () => {
|
||||
const resolved = resolveLighterNetwork({});
|
||||
expect(resolved.environment).toBe("testnet");
|
||||
expect(resolved.chainId).toBe(300);
|
||||
});
|
||||
|
||||
it("remaps a web app hostname onto the matching API host", () => {
|
||||
const rh = resolveLighterNetwork({ baseUrl: "https://robinhoodchain.lighter.xyz" });
|
||||
expect(rh.environment).toBe("rh");
|
||||
expect(rh.restUrl).toBe("https://api.rh.lighter.xyz");
|
||||
|
||||
const main = resolveLighterNetwork({ baseUrl: "https://app.lighter.xyz/" });
|
||||
expect(main.environment).toBe("mainnet");
|
||||
expect(main.restUrl).toBe("https://mainnet.zklighter.elliot.ai");
|
||||
});
|
||||
|
||||
it("refuses an unknown host without an explicit chain id", () => {
|
||||
expect(() => resolveLighterNetwork({ baseUrl: "https://proxy.internal.example" })).toThrow(
|
||||
/chain id/i
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts an unknown host once the chain id is supplied", () => {
|
||||
const resolved = resolveLighterNetwork({ baseUrl: "https://proxy.internal.example", chainId: 466324 });
|
||||
expect(resolved.environment).toBeNull();
|
||||
expect(resolved.wsUrl).toBe("wss://proxy.internal.example/stream");
|
||||
expect(resolved.chainId).toBe(466324);
|
||||
expect(resolved.expectedL1ChainId).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the environment chain id when the venue is reached through a proxy", () => {
|
||||
const resolved = resolveLighterNetwork({ environment: "rh", baseUrl: "https://proxy.internal.example" });
|
||||
expect(resolved.restUrl).toBe("https://proxy.internal.example");
|
||||
expect(resolved.wsUrl).toBe("wss://proxy.internal.example/stream");
|
||||
expect(resolved.chainId).toBe(466324);
|
||||
});
|
||||
|
||||
it("lets an explicit websocket url win", () => {
|
||||
const resolved = resolveLighterNetwork({ environment: "rh", wsUrl: "wss://custom.example/stream" });
|
||||
expect(resolved.wsUrl).toBe("wss://custom.example/stream");
|
||||
expect(resolved.restUrl).toBe("https://api.rh.lighter.xyz");
|
||||
});
|
||||
|
||||
it("lets an explicit chain id override the table", () => {
|
||||
const resolved = resolveLighterNetwork({ environment: "rh", chainId: 999 });
|
||||
expect(resolved.chainId).toBe(999);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user