# Lighter — instances, hosts, and REST API

Two independent deployments of the same exchange, where the robinhoodchain instance host comes from, which reads are public and which require an auth token, the response shapes for `orderBooks` / `orderBookDetails` / `account` / `accountActiveOrders`, and which metadata is needed for trading. Facts were verified on the robinhoodchain instance; regular zkLighter mainnet was not verified live.

## TL;DR

1. **Lighter is one exchange with two independent deployments.** Regular mainnet (zkLighter) is at `https://mainnet.zklighter.elliot.ai`. The robinhoodchain instance (“RH,” commonly called “Robinhood”) is at `https://api.rh.lighter.xyz`. They have **different market lists, different accounts, and different `account_index` values**; an order on one instance is not visible on the other. Code written for one host can be moved to the other by replacing the base URL, but metadata and `market_id` values must be read again. *Verified with live requests 2026-08-20 (RH); mainnet is based on documentation and was not verified live.*
2. **The RH host is not in the frontend HTML**—it is extracted from the JS bundle (`/assets/index-*.js`). If the host changes, look for it there. *Verified 2026-08-20.*
3. **Public reads without a signature or token:** `/api/v1/orderBooks`, `/api/v1/orderBookDetails`, `/api/v1/account?by=index&value=<account_index>`. **Private read with an auth token:** `/api/v1/accountActiveOrders?account_index=<N>` (`authorization: <token>` header). The signer issues the token (`create_auth_token_with_expiry`, 10-minute lifetime). *Verified 2026-08-20.*
4. **`orderBookDetails` is the only metadata request needed for trading:** `market_id`, `supported_size_decimals`, `supported_price_decimals`, `min_base_amount`, `min_quote_amount`, `min_initial_margin_fraction`, `mark_price`, and `status`. The leverage cap is `floor(10000 / min_initial_margin_fraction)`. No separate mid-price request is needed: `mark_price` comes from the same response. *Verified 2026-08-20.*
5. **`accountActiveOrders` without `market_id` (and with `market_id=255`) returns orders for every market in one request.** The list is genuinely complete; no per-market sweep is needed. *Verified with a live order 2026-08-20.*
6. **Numbers arrive as strings** (`"1605.34"`, `"0.0100"`, `"50.00"`), while order identifiers arrive both as a string (`order_id`) and a number (`order_index`). The numeric form has **already been corrupted by `JSON.parse`** (values around 1e16 > 2^53). Only the string identifies the order. *Verified 2026-08-21.* → `orders.md` §6
7. **One value uses two scales.** `orderBookDetails.min_initial_margin_fraction` is in hundredths of a percent (`1000` = 10%); `account.positions[].initial_margin_fraction` is a percentage string (`"50.00"` = 50%). Divide by 10,000 and 100, respectively. *Verified 2026-08-23.* → `account-and-leverage.md` §3
8. **Reads do not count toward the 40/60 s write window**, but they have their own limit: an initial burst of `HTTP 429` responses on reads after restart was observed and cleared within a minute. Retry reads on 5xx and timeout; do not retry 4xx (including 429)—a read 429 is handled by cached metadata and the next poll. *Observed 2026-08.* → `rate-limits.md`

---

## 1. Two instances

| | zkLighter mainnet | robinhoodchain instance (“RH”) |
|---|---|---|
| REST base URL | `https://mainnet.zklighter.elliot.ai` | `https://api.rh.lighter.xyz` |
| Host source | Lighter documentation | instance frontend JS bundle (`/assets/index-*.js`); not present in HTML |
| Market list | its own (not verified live) | 40 base perpetuals + 26 `*/USDG` duplicates as of 2026-08-23 |
| Accounts | its own `account_index` values and registered L1 address | its own; the L1 address is the owner wallet’s same EVM address |
| Signing | one Lighter scheme (Python/Go SDK) | same scheme and SDK, different `url` in `SignerClient` |
| Knowledge-base facts | based on documentation, not verified live | verified through live requests and orders |

What this means for code:

- **One process — one instance.** Base URL, `account_index`, `api_key_index`, and key form one deployment-specific set. Running two instances in one process has not been tried and is not recommended: their write windows and metadata are independent, and identical market names can have different `market_id` values.
- **“Robinhood”** commonly means the robinhoodchain instance, not regular mainnet. Before any claim that “X is listed on Lighter,” identify the instance and query its `/api/v1/orderBooks` endpoint.
- Everything below about response shapes was verified on RH. Treat mainnet behavior as “documented, not verified” until checked with a live request.

---

## 2. Public reads

### 2.1 `/api/v1/orderBooks` — market list

A flat list of instance markets. It is useful for checking listings (which assets exist on the instance at all). It is not needed for trading because everything here is also available in `orderBookDetails`. *Verified 2026-08-23.*

### 2.2 `/api/v1/orderBookDetails` — metadata, mark price, margin fractions

Response: `{ order_book_details: [ … ] }`. Fields needed for trading (one entry per market):

| Field | Wire type | Meaning | Example |
|---|---|---|---|
| `symbol` | string | market name as known by the exchange | `ETH`, `SNDK`, `ETH/USDG` |
| `market_id` | number | market identifier; also `market_index` in orders | `32` |
| `market_type` | string | accept only `'perp'` | `perp` |
| `status` | string | trade only `'active'`; other values were not observed | `active` |
| `supported_size_decimals` | number | decimal places in size; lot = `10^-n` | `4` |
| `supported_price_decimals` | number | decimal places in price | `2` |
| `min_base_amount` | string | **lot-based minimum** in base units, independent of the dollar minimum | `"0.0100"` |
| `min_quote_amount` | string | dollar order minimum | `"10.000000"` |
| `mark_price` | string | price used by the exchange for margin and liquidation | `"1605.34"` |
| `last_trade_price` | string | last trade; jumps on thin markets, do not use as the mid | |
| `min_initial_margin_fraction` | string | minimum initial fraction, in hundredths of a percent | `"1000"` = 10% → 10x cap |
| `default_initial_margin_fraction` | string | default fraction for an account that has not set leverage | `"5000"` = 50% = 2x |

Parsing example:

```ts
// Lighter market metadata → values for quantization and leverage. Numbers arrive as strings.
interface LighterMarket {
  coin: string; marketId: number;
  sizeDecimals: number; priceDecimals: number;
  minBase: number;         // min_base_amount—the lot-based minimum
  minNotionalUsd: number;  // min_quote_amount
  markPrice: number;       // mark_price
  maxLeverage: number;     // floor(10000 / min_initial_margin_fraction)
  status: string;
}

function decodeMarkets(payload: unknown): Map<string, LighterMarket> {
  const books = (payload as { order_book_details?: unknown })?.order_book_details;
  if (!Array.isArray(books)) throw new Error('orderBookDetails: order_book_details is not an array');
  const out = new Map<string, LighterMarket>();
  const seenIds = new Set<number>();
  for (const raw of books as Array<Record<string, unknown>>) {
    if (raw.market_type !== 'perp') continue;
    const coin = typeof raw.symbol === 'string' ? raw.symbol.trim() : '';
    const marketId = Number(raw.market_id);
    if (!coin || !Number.isInteger(marketId)) throw new Error(`orderBookDetails: malformed record`);
    if (out.has(coin) || seenIds.has(marketId)) throw new Error(`orderBookDetails: duplicate ${coin}/${marketId}`);
    seenIds.add(marketId);
    const sizeDecimals = Number(raw.supported_size_decimals);
    const priceDecimals = Number(raw.supported_price_decimals);
    if (!(sizeDecimals >= 0) || !(priceDecimals >= 0)) throw new Error(`orderBookDetails: missing precision for ${coin}`);
    const imf = Number(raw.min_initial_margin_fraction);
    out.set(coin, {
      coin, marketId, sizeDecimals, priceDecimals,
      minBase: Number(raw.min_base_amount) || 0,
      minNotionalUsd: Number(raw.min_quote_amount) || 0,
      markPrice: Number(raw.mark_price) || 0,
      maxLeverage: Math.max(1, Math.floor(10_000 / (imf > 0 ? imf : 10_000))),
      status: typeof raw.status === 'string' ? raw.status : '',
    });
  }
  if (out.size === 0) throw new Error('orderBookDetails: no perpetual markets');
  return out;
}
```

Metadata-cache rules:

- Cache TTL is 5 minutes, refresh is single-flight, and a failed refresh returns the stale cache with a loud warning and its age, then retries no sooner than 30 seconds. A metadata error aborts every order, including closes, so “crashing” is worse than “serving stale.”
- Protection against a “bad” refresh is **targeted**: reject the refresh only if it loses a market where you have a position or order, or if such a market changes `market_id` or decimals. A rule that “no previously seen asset may disappear” freezes the cache until restart after the first market rename. Delisting an unrelated asset must not blind the process to every other one.
- Duplicate `symbol` or `market_id` in the response rejects the entire parse: the payload cannot be trusted.

### 2.3 `/api/v1/account?by=index&value=<account_index>` — account and positions

Response: `{ accounts: [ { … } ] }`; use `accounts[0]`. Public: no token is required, and anyone who knows `account_index` can read the account state. *Verified 2026-08-20.*

| Field | Meaning |
|---|---|
| `index` | `account_index` |
| `l1_address` | EVM address of the account owner. At startup, use it to verify that the configured `account_index` belongs to the expected address |
| `total_asset_value` | account value—use this as equity |
| `collateral` | collateral |
| `available_balance` | available for new orders |
| `total_order_count` | number of orders |
| `positions[]` | positions, described below |

A position (`positions[]`) contains `market_id`, `symbol`, **separate `sign` and `position`** (the magnitude is always positive; the sign is in `sign`), `avg_entry_price`, `position_value`, `unrealized_pnl`, `initial_margin_fraction` (a percentage string, `"50.00"`), and `margin_mode` (`"1"` = isolated, otherwise cross). Details → `account-and-leverage.md`.

### 2.4 Auth token and `/api/v1/accountActiveOrders`

- The signer issues the token: `create_auth_token_with_expiry(SignerClient.DEFAULT_10_MIN_AUTH_EXPIRY)` → `(token, err)`. It lives for 10 minutes. Cache it for **5 minutes** (half its lifetime; also the default in `lighter-kit`) and refresh it after 401/403. *Verified 2026-08-20.*
- Header: `authorization: <token>`—**without** the `Bearer` prefix (verified in this form; `Bearer` was not tested).
- `GET /api/v1/accountActiveOrders?account_index=<N>` → `{ orders: [ … ] }`. **Without `market_id`, it returns all markets at once**, as does `market_id=255`. Order fields → `orders.md` §6.
- If `orders` is not an array, this is a read error, not “no orders.” “Could not read” ≠ “empty”: irreversible decisions (“account is empty,” delete memory of your own orders) require a successful read.

### 2.5 Read transport policy

- Fetch timeout 15 seconds; up to 3 attempts with a `400 ms × attempt number` delay.
- **4xx is an exchange response; do not retry** (except 401/403 on authenticated reads—refresh the token and retry). Retry 5xx, timeout, and connection loss. Under this policy, read 429 falls into 4xx: the request fails immediately, and the initial burst is handled by cached metadata (served stale with a warning) and the next poll. A separate “429 → wait and retry” branch for reads has not been verified (open question).
- Distinguish transport errors from exchange rejections with a separate class: for placements, an exchange rejection is a known outcome, while a network failure is **unknown**. → `orders.md` §8

---

## 3. Metadata needed for trading — summary

| Value | Source | Purpose |
|---|---|---|
| `market_id` | `orderBookDetails` | `market_index` in `create_order` / `cancel_order` / `update_leverage`; map `market_index` → `symbol` when reading orders |
| `supported_size_decimals` | same | lot `10^-n`, `base_amount = round(sz × 10^n)` |
| `supported_price_decimals` | same | `price = round(px × 10^n)`, `pxToStr = px.toFixed(n)` |
| `min_base_amount` | same | second minimum; code 21706 when violated |
| `min_quote_amount` | same | dollar minimum ($10 on RH) |
| `mark_price` | same | mid used to calculate order prices; 21734 “too far from the mark” rejections are measured from it |
| `min_initial_margin_fraction` | same | leverage cap `floor(10000 / x)` |
| `status` | same | trade only `active` |

---

## 4. Known only from documentation (not verified)

- The Lighter WebSocket stream (`/stream`) was not used; frame shapes, subscription limits, and behavior on RH were not checked.
- Other REST endpoints (trade history, candles, funding, `orderBookOrders`, `recentTrades`) were not used; their response shapes were not checked.
- Market `status` values other than `active` were not observed; their meanings and market behavior are unknown.
- Whether `Authorization: Bearer <token>` works alongside `authorization: <token>` was not verified.

---

## Pitfalls

| What breaks | Why | Correct approach |
|---|---|---|
| “Market X is not on Lighter”—but it is | checked the wrong instance | always name the instance and query its `orderBooks` |
| RH host “disappears” | it is not in HTML | extract it from the frontend JS bundle |
| Mid jumps and orders are rejected | used `last_trade_price` | mid = `mark_price` from `orderBookDetails` |
| Margin and ROE are off by 100× | `min_initial_margin_fraction` and `initial_margin_fraction` use different scales | `/10000` and `/100`, respectively (verified 2026-08-23) |
| Metadata cache “freezes” after a market rename | protection says “no asset may disappear” | targeted protection only for markets with your own position or order |
| Cancellation goes nowhere | `order_index` was rounded by `JSON.parse` | order identity is the `order_id` string (verified 2026-08-21) |
| Retries on `HTTP 404`/`400` | 4xx was treated as a transport error | 4xx is an exchange response; do not retry |

---

## Open questions / not verified

- Regular zkLighter mainnet: market list, minimums, limits, and response shapes were not checked live; the assumption that it matches RH is not verified.
- Whether RH and mainnet differ in error codes or write limits is unknown (all measurements are from RH).
- Exact limit on public reads (only an initial burst of 429 responses that cleared within a minute was observed).
- `accountActiveOrders` behavior with a very large number of orders (pagination?)—verified responses arrived complete; the limit was not measured.
- `Bearer` prefix for the auth token; token lifetime beyond `DEFAULT_10_MIN_AUTH_EXPIRY`.

---

Facts verified through 2026-09-16. The Lighter API changes—verify hosts, limits, and response shapes with a live request, especially before the first order on a new instance.

---

<!-- license-footer -->
_© markpaper authors. Licensed under [CC BY 4.0](LICENSE.md): when publishing or adapting this material, credit “markpaper — Lighter knowledge base” and link to the original and the license._
