From f3a96886ac5a3436917aadb9e7dcd3b593fc47c4 Mon Sep 17 00:00:00 2001 From: discountry Date: Fri, 14 Aug 2026 14:05:37 +0800 Subject: [PATCH] feat(lighter): add Robinhood Chain venue support --- README.md | 1 + README_en.md | 1 + docs/exchanges/lighter.en.md | 68 +++-- docs/exchanges/lighter.md | 68 +++-- src/exchanges/lighter/adapter.ts | 4 +- src/exchanges/lighter/constants.ts | 92 ++++++- src/exchanges/lighter/gateway.ts | 286 ++++++++++++++------- src/exchanges/lighter/http-client.ts | 36 ++- src/exchanges/lighter/network.ts | 149 +++++++++++ src/exchanges/lighter/types.ts | 2 + src/exchanges/resolve-from-env.ts | 2 + tests/lighter/gateway-construction.test.ts | 136 ++++++++++ tests/lighter/gateway-venue.test.ts | 172 +++++++++++++ tests/lighter/network.test.ts | 131 ++++++++++ 14 files changed, 1009 insertions(+), 139 deletions(-) create mode 100644 src/exchanges/lighter/network.ts create mode 100644 tests/lighter/gateway-construction.test.ts create mode 100644 tests/lighter/gateway-venue.test.ts create mode 100644 tests/lighter/network.test.ts diff --git a/README.md b/README.md index 23a70dc..98adff5 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/README_en.md b/README_en.md index d5d0747..d9dda4e 100644 --- a/README_en.md +++ b/README_en.md @@ -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) diff --git a/docs/exchanges/lighter.en.md b/docs/exchanges/lighter.en.md index 37b67f6..7487c71 100644 --- a/docs/exchanges/lighter.en.md +++ b/docs/exchanges/lighter.en.md @@ -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= +LIGHTER_API_KEY_INDEX= +LIGHTER_API_PRIVATE_KEY= +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 diff --git a/docs/exchanges/lighter.md b/docs/exchanges/lighter.md index ba4f43c..455ecab 100644 --- a/docs/exchanges/lighter.md +++ b/docs/exchanges/lighter.md @@ -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= +LIGHTER_API_KEY_INDEX= +LIGHTER_API_PRIVATE_KEY= +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 环境。 ## 安全要求 diff --git a/src/exchanges/lighter/adapter.ts b/src/exchanges/lighter/adapter.ts index 59e81d8..9218438 100644 --- a/src/exchanges/lighter/adapter.ts +++ b/src/exchanges/lighter/adapter.ts @@ -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, diff --git a/src/exchanges/lighter/constants.ts b/src/exchanges/lighter/constants.ts index 2100d52..87daf27 100644 --- a/src/exchanges/lighter/constants.ts +++ b/src/exchanges/lighter/constants.ts @@ -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 = { +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 = { 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 = { - mainnet: 304, - testnet: 300, - staging: 300, - dev: 300, +/** Spellings users actually type, mapped onto canonical environment names. */ +export const LIGHTER_ENVIRONMENT_ALIASES: Record = { + 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 = { + "app.lighter.xyz": "mainnet", + "robinhoodchain.lighter.xyz": "rh", +}; + +export const LIGHTER_HOSTS: Record = Object.fromEntries( + Object.entries(LIGHTER_NETWORKS).map(([env, config]) => [env, { rest: config.rest, ws: config.ws }]) +) as Record; + +export const LIGHTER_CHAIN_IDS: Record = Object.fromEntries( + Object.entries(LIGHTER_NETWORKS).map(([env, config]) => [env, config.chainId]) +) as Record; + export const DEFAULT_LIGHTER_ENVIRONMENT: LighterEnvironment = "testnet"; export const DEFAULT_TRANSACTION_EXPIRY_BUFFER_MS = 10 * 60 * 1000 - 1000; // 10 min minus 1s diff --git a/src/exchanges/lighter/gateway.ts b/src/exchanges/lighter/gateway.ts index 76b6859..addb982 100644 --- a/src/exchanges/lighter/gateway.ts +++ b/src/exchanges/lighter/gateway.ts @@ -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(): SimpleEvent { }; } -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; klines: Map>; @@ -181,8 +139,34 @@ const TERMINAL_ORDER_STATUSES = new Set([ "canceled-reduce-only", ]); -const KNOWN_SPOT_MARKETS: Record = { - ETHUSDC: { marketId: 2048, base: "ETH", quote: "USDC", priceDecimals: 2, sizeDecimals: 4 }, +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>> = { + 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 = { + ETHUSDC: "mainnet", + ETHUSDG: "rh", }; export interface LighterGatewayOptions { @@ -191,7 +175,9 @@ export interface LighterGatewayOptions { accountIndex: number; apiKeys: Record; 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 | 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 { @@ -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 { + if (this.networkVerified) return; + const { expectedL1ChainId, expectedZkLighterContract } = this.network; + if (expectedL1ChainId == null && expectedZkLighterContract == null) { + this.networkVerified = true; + return; + } + let info: Awaited>; + 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 { + 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 { 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(); diff --git a/src/exchanges/lighter/http-client.ts b/src/exchanges/lighter/http-client.ts index 3c34bec..de6205a 100644 --- a/src/exchanges/lighter/http-client.ts +++ b/src/exchanges/lighter/http-client.ts @@ -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 { + return this.get("/api/v1/layer1BasicInfo"); + } + async getExchangeStats(): Promise { const response = await this.get("/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; +} diff --git a/src/exchanges/lighter/network.ts b/src/exchanges/lighter/network.ts new file mode 100644 index 0000000..e45c9de --- /dev/null +++ b/src/exchanges/lighter/network.ts @@ -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", + }; +} diff --git a/src/exchanges/lighter/types.ts b/src/exchanges/lighter/types.ts index aa1f1f2..2d73178 100644 --- a/src/exchanges/lighter/types.ts +++ b/src/exchanges/lighter/types.ts @@ -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 { diff --git a/src/exchanges/resolve-from-env.ts b/src/exchanges/resolve-from-env.ts index eb55293..107b380 100644 --- a/src/exchanges/resolve-from-env.ts +++ b/src/exchanges/resolve-from-env.ts @@ -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, diff --git a/tests/lighter/gateway-construction.test.ts b/tests/lighter/gateway-construction.test.ts new file mode 100644 index 0000000..211991a --- /dev/null +++ b/tests/lighter/gateway-construction.test.ts @@ -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 = {}; + +const build = (options: Record = {}) => + 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"); + }); +}); diff --git a/tests/lighter/gateway-venue.test.ts b/tests/lighter/gateway-venue.test.ts new file mode 100644 index 0000000..a6521e9 --- /dev/null +++ b/tests/lighter/gateway-venue.test.ts @@ -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 = (method: string, context: Record, ...args: unknown[]): T => + (LighterGateway.prototype as any)[method].apply(context, args); + +const book = (overrides: Partial): 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) => { + 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>("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>("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>("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, 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>("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>("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>("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>("verifyNetworkIdentity", context)).resolves.toBeUndefined(); + expect(context.networkVerified).toBe(false); + }); +}); diff --git a/tests/lighter/network.test.ts b/tests/lighter/network.test.ts new file mode 100644 index 0000000..f218e77 --- /dev/null +++ b/tests/lighter/network.test.ts @@ -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); + }); +});