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
- 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 athttps://api.rh.lighter.xyz. They have different market lists, different accounts, and differentaccount_indexvalues; 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 andmarket_idvalues must be read again. Verified with live requests 2026-08-20 (RH); mainnet is based on documentation and was not verified live. - 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. - 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. orderBookDetailsis 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, andstatus. The leverage cap isfloor(10000 / min_initial_margin_fraction). No separate mid-price request is needed:mark_pricecomes from the same response. Verified 2026-08-20.accountActiveOrderswithoutmarket_id(and withmarket_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.- 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 byJSON.parse(values around 1e16 > 2^53). Only the string identifies the order. Verified 2026-08-21. →orders.md§6 - One value uses two scales.
orderBookDetails.min_initial_margin_fractionis in hundredths of a percent (1000= 10%);account.positions[].initial_margin_fractionis a percentage string ("50.00"= 50%). Divide by 10,000 and 100, respectively. Verified 2026-08-23. →account-and-leverage.md§3 - Reads do not count toward the 40/60 s write window, but they have their own limit: an initial burst of
HTTP 429responses 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 differentmarket_idvalues. - “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/orderBooksendpoint. - 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:
// 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_idor 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
symbolormarket_idin 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 inlighter-kit) and refresh it after 401/403. Verified 2026-08-20. - Header:
authorization: <token>—without theBearerprefix (verified in this form;Bearerwas not tested). GET /api/v1/accountActiveOrders?account_index=<N>→{ orders: [ … ] }. Withoutmarket_id, it returns all markets at once, as doesmarket_id=255. Order fields →orders.md§6.- If
ordersis 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 numberdelay. - 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
statusvalues other thanactivewere not observed; their meanings and market behavior are unknown. - Whether
Authorization: Bearer <token>works alongsideauthorization: <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).
accountActiveOrdersbehavior with a very large number of orders (pagination?)—verified responses arrived complete; the limit was not measured.Bearerprefix for the auth token; token lifetime beyondDEFAULT_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.
© markpaper authors. Licensed under CC BY 4.0: when publishing or adapting this material, credit “markpaper — Lighter knowledge base” and link to the original and the license.