feat(lighter): add Robinhood Chain venue support

This commit is contained in:
discountry
2026-08-14 14:05:37 +08:00
parent 0ea71503e3
commit f3a96886ac
14 changed files with 1009 additions and 139 deletions
+1
View File
@@ -10,6 +10,7 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en
如果您希望获取优惠并支持本项目,请考虑使用以下注册链接:
* [Lighter Robinhood Chain 注册链接](https://robinhoodchain.lighter.xyz/?referral=RITMEX) —— 额外 10% 积分加成
* [Lighter 手续费优惠注册链接](https://app.lighter.xyz/?referral=RITMEX)
* [Hyperliquid 邀请注册链接](https://app.hyperliquid.xyz/join/RITMEX)
* [Ondo Perps 邀请注册链接](https://app.ondoperps.xyz/?ref=4A3ACQ)
+1
View File
@@ -6,6 +6,7 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en
If you'd like to support this project and get fee discounts, please consider using these referral links:
* [Lighter Robinhood Chain referral link](https://robinhoodchain.lighter.xyz/?referral=RITMEX) — 10% bonus points
* [Lighter referral link](https://app.lighter.xyz/?referral=RITMEX)
* [Hyperliquid referral link](https://app.hyperliquid.xyz/join/RITMEX)
* [Ondo Perps referral link](https://app.ondoperps.xyz/?ref=4A3ACQ)
+53 -15
View File
@@ -8,18 +8,30 @@ This guide configures Lighter perpetuals and the integrated Spot markets. Lighte
## 1. Select a network
| `LIGHTER_ENV` | REST URL | Chain ID |
| --- | --- | --- |
| `mainnet` | `https://mainnet.zklighter.elliot.ai` | `304` |
| `testnet` | `https://testnet.zklighter.elliot.ai` | `300` |
| `staging` | `https://staging.zklighter.elliot.ai` | `300` |
| `dev` | `https://dev.zklighter.elliot.ai` | `300` |
| `LIGHTER_ENV` | REST URL | WebSocket | Signing chain ID | Quote asset |
| --- | --- | --- | --- | --- |
| `mainnet` | `https://mainnet.zklighter.elliot.ai` | `wss://mainnet.zklighter.elliot.ai/stream` | `304` | USDC |
| `rh` | `https://api.rh.lighter.xyz` | `wss://api.rh.lighter.xyz/stream` | `466324` | USDG |
| `testnet` | `https://testnet.zklighter.elliot.ai` | `wss://testnet.zklighter.elliot.ai/stream` | `300` | USDC |
| `rh-testnet` | `https://api.rh-testnet.lighter.xyz` | `wss://api.rh-testnet.lighter.xyz/stream` | `300` | USDG |
| `staging` | `https://staging.zklighter.elliot.ai` | `wss://staging.zklighter.elliot.ai/stream` | `300` | USDC |
| `dev` | `https://dev.zklighter.elliot.ai` | `wss://dev.zklighter.elliot.ai/stream` | `300` | USDC |
The current default is `testnet`. Set `LIGHTER_ENV=mainnet` explicitly for production trading.
`rh` is the Robinhood Chain deployment (web app at `robinhoodchain.lighter.xyz`). It is a separate chain from the main venue: accounts, API keys, market IDs and funds are not shared, and the signing chain ID differs.
**Switching venues means changing only `LIGHTER_ENV`** — the REST URL, WebSocket URL and signing chain ID are all derived from it together, so they cannot drift apart. The aliases `robinhood`, `robinhoodchain` and `rhc` all mean `rh`.
The current default is `testnet`. Set `LIGHTER_ENV=mainnet` or `LIGHTER_ENV=rh` explicitly for production trading.
At startup the bot prints one confirmation line and calls `/api/v1/layer1BasicInfo` to check the L1 chain ID and ZkLighter contract address against the configured deployment, failing immediately on a mismatch:
```
[Lighter] env=rh rest=https://api.rh.lighter.xyz ws=wss://api.rh.lighter.xyz/stream chainId=466324 account=12345
```
## 2. Obtain the account index and API key
1. Create and fund an account on [Lighter](https://app.lighter.xyz/?referral=111909FA).
1. Create and fund an account on [Robinhood Chain](https://robinhoodchain.lighter.xyz/?referral=RITMEX) (10% bonus points) or the [Lighter main venue](https://app.lighter.xyz/?referral=111909FA). Accounts on the two are independent.
2. Follow the official [Get Started guide](https://apidocs.lighter.xyz/docs/get-started) to query `account_index` from the L1 address.
3. Follow the official [API Keys guide](https://apidocs.lighter.xyz/docs/api-keys) to create an API key.
4. Save the API private key returned by the creation flow and record its `api_key_index`.
@@ -53,22 +65,45 @@ LIGHTER_SYMBOL=BTC
Testnet and mainnet credentials cannot be mixed.
## 5. Optional settings
## 5. Robinhood Chain configuration
```dotenv
EXCHANGE=lighter
LIGHTER_ENV=rh
LIGHTER_ACCOUNT_INDEX=<your_rh_account_index>
LIGHTER_API_KEY_INDEX=<your_rh_api_key_index>
LIGHTER_API_PRIVATE_KEY=<your_rh_api_private_key_hex>
LIGHTER_SYMBOL=BTC
```
What changes when switching venues:
- **Credentials are venue-specific.** Create the account index and API key on Robinhood Chain itself.
- **Market IDs use a different numbering**, so reusing one across venues points at the wrong instrument. Leave `LIGHTER_MARKET_ID` unset unless metadata resolution fails, and clear it when coming from the main venue.
- **Spot is quoted in USDG, not USDC** — spot symbols look like `ETH/USDG`.
- The venue lists equity perpetuals (`TSLA`, `AAPL`, `NVDA`, …) and tokenized equity spot markets.
- `SGOV/USDG`, `ORCL/USDG` and `MU/USDG` have a contract `multiplier` other than 1 while order scaling assumes 1.0, so those markets are refused. Set `LIGHTER_ALLOW_NON_UNIT_MULTIPLIER=1` to trade them anyway.
## 6. Optional settings
| Variable | Purpose |
| --- | --- |
| `LIGHTER_BASE_URL` | Overrides the REST URL; known hostnames also determine the network |
| `LIGHTER_BASE_URL` | Overrides the REST URL; known hostnames determine the network, and a web-app URL (e.g. `robinhoodchain.lighter.xyz`) is remapped to its API host |
| `LIGHTER_WS_URL` | Overrides the WebSocket URL; derived from `LIGHTER_ENV` or `LIGHTER_BASE_URL` otherwise |
| `LIGHTER_L1_ADDRESS` | L1 address associated with the account |
| `LIGHTER_MARKET_ID` | Forces a market ID when metadata resolution fails |
| `LIGHTER_MARKET_ID` | Forces a market ID when metadata resolution fails; never reuse across venues |
| `LIGHTER_MARKET_TYPE` | `perp` or `spot` |
| `LIGHTER_PRICE_DECIMALS` | Forces price decimals |
| `LIGHTER_SIZE_DECIMALS` | Forces size decimals |
| `LIGHTER_CHAIN_ID` | Overrides the signing chain ID |
| `LIGHTER_CHAIN_ID` | Overrides the signing chain ID; required for a self-hosted or proxied host that cannot be recognized |
| `LIGHTER_ALLOW_NON_UNIT_MULTIPLIER` | Allows trading markets whose `multiplier` is not 1 |
| `LIGHTER_DEBUG` | Set to `1` or `true` for debug output |
Spot markets use symbols such as `ETH/USDC`. Explicit market IDs and decimal overrides must match order-book metadata for the selected network.
Spot markets use symbols such as `ETH/USDC` (main venue) or `ETH/USDG` (Robinhood Chain). Explicit market IDs and decimal overrides must match order-book metadata for the selected network.
## 6. Verify the configuration
For a self-hosted node or a proxy whose hostname cannot be recognized, `LIGHTER_CHAIN_ID` is mandatory: no endpoint exposes the signing chain ID, and guessing it wrong makes every transaction fail signature verification, so startup fails loudly instead of assuming a default.
## 7. Verify the configuration
```bash
bun run index.ts doctor --exchange lighter --symbol BTC --json
@@ -82,7 +117,10 @@ The ticker check loads market metadata, validates the account/API-key pair, and
- `LIGHTER_ACCOUNT_INDEX must be an integer`: use the numeric index returned by the account API.
- `Invalid LIGHTER_API_KEY_INDEX`: use the non-negative integer recorded during key creation.
- `private key does not match the one on Lighter`: the account index, key index, private key, or network differs.
- `Configured market id ... not found`: verify `LIGHTER_ENV`, `LIGHTER_SYMBOL`, and any manual market ID.
- `Configured market id ... not found`: verify `LIGHTER_ENV`, `LIGHTER_SYMBOL`, and any manual market ID. After switching venues the usual cause is a `LIGHTER_MARKET_ID` left over from the previous one.
- `Lighter network mismatch`: the REST URL and `LIGHTER_ENV` point at different deployments, caught before any order is signed. Reconcile `LIGHTER_ENV` and `LIGHTER_BASE_URL` against the table above.
- `Unknown Lighter environment`: `LIGHTER_ENV` is misspelled; the error lists every valid value and alias.
- `has contract multiplier ... not 1.0`: the market's contract multiplier is not 1 and sizing could be wrong; set `LIGHTER_ALLOW_NON_UNIT_MULTIPLIER=1` once you have verified the scaling.
- Signer loading failures: the repository ships macOS arm64 and Linux amd64 signer libraries. Other platforms require a compatible signer build or a supported WSL/Linux environment.
## Security
+53 -15
View File
@@ -8,18 +8,30 @@ English version: [Lighter Configuration Guide](lighter.en.md)
## 1. 选择网络
| `LIGHTER_ENV` | REST 地址 | Chain ID |
| --- | --- | --- |
| `mainnet` | `https://mainnet.zklighter.elliot.ai` | `304` |
| `testnet` | `https://testnet.zklighter.elliot.ai` | `300` |
| `staging` | `https://staging.zklighter.elliot.ai` | `300` |
| `dev` | `https://dev.zklighter.elliot.ai` | `300` |
| `LIGHTER_ENV` | REST 地址 | WebSocket | 签名 Chain ID | 计价资产 |
| --- | --- | --- | --- | --- |
| `mainnet` | `https://mainnet.zklighter.elliot.ai` | `wss://mainnet.zklighter.elliot.ai/stream` | `304` | USDC |
| `rh` | `https://api.rh.lighter.xyz` | `wss://api.rh.lighter.xyz/stream` | `466324` | USDG |
| `testnet` | `https://testnet.zklighter.elliot.ai` | `wss://testnet.zklighter.elliot.ai/stream` | `300` | USDC |
| `rh-testnet` | `https://api.rh-testnet.lighter.xyz` | `wss://api.rh-testnet.lighter.xyz/stream` | `300` | USDG |
| `staging` | `https://staging.zklighter.elliot.ai` | `wss://staging.zklighter.elliot.ai/stream` | `300` | USDC |
| `dev` | `https://dev.zklighter.elliot.ai` | `wss://dev.zklighter.elliot.ai/stream` | `300` | USDC |
当前默认值为 `testnet`。生产交易应显式设置 `LIGHTER_ENV=mainnet`
`rh` 是 Robinhood Chain 部署(网页端 `robinhoodchain.lighter.xyz`)。它与主站是两条独立的链:账户、API Key、market ID 和资金都不互通,签名 Chain ID 也不同
**切换平台只需要改 `LIGHTER_ENV` 这一个变量** —— REST 地址、WebSocket 地址和签名 Chain ID 都由它一起派生,不会出现只改了一半的错配。别名 `robinhood``robinhoodchain``rhc` 等价于 `rh`
当前默认值为 `testnet`。生产交易应显式设置 `LIGHTER_ENV=mainnet``LIGHTER_ENV=rh`
启动时机器人会打印一行确认,并调用 `/api/v1/layer1BasicInfo` 用 L1 Chain ID 与 ZkLighter 合约地址核对连接的确实是配置声明的那条链,不一致直接报错退出:
```
[Lighter] env=rh rest=https://api.rh.lighter.xyz ws=wss://api.rh.lighter.xyz/stream chainId=466324 account=12345
```
## 2. 获取账户索引和 API Key
1. 在 [Lighter](https://app.lighter.xyz/?referral=111909FA) 创建并入金账户
1. 创建并入金账户:[Robinhood Chain](https://robinhoodchain.lighter.xyz/?referral=RITMEX)(额外 10% 积分加成)或 [Lighter 主站](https://app.lighter.xyz/?referral=111909FA)。两个平台的账户互相独立
2. 按[官方 Get Started](https://apidocs.lighter.xyz/docs/get-started) 使用 L1 地址查询 `account_index`
3. 按[官方 API Keys 指南](https://apidocs.lighter.xyz/docs/api-keys) 创建 API Key。
4. 保存创建流程返回的 API 私钥,并记录对应的 `api_key_index`
@@ -53,22 +65,45 @@ LIGHTER_SYMBOL=BTC
测试网和主网凭证不可混用。
## 5. 可选配置
## 5. Robinhood Chain 配置
```dotenv
EXCHANGE=lighter
LIGHTER_ENV=rh
LIGHTER_ACCOUNT_INDEX=<your_rh_account_index>
LIGHTER_API_KEY_INDEX=<your_rh_api_key_index>
LIGHTER_API_PRIVATE_KEY=<your_rh_api_private_key_hex>
LIGHTER_SYMBOL=BTC
```
切换平台时的注意事项:
- **凭证不通用**Robinhood Chain 的账户索引和 API Key 必须在该平台单独创建。
- **market ID 是另一套编号**,跨平台复用必然指向错误的标的。除非自动解析失败,否则不要设置 `LIGHTER_MARKET_ID`;从主站切过来时务必清掉这个变量。
- **现货计价资产是 USDG 而非 USDC**,现货符号写成 `ETH/USDG`
- 该平台提供股票类永续(`TSLA``AAPL``NVDA` 等)和代币化股票现货。
- `SGOV/USDG``ORCL/USDG``MU/USDG` 三个现货市场的合约 `multiplier` 不等于 1,而下单数量/价格换算按 1.0 处理,因此这些市场会被直接拒绝。确认自己清楚换算关系后可用 `LIGHTER_ALLOW_NON_UNIT_MULTIPLIER=1` 放行。
## 6. 可选配置
| 变量 | 说明 |
| --- | --- |
| `LIGHTER_BASE_URL` | 覆盖 REST 地址;网络可从已知主机名推断 |
| `LIGHTER_BASE_URL` | 覆盖 REST 地址;已知主机名会自动推断网络,填入网页端地址(如 `robinhoodchain.lighter.xyz`)会自动换成对应 API 地址 |
| `LIGHTER_WS_URL` | 覆盖 WebSocket 地址;不填时由 `LIGHTER_ENV``LIGHTER_BASE_URL` 派生 |
| `LIGHTER_L1_ADDRESS` | 账户关联的 L1 地址 |
| `LIGHTER_MARKET_ID` | 强制 market ID;仅在自动解析失败时设置 |
| `LIGHTER_MARKET_ID` | 强制 market ID;仅在自动解析失败时设置,且不可跨平台复用 |
| `LIGHTER_MARKET_TYPE` | `perp``spot` |
| `LIGHTER_PRICE_DECIMALS` | 强制价格小数位 |
| `LIGHTER_SIZE_DECIMALS` | 强制数量小数位 |
| `LIGHTER_CHAIN_ID` | 覆盖签名 Chain ID |
| `LIGHTER_CHAIN_ID` | 覆盖签名 Chain ID;自建/代理主机无法识别网络时必填 |
| `LIGHTER_ALLOW_NON_UNIT_MULTIPLIER` | 允许交易 `multiplier ≠ 1` 的市场 |
| `LIGHTER_DEBUG` | 设置为 `1``true` 输出调试日志 |
现货市场使用 `ETH/USDC` 这类符号。显式 market ID、价格小数位和数量小数位必须与目标网络的 order book 元数据一致。
现货市场使用 `ETH/USDC`(主站)或 `ETH/USDG`Robinhood Chain这类符号。显式 market ID、价格小数位和数量小数位必须与目标网络的 order book 元数据一致。
## 6. 验证配置
自建节点或走代理时,若主机名无法识别为已知部署,则必须显式设置 `LIGHTER_CHAIN_ID` —— 签名 Chain ID 没有任何接口可以查询,猜错会导致每一笔交易验签失败,因此这里选择直接报错而不是使用默认值。
## 7. 验证配置
```bash
bun run index.ts doctor --exchange lighter --symbol BTC --json
@@ -82,7 +117,10 @@ bun run index.ts market ticker --exchange lighter --symbol BTC --json
- `LIGHTER_ACCOUNT_INDEX must be an integer`:填写账户接口返回的数字索引。
- `Invalid LIGHTER_API_KEY_INDEX`:使用创建 Key 时记录的非负整数索引。
- `private key does not match the one on Lighter`:账户索引、Key 索引、私钥或网络不匹配。
- `Configured market id ... not found`:检查 `LIGHTER_ENV``LIGHTER_SYMBOL` 和手动 market ID。
- `Configured market id ... not found`:检查 `LIGHTER_ENV``LIGHTER_SYMBOL` 和手动 market ID。跨平台切换后最常见的原因是 `LIGHTER_MARKET_ID` 仍是上一个平台的编号。
- `Lighter network mismatch`REST 地址与 `LIGHTER_ENV` 指向了不同的部署,机器人在下单前拦下了这个错配。按上表核对 `LIGHTER_ENV``LIGHTER_BASE_URL`
- `Unknown Lighter environment``LIGHTER_ENV` 拼写错误,报错信息会列出全部合法取值与别名。
- `has contract multiplier ... not 1.0`:该市场的合约乘数不为 1,换算可能失真;确认无误后用 `LIGHTER_ALLOW_NON_UNIT_MULTIPLIER=1` 放行。
- signer 加载失败:仓库预置 macOS arm64 与 Linux amd64 签名库,其他平台需要构建兼容签名库或使用受支持的 WSL/Linux 环境。
## 安全要求
+3 -1
View File
@@ -22,6 +22,7 @@ export interface LighterCredentials {
apiKeyIndex?: number;
environment?: string;
baseUrl?: string;
wsUrl?: string;
marketId?: number;
priceDecimals?: number;
sizeDecimals?: number;
@@ -60,7 +61,8 @@ export class LighterExchangeAdapter implements ExchangeAdapter {
accountIndex,
apiKeys,
baseUrl: credentials.baseUrl ?? process.env.LIGHTER_BASE_URL,
environment: environment as LighterGatewayOptions["environment"],
environment,
wsUrl: credentials.wsUrl ?? process.env.LIGHTER_WS_URL,
marketId,
priceDecimals,
sizeDecimals,
+85 -7
View File
@@ -1,36 +1,114 @@
export type LighterEnvironment = "mainnet" | "testnet" | "staging" | "dev";
export type LighterEnvironment = "mainnet" | "testnet" | "staging" | "dev" | "rh" | "rh-testnet";
export interface LighterHostConfig {
rest: string;
ws: string;
}
export const LIGHTER_HOSTS: Record<LighterEnvironment, LighterHostConfig> = {
export interface LighterNetworkConfig extends LighterHostConfig {
/**
* Chain id folded into every signature. A wrong value is unrecoverable: the signer
* happily produces a payload and the sequencer rejects every transaction.
* No endpoint exposes it, so this table is the only source of truth.
*/
chainId: number;
/**
* `l1_providers[0].chainId` from `/api/v1/layer1BasicInfo`, plus the ZkLighter contract
* address — the only deployment fingerprints the server hands out. Used at startup to
* prove REST really points at the venue the config claims. `null` = not verified.
*/
l1ChainId: number | null;
zkLighterContract: string | null;
/** Settlement/quote asset the venue defaults to when metadata does not name one. */
defaultQuoteAsset: string;
}
/**
* Every knob a deployment needs, bound together so no caller can mix a REST host from one
* venue with the chain id or websocket of another.
*/
export const LIGHTER_NETWORKS: Record<LighterEnvironment, LighterNetworkConfig> = {
mainnet: {
rest: "https://mainnet.zklighter.elliot.ai",
ws: "wss://mainnet.zklighter.elliot.ai/stream",
chainId: 304,
l1ChainId: 1,
zkLighterContract: "0x3B4D794a66304F130a4Db8F2551B0070dfCf5ca7",
defaultQuoteAsset: "USDC",
},
rh: {
rest: "https://api.rh.lighter.xyz",
ws: "wss://api.rh.lighter.xyz/stream",
chainId: 466324,
l1ChainId: 4663,
zkLighterContract: "0x94bAB9693Ba2f6358507eFfcbd372b0660AFfF9d",
defaultQuoteAsset: "USDG",
},
"rh-testnet": {
rest: "https://api.rh-testnet.lighter.xyz",
ws: "wss://api.rh-testnet.lighter.xyz/stream",
chainId: 300,
// Shares L1 chain id 123456 with zklighter testnet; only the contract tells them apart.
l1ChainId: 123456,
zkLighterContract: "0x8413Cd5B9856B6D156A8A1066D778885FeaE38F8",
defaultQuoteAsset: "USDG",
},
testnet: {
rest: "https://testnet.zklighter.elliot.ai",
ws: "wss://testnet.zklighter.elliot.ai/stream",
chainId: 300,
l1ChainId: 123456,
zkLighterContract: "0xe034801BC49cCDC79FB683022dA0591C86077261",
defaultQuoteAsset: "USDC",
},
staging: {
rest: "https://staging.zklighter.elliot.ai",
ws: "wss://staging.zklighter.elliot.ai/stream",
chainId: 300,
l1ChainId: null,
zkLighterContract: null,
defaultQuoteAsset: "USDC",
},
dev: {
rest: "https://dev.zklighter.elliot.ai",
ws: "wss://dev.zklighter.elliot.ai/stream",
chainId: 300,
l1ChainId: null,
zkLighterContract: null,
defaultQuoteAsset: "USDC",
},
};
export const LIGHTER_CHAIN_IDS: Record<LighterEnvironment, number> = {
mainnet: 304,
testnet: 300,
staging: 300,
dev: 300,
/** Spellings users actually type, mapped onto canonical environment names. */
export const LIGHTER_ENVIRONMENT_ALIASES: Record<string, LighterEnvironment> = {
robinhood: "rh",
robinhoodchain: "rh",
"robinhood-chain": "rh",
"rh-mainnet": "rh",
rhc: "rh",
"robinhood-testnet": "rh-testnet",
rhtestnet: "rh-testnet",
prod: "mainnet",
production: "mainnet",
};
/**
* Web app hostnames. Pasting one of these as a base URL is a common mistake — they serve the
* SPA, not the API — so they resolve to the matching environment's real REST host instead.
*/
export const LIGHTER_APP_HOSTS: Record<string, LighterEnvironment> = {
"app.lighter.xyz": "mainnet",
"robinhoodchain.lighter.xyz": "rh",
};
export const LIGHTER_HOSTS: Record<LighterEnvironment, LighterHostConfig> = Object.fromEntries(
Object.entries(LIGHTER_NETWORKS).map(([env, config]) => [env, { rest: config.rest, ws: config.ws }])
) as Record<LighterEnvironment, LighterHostConfig>;
export const LIGHTER_CHAIN_IDS: Record<LighterEnvironment, number> = Object.fromEntries(
Object.entries(LIGHTER_NETWORKS).map(([env, config]) => [env, config.chainId])
) as Record<LighterEnvironment, number>;
export const DEFAULT_LIGHTER_ENVIRONMENT: LighterEnvironment = "testnet";
export const DEFAULT_TRANSACTION_EXPIRY_BUFFER_MS = 10 * 60 * 1000 - 1000; // 10 min minus 1s
+193 -91
View File
@@ -34,13 +34,12 @@ import type {
} from "./types";
import {
DEFAULT_AUTH_TOKEN_BUFFER_MS,
DEFAULT_LIGHTER_ENVIRONMENT,
LIGHTER_HOSTS,
LIGHTER_ORDER_TYPE,
LIGHTER_TIME_IN_FORCE,
IMMEDIATE_OR_CANCEL_EXPIRY_PLACEHOLDER,
type LighterEnvironment,
} from "./constants";
import { resolveLighterNetwork, type LighterNetworkResolution } from "./network";
import { decimalToScaled, scaledToDecimalString, scaleQuantityWithMinimum } from "./decimal";
import { lighterOrderToAster, toAccountSnapshot, toDepth, toKlines, toOrders, toTicker } from "./mappers";
import { normalizeOrderIdentity, orderIdentityEquals } from "./order-identity";
@@ -77,47 +76,6 @@ function createEvent<T>(): SimpleEvent<T> {
};
}
function isLighterEnvironment(value: string | undefined | null): value is LighterEnvironment {
if (!value) return false;
return Object.prototype.hasOwnProperty.call(LIGHTER_HOSTS, value);
}
function detectEnvironmentFromUrl(baseUrl: string | undefined | null): LighterEnvironment | null {
if (!baseUrl) return null;
const matchHost = (host: string): LighterEnvironment | null => {
for (const [env, config] of Object.entries(LIGHTER_HOSTS)) {
try {
const restHost = new URL(config.rest).hostname.toLowerCase();
if (restHost === host) {
return env as LighterEnvironment;
}
} catch {
// ignore invalid config URLs
}
}
if (host.includes("mainnet")) return "mainnet";
if (host.includes("testnet")) return "testnet";
if (host.includes("staging")) return "staging";
if (host.includes("dev")) return "dev";
return null;
};
try {
const parsed = new URL(baseUrl);
return matchHost(parsed.hostname.toLowerCase());
} catch {
return matchHost(baseUrl.toLowerCase());
}
}
function inferEnvironment(envOption: string | undefined, baseUrl?: string | null): LighterEnvironment {
if (isLighterEnvironment(envOption)) {
return envOption;
}
const detected = detectEnvironmentFromUrl(baseUrl ?? undefined);
return detected ?? DEFAULT_LIGHTER_ENVIRONMENT;
}
interface Pollers {
ticker?: ReturnType<typeof setInterval>;
klines: Map<string, ReturnType<typeof setInterval>>;
@@ -181,8 +139,34 @@ const TERMINAL_ORDER_STATUSES = new Set([
"canceled-reduce-only",
]);
const KNOWN_SPOT_MARKETS: Record<string, { marketId: number; base: string; quote: string; priceDecimals?: number; sizeDecimals?: number }> = {
interface SpotMarketPreset {
marketId: number;
base: string;
quote: string;
priceDecimals?: number;
sizeDecimals?: number;
}
/**
* Market ids are per-deployment, so presets are keyed by environment first — reusing a mainnet
* id on Robinhood Chain would silently trade a different instrument.
*/
const KNOWN_SPOT_MARKETS: Partial<Record<LighterEnvironment, Record<string, SpotMarketPreset>>> = {
mainnet: {
ETHUSDC: { marketId: 2048, base: "ETH", quote: "USDC", priceDecimals: 2, sizeDecimals: 4 },
},
rh: {
ETHUSDG: { marketId: 2048, base: "ETH", quote: "USDG", priceDecimals: 2, sizeDecimals: 4 },
},
};
/**
* Which deployment lists a given spot symbol. Used only to pick an environment when the user
* supplied neither LIGHTER_ENV nor LIGHTER_BASE_URL, since the default is testnet.
*/
const SPOT_PRESET_ENVIRONMENTS: Record<string, LighterEnvironment> = {
ETHUSDC: "mainnet",
ETHUSDG: "rh",
};
export interface LighterGatewayOptions {
@@ -191,7 +175,9 @@ export interface LighterGatewayOptions {
accountIndex: number;
apiKeys: Record<number, string>;
baseUrl?: string;
environment?: keyof typeof LIGHTER_HOSTS;
/** Canonical name or alias; see LIGHTER_ENVIRONMENT_ALIASES. */
environment?: string;
wsUrl?: string;
marketId?: number;
priceDecimals?: number;
sizeDecimals?: number;
@@ -211,7 +197,9 @@ export class LighterGateway {
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 network: LighterNetworkResolution;
private readonly environment: LighterEnvironment | null;
private networkVerified = false;
private readonly pollers: Pollers = { ticker: undefined, klines: new Map() };
private accountPoller: ReturnType<typeof setInterval> | null = null;
private accountPollInFlight = false;
@@ -236,6 +224,8 @@ export class LighterGateway {
private forcedSpotPreset = false;
private marketId: number | null = null;
/** Exact symbol as listed by the venue (e.g. `ETH/USDG`), used to match stats payloads. */
private resolvedMarketSymbol: string | null = null;
private marketType: "perp" | "spot" | null = null;
private priceDecimals: number | null = null;
private sizeDecimals: number | null = null;
@@ -295,40 +285,43 @@ export class LighterGateway {
const parsedSymbols = parseBaseQuote(this.marketSymbol);
this.baseAssetSymbol = parsedSymbols.base ?? null;
this.quoteAssetSymbol = parsedSymbols.quote ?? null;
this.applyPresetMarket();
if (process.env.LIGHTER_MARKET_ID) {
this.marketId = Number(process.env.LIGHTER_MARKET_ID);
}
// Explicit overrides are applied before presets so a preset can only fill a gap, never
// overwrite what the operator asked for.
this.marketId =
options.marketId != null
? Number(options.marketId)
: process.env.LIGHTER_MARKET_ID
? Number(process.env.LIGHTER_MARKET_ID)
: null;
this.priceDecimals = options.priceDecimals ?? null;
this.sizeDecimals = options.sizeDecimals ?? null;
if (process.env.LIGHTER_MARKET_TYPE) {
this.marketType = normalizeMarketType(process.env.LIGHTER_MARKET_TYPE) ?? this.marketType;
}
const envPreference =
options.environment ??
process.env.LIGHTER_ENV ??
(this.forcedSpotPreset && !options.baseUrl ? "mainnet" : undefined);
this.environment = inferEnvironment(envPreference, options.baseUrl);
const host = options.baseUrl ?? LIGHTER_HOSTS[this.environment]?.rest;
if (!host) {
throw new Error(`Unknown Lighter environment ${this.environment}`);
}
if (process.env.LIGHTER_DEBUG === "1" || process.env.LIGHTER_DEBUG === "true") {
// eslint-disable-next-line no-console
console.error(
"[LighterGateway] init",
JSON.stringify({ env: this.environment, host, marketId: this.marketId, marketType: this.marketType })
);
}
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 });
const baseUrl = options.baseUrl ?? process.env.LIGHTER_BASE_URL ?? undefined;
// A spot-only symbol implies its venue, but only when nothing more explicit was given —
// otherwise the default (testnet) would be picked for a market that does not exist there.
const presetEnvHint = SPOT_PRESET_ENVIRONMENTS[normalizeSymbolKey(this.marketSymbol)];
this.network = resolveLighterNetwork({
environment: options.environment ?? process.env.LIGHTER_ENV ?? (baseUrl ? undefined : presetEnvHint),
baseUrl,
wsUrl: options.wsUrl ?? process.env.LIGHTER_WS_URL,
chainId: options.chainId,
});
this.environment = this.network.environment;
// Market ids are per-deployment, so presets can only be applied once the venue is known.
this.applyPresetMarket();
this.wsUrl = this.network.wsUrl;
this.http = new LighterHttpClient({ baseUrl: this.network.restUrl });
this.signer = new LighterSigner({
accountIndex: options.accountIndex,
chainId: options.chainId ?? (this.environment === "mainnet" ? 304 : 300),
chainId: this.network.chainId,
apiKeys: options.apiKeys,
baseUrl: host,
baseUrl: this.network.restUrl,
});
this.apiKeyIndices = options.apiKeyIndices ?? Object.keys(options.apiKeys).map(Number);
if (this.forcedSpotPreset && this.apiKeyIndices.length > 1) {
@@ -347,9 +340,6 @@ export class LighterGateway {
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;
@@ -359,6 +349,16 @@ export class LighterGateway {
this.lastOrdersUpdateAt = now;
this.lastAccountUpdateAt = now;
this.lastTickerUpdateAt = now;
this.announceNetwork();
}
/** One line so an operator can confirm which venue the bot actually attached to. */
private announceNetwork(): void {
// eslint-disable-next-line no-console
console.error(
`[Lighter] env=${this.environment ?? "custom"} rest=${this.network.restUrl} ws=${this.network.wsUrl} ` +
`chainId=${this.network.chainId} account=${Number(this.signer.accountIndex)}`
);
}
async ensureInitialized(): Promise<void> {
@@ -520,16 +520,64 @@ export class LighterGateway {
this.startStaleMonitor();
}
/**
* Proves the REST host really is the deployment the config claims, before a single order is
* signed. The signing chain id is not exposed by any endpoint, so it can only be validated
* indirectly: `layer1BasicInfo` carries the L1 chain id and the ZkLighter contract address,
* both unique per deployment. A mismatch means REST, websocket and chain id have drifted
* apart — every transaction would be signed for the wrong chain — so it fails closed.
*/
private async verifyNetworkIdentity(): Promise<void> {
if (this.networkVerified) return;
const { expectedL1ChainId, expectedZkLighterContract } = this.network;
if (expectedL1ChainId == null && expectedZkLighterContract == null) {
this.networkVerified = true;
return;
}
let info: Awaited<ReturnType<LighterHttpClient["getLayer1BasicInfo"]>>;
try {
info = await this.http.getLayer1BasicInfo();
} catch (error) {
// An auxiliary endpoint being unreachable must not block trading; the real calls will
// surface a connectivity problem on their own.
this.logger("verifyNetwork", error);
return;
}
const actualL1ChainId = info.l1_providers?.[0]?.chainId ?? null;
const actualContract =
info.contract_addresses?.find((entry) => entry.name === "ZkLighterContract")?.address ?? null;
const mismatches: string[] = [];
if (expectedL1ChainId != null && actualL1ChainId != null && actualL1ChainId !== expectedL1ChainId) {
mismatches.push(`L1 chainId ${actualL1ChainId} (expected ${expectedL1ChainId})`);
}
if (
expectedZkLighterContract &&
actualContract &&
actualContract.toLowerCase() !== expectedZkLighterContract.toLowerCase()
) {
mismatches.push(`ZkLighter contract ${actualContract} (expected ${expectedZkLighterContract})`);
}
if (mismatches.length) {
throw new Error(
`Lighter network mismatch: ${this.network.restUrl} reports ${mismatches.join(" and ")}. ` +
`Config claims env=${this.environment ?? "custom"} (signing chainId ${this.network.chainId}). ` +
`Fix LIGHTER_ENV / LIGHTER_BASE_URL before trading.`
);
}
this.networkVerified = true;
}
private async loadMetadata(): Promise<void> {
await this.verifyNetworkIdentity();
const books = await this.http.getOrderBooks();
const desiredSymbol = this.marketSymbol;
const wantsSpot = guessMarketType(desiredSymbol) === "spot" || this.marketType === "spot";
this.logger("loadMetadata", { desiredSymbol, wantsSpot, presetMarketId: this.marketId, bookCount: books.length });
let target: LighterOrderBookMetadata | null = null;
if (!this.marketId && wantsSpot) {
const normalized = desiredSymbol.toUpperCase().replace(/[^A-Z0-9]/g, "");
const preset = KNOWN_SPOT_MARKETS[normalized];
if (!this.marketId && wantsSpot && this.environment) {
const preset = KNOWN_SPOT_MARKETS[this.environment]?.[normalizeSymbolKey(desiredSymbol)];
if (preset) {
this.marketId = preset.marketId;
this.baseAssetSymbol = this.baseAssetSymbol ?? preset.base;
@@ -550,8 +598,11 @@ export class LighterGateway {
target = spotById ?? null;
}
if (!target) {
// Market ids are per-deployment, so a stale id carried over from another venue is the
// most likely cause here.
throw new Error(
`Configured market id ${this.marketId} not found in Lighter order books. Check LIGHTER_ENV/baseUrl matches the venue that lists spot ETH/USDC (e.g., mainnet).`
`Configured market id ${this.marketId} not found on ${this.environment ?? this.network.restUrl}. ` +
`Market ids differ per deployment — clear LIGHTER_MARKET_ID or set one listed by this venue.`
);
}
}
@@ -574,7 +625,9 @@ export class LighterGateway {
`Expected spot market for ${desiredSymbol}, but resolved to market_id=${target.market_id} type=${target.market_type ?? "unknown"}`
);
}
this.assertUnitMultiplier(target);
this.marketId = Number(target.market_id);
this.resolvedMarketSymbol = target.symbol ?? null;
this.marketType = normalizeMarketType(target.market_type) ?? this.marketType ?? guessMarketType(target.symbol);
this.baseAssetId = target.base_asset_id ?? this.baseAssetId;
this.quoteAssetId = target.quote_asset_id ?? this.quoteAssetId;
@@ -596,6 +649,31 @@ export class LighterGateway {
}
}
/**
* Robinhood Chain lists a few tokenized-equity markets whose contract `multiplier` is not 1
* (corporate actions / accrued yield). Size and price scaling here assumes 1.0, so those
* markets are refused rather than traded with quietly wrong quantities. Override only if you
* have verified the scaling yourself.
*/
private assertUnitMultiplier(book: LighterOrderBookMetadata): void {
const raw = book.multiplier;
if (raw == null) return;
const multiplier = Number(raw);
if (!Number.isFinite(multiplier) || Math.abs(multiplier - 1) < 1e-9) return;
if (process.env.LIGHTER_ALLOW_NON_UNIT_MULTIPLIER === "1" || process.env.LIGHTER_ALLOW_NON_UNIT_MULTIPLIER === "true") {
this.logger(
"loadMetadata",
`market ${book.symbol} has multiplier ${raw}; order sizing assumes 1.0 and may be off`
);
return;
}
throw new Error(
`Lighter market ${book.symbol} (id=${book.market_id}) has contract multiplier ${raw}, not 1.0. ` +
`Order size/price scaling assumes 1.0, so trading it could size positions incorrectly. ` +
`Set LIGHTER_ALLOW_NON_UNIT_MULTIPLIER=1 to proceed anyway.`
);
}
private async refreshAccountSnapshot(): Promise<void> {
try {
const auth = await this.ensureAuthToken();
@@ -927,8 +1005,7 @@ export class LighterGateway {
desiredSymbol.includes("/") ||
desiredSymbol.includes("-") ||
desiredSymbol.includes(":") ||
normalizedDesired.includes("USDC") ||
normalizedDesired.endsWith("USD");
SPOT_QUOTE_SUFFIXES.some((suffix) => normalizedDesired.includes(suffix));
const preferred = candidates.filter((book) =>
wantsSpot ? normalizeMarketType(book.market_type) === "spot" : true
);
@@ -1471,7 +1548,8 @@ export class LighterGateway {
private extractMarketIdFromChannel(channel: unknown): number | null {
if (typeof channel !== "string") return null;
const match = channel.match(/account_market:(\d+)/);
// Subscriptions use `account_market/{market}/{account}`; echoes may come back colon-separated.
const match = channel.match(/account_market[:/](\d+)/);
if (match && match[1]) {
const value = Number(match[1]);
return Number.isFinite(value) ? value : null;
@@ -1513,7 +1591,9 @@ export class LighterGateway {
marketId: this.marketId,
marketType: this.marketType ?? guessMarketType(this.marketSymbol),
baseAssetSymbol: this.baseAssetSymbol,
quoteAssetSymbol: this.quoteAssetSymbol,
// Falls back to the venue's settlement asset (USDG on rh, USDC elsewhere) so the
// dashboard never labels a balance with the wrong currency.
quoteAssetSymbol: this.quoteAssetSymbol ?? this.network.defaultQuoteAsset,
baseAssetId: this.baseAssetId,
quoteAssetId: this.quoteAssetId,
}
@@ -1661,10 +1741,17 @@ export class LighterGateway {
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
);
// Neither mainnet nor rh returns market_id in this payload today, so matching falls back
// to the exact venue symbol. Compared delimiter-free (`ETH/USDG` vs `ETHUSDG`) but never
// by base alone, which would let the ETH perp masquerade as the ETH/USDG spot market.
const desiredKey = normalizeSymbolKey(this.resolvedMarketSymbol ?? this.marketSymbol);
const match = stats.find((entry) => {
if (entry.market_id != null && Number(entry.market_id) === marketId) return true;
return entry.symbol ? normalizeSymbolKey(entry.symbol) === desiredKey : false;
});
if (!match) return;
// Cached so estimateMarketPrice has a last-trade fallback when the book is empty.
this.ticker = match;
const ticker = toTicker(this.displaySymbol, match);
this.tickerEvent.emit(ticker);
this.loggedCreateOrderPayload = false;
@@ -1783,8 +1870,9 @@ export class LighterGateway {
}
private applyPresetMarket(): void {
const normalized = (this.marketSymbol ?? "").toUpperCase().replace(/[^A-Z0-9]/g, "");
const preset = KNOWN_SPOT_MARKETS[normalized];
if (!this.environment) return;
const normalized = normalizeSymbolKey(this.marketSymbol);
const preset = KNOWN_SPOT_MARKETS[this.environment]?.[normalized];
if (!preset) return;
if (this.marketId == null) this.marketId = preset.marketId;
if (!this.baseAssetSymbol) this.baseAssetSymbol = preset.base;
@@ -2192,10 +2280,19 @@ function normalizeMarketType(value: string | null | undefined): "perp" | "spot"
return undefined;
}
/**
* Quote assets that mark a compact symbol as spot. Deliberately excludes bare "USD": mainnet
* lists forex perps such as NZDUSD that would otherwise be mistaken for spot pairs.
*/
const SPOT_QUOTE_SUFFIXES = ["USDC", "USDG"];
function guessMarketType(symbol: string | null | undefined): "perp" | "spot" | null {
if (!symbol) return null;
const upper = symbol.toUpperCase();
if (upper.includes("/") || upper.includes("-") || upper.includes(":") || upper.endsWith("USDC")) {
if (upper.includes("/") || upper.includes("-") || upper.includes(":")) {
return "spot";
}
if (SPOT_QUOTE_SUFFIXES.some((suffix) => upper.endsWith(suffix))) {
return "spot";
}
return null;
@@ -2224,6 +2321,11 @@ function tryParseTxInfo(value: string): unknown {
}
}
/** Delimiter-free upper-case form, e.g. `ETH/USDG` and `eth-usdg` both become `ETHUSDG`. */
function normalizeSymbolKey(value: string | null | undefined): string {
return (value ?? "").toUpperCase().replace(/[^A-Z0-9]/g, "");
}
function normalizeSymbolForms(value: string | null | undefined): string[] {
if (!value) return [];
const upper = value.toUpperCase();
+27 -9
View File
@@ -4,7 +4,7 @@ import type {
LighterMarketStats,
LighterOrderBookMetadata,
} from "./types";
import { DEFAULT_LIGHTER_ENVIRONMENT, LIGHTER_HOSTS } from "./constants";
import { DEFAULT_LIGHTER_ENVIRONMENT, LIGHTER_HOSTS, LIGHTER_NETWORKS } from "./constants";
interface ApiResponseBase {
code: number;
@@ -38,6 +38,11 @@ interface NextNonceResponse extends ApiResponseBase {
nonce: number;
}
export interface Layer1BasicInfo extends ApiResponseBase {
l1_providers?: Array<{ chainId?: number; networkId?: number }>;
contract_addresses?: Array<{ name?: string; address?: string }>;
}
export interface SendTxResponse extends ApiResponseBase {
tx_hash: string;
predicted_execution_time_ms?: number;
@@ -78,7 +83,7 @@ export class LighterHttpClient {
constructor(options: LighterHttpClientOptions = {}) {
const env = options.environment ?? DEFAULT_LIGHTER_ENVIRONMENT;
const host = options.baseUrl ?? LIGHTER_HOSTS[env]?.rest;
const host = options.baseUrl ?? LIGHTER_NETWORKS[env]?.rest;
if (!host) {
throw new Error(`Unknown Lighter environment: ${env}`);
}
@@ -95,25 +100,31 @@ export class LighterHttpClient {
return response.order_books ?? [];
}
async getLayer1BasicInfo(): Promise<Layer1BasicInfo> {
return this.get<Layer1BasicInfo>("/api/v1/layer1BasicInfo");
}
async getExchangeStats(): Promise<LighterMarketStats[]> {
const response = await this.get<ExchangeStatsResponse>("/api/v1/exchangeStats");
const stats = response.order_book_stats ?? [];
// Both mainnet and rh return prices as JSON numbers here while the shared types (and the
// Ticker contract) declare strings, so normalize instead of leaking numbers downstream.
return stats.map((entry) => ({
market_id: entry.market_id,
symbol: entry.symbol,
market_type: (entry as any).market_type,
index_price: (entry as any).index_price ?? entry.mark_price ?? entry.last_trade_price,
mid_price: (entry as any).mid_price,
mark_price: entry.mark_price ?? (entry as any).mid_price ?? entry.last_trade_price,
last_trade_price: entry.last_trade_price,
open_interest: (entry as any).open_interest ?? "0",
index_price: toPriceString((entry as any).index_price ?? entry.mark_price ?? entry.last_trade_price) ?? "0",
mid_price: toPriceString((entry as any).mid_price),
mark_price: toPriceString(entry.mark_price ?? (entry as any).mid_price ?? entry.last_trade_price),
last_trade_price: toPriceString(entry.last_trade_price) ?? "0",
open_interest: toPriceString((entry as any).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,
current_funding_rate: toPriceString(entry.current_funding_rate),
funding_rate: toPriceString(entry.funding_rate),
funding_timestamp: entry.funding_timestamp,
}));
}
@@ -293,3 +304,10 @@ export class LighterHttpClient {
function truncateBody(body: string, limit = 200): string {
return body.length > limit ? `${body.slice(0, limit)}` : body;
}
function toPriceString(value: unknown): string | undefined {
if (value == null) return undefined;
if (typeof value === "string") return value;
if (typeof value === "number") return Number.isFinite(value) ? String(value) : undefined;
return undefined;
}
+149
View File
@@ -0,0 +1,149 @@
import {
DEFAULT_LIGHTER_ENVIRONMENT,
LIGHTER_APP_HOSTS,
LIGHTER_ENVIRONMENT_ALIASES,
LIGHTER_NETWORKS,
type LighterEnvironment,
} from "./constants";
export interface LighterNetworkResolution {
/** `null` only for a self-hosted/proxied REST host we cannot map to a known deployment. */
environment: LighterEnvironment | null;
restUrl: string;
wsUrl: string;
chainId: number;
expectedL1ChainId: number | null;
expectedZkLighterContract: string | null;
defaultQuoteAsset: string;
}
export interface ResolveLighterNetworkOptions {
environment?: string | null;
baseUrl?: string | null;
wsUrl?: string | null;
chainId?: number | null;
}
const KNOWN_ENVIRONMENTS = Object.keys(LIGHTER_NETWORKS) as LighterEnvironment[];
function isLighterEnvironment(value: string): value is LighterEnvironment {
return Object.prototype.hasOwnProperty.call(LIGHTER_NETWORKS, value);
}
/**
* Canonicalizes a user-supplied environment name. Returns `null` for empty input and throws
* on a non-empty unknown value — silently falling back would point a live bot at the wrong
* chain, which is exactly the failure this module exists to prevent.
*/
export function normalizeEnvironmentName(value: string | null | undefined): LighterEnvironment | null {
if (value == null) return null;
const trimmed = String(value).trim().toLowerCase();
if (!trimmed) return null;
if (isLighterEnvironment(trimmed)) return trimmed;
const alias = LIGHTER_ENVIRONMENT_ALIASES[trimmed];
if (alias) return alias;
throw new Error(
`Unknown Lighter environment "${value}". Valid values: ${KNOWN_ENVIRONMENTS.join(", ")} ` +
`(aliases: ${Object.keys(LIGHTER_ENVIRONMENT_ALIASES).join(", ")})`
);
}
function extractHostname(value: string | null | undefined): string | null {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed) return null;
try {
return new URL(trimmed).hostname.toLowerCase();
} catch {
// Bare hostnames ("api.rh.lighter.xyz") are accepted too.
const withoutPath = trimmed.split("/")[0] ?? "";
return withoutPath.toLowerCase() || null;
}
}
/** Maps a web-app hostname (not an API host) onto the deployment it belongs to. */
export function detectEnvironmentFromAppHost(value: string | null | undefined): LighterEnvironment | null {
const host = extractHostname(value);
if (!host) return null;
return LIGHTER_APP_HOSTS[host] ?? null;
}
/**
* Maps an API hostname onto a known deployment. Order matters: the Robinhood hosts are matched
* before the substring rules, because `api.rh-testnet.lighter.xyz` contains "testnet" and would
* otherwise be mistaken for the zklighter testnet.
*/
export function detectEnvironmentFromUrl(value: string | null | undefined): LighterEnvironment | null {
const host = extractHostname(value);
if (!host) return null;
for (const env of KNOWN_ENVIRONMENTS) {
const configured = extractHostname(LIGHTER_NETWORKS[env].rest);
if (configured && configured === host) return env;
}
if (host.includes("rh-testnet.lighter") || host.includes("robinhood-testnet")) return "rh-testnet";
if (host.includes("rh.lighter") || host.includes("robinhood")) return "rh";
if (host.includes("mainnet")) return "mainnet";
if (host.includes("testnet")) return "testnet";
if (host.includes("staging")) return "staging";
if (host.includes("dev")) return "dev";
return null;
}
/** Turns a REST base URL into the matching stream URL for a self-hosted deployment. */
export function deriveWebSocketUrl(restUrl: string): string {
const trimmed = restUrl.trim().replace(/\/+$/, "");
const withScheme = /^[a-z]+:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
const swapped = withScheme.replace(/^http:\/\//i, "ws://").replace(/^https:\/\//i, "wss://");
return swapped.endsWith("/stream") ? swapped : `${swapped}/stream`;
}
function sameHost(a: string, b: string): boolean {
const hostA = extractHostname(a);
const hostB = extractHostname(b);
return hostA != null && hostA === hostB;
}
/**
* Single place where REST host, websocket host and signing chain id are decided together.
* Precedence: explicit environment > web-app hostname > API hostname > default environment.
*/
export function resolveLighterNetwork(options: ResolveLighterNetworkOptions = {}): LighterNetworkResolution {
const explicitEnv = normalizeEnvironmentName(options.environment);
const baseUrl = options.baseUrl?.trim() || null;
const appHostEnv = detectEnvironmentFromAppHost(baseUrl);
const detectedEnv = detectEnvironmentFromUrl(baseUrl);
const environment: LighterEnvironment | null =
explicitEnv ?? appHostEnv ?? detectedEnv ?? (baseUrl ? null : DEFAULT_LIGHTER_ENVIRONMENT);
const config = environment ? LIGHTER_NETWORKS[environment] : null;
// A web-app URL never serves the API, so it selects the deployment and is then discarded.
const restUrl = (appHostEnv ? config?.rest : baseUrl ?? config?.rest) ?? config?.rest ?? null;
if (!restUrl) {
throw new Error("Lighter REST base URL could not be resolved; set LIGHTER_ENV or LIGHTER_BASE_URL");
}
const explicitWs = options.wsUrl?.trim() || null;
const wsUrl =
explicitWs ?? (config && sameHost(restUrl, config.rest) ? config.ws : deriveWebSocketUrl(restUrl));
const chainId = options.chainId ?? config?.chainId ?? null;
if (chainId == null) {
throw new Error(
`Cannot determine the Lighter signing chain id for host ${extractHostname(restUrl) ?? restUrl}. ` +
`Set LIGHTER_ENV to a known deployment (${KNOWN_ENVIRONMENTS.join(", ")}) or set LIGHTER_CHAIN_ID explicitly.`
);
}
return {
environment,
restUrl: restUrl.replace(/\/+$/, ""),
wsUrl,
chainId,
expectedL1ChainId: config?.l1ChainId ?? null,
expectedZkLighterContract: config?.zkLighterContract ?? null,
defaultQuoteAsset: config?.defaultQuoteAsset ?? "USDC",
};
}
+2
View File
@@ -144,6 +144,8 @@ export interface LighterOrderBookMetadata {
supported_price_decimals: number;
supported_quote_decimals: number;
status: "inactive" | "frozen" | "active" | string;
/** Contract multiplier; "1.0" everywhere except a few tokenized-equity markets on rh. */
multiplier?: string;
}
export interface LighterAccountMarketUpdate {
+2
View File
@@ -84,6 +84,8 @@ function resolveLighterCredentials(symbol: string): LighterCredentials {
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,
wsUrl: process.env.LIGHTER_WS_URL,
chainId: process.env.LIGHTER_CHAIN_ID ? Number(process.env.LIGHTER_CHAIN_ID) : undefined,
l1Address: process.env.LIGHTER_L1_ADDRESS,
marketSymbol: process.env.LIGHTER_SYMBOL,
marketId: process.env.LIGHTER_MARKET_ID ? Number(process.env.LIGHTER_MARKET_ID) : undefined,
+136
View File
@@ -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");
});
});
+172
View File
@@ -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);
});
});
+131
View File
@@ -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);
});
});