SKILL.md
vregistry-c914171 · 22.5 KB
---
name: lighter
license: CC-BY-4.0. Publication and distribution require attribution to “markpaper — Lighter knowledge base.” Full terms are in LICENSE.md next to the skill.
description: Practical, verified rules for the Lighter exchange (zkLighter mainnet and the robinhoodchain instance, also known as “Robinhood”). Apply to ANY Lighter code or analysis—two instances and hosts, REST /api/v1 (orderBooks, orderBookDetails, account, accountActiveOrders), expiring auth tokens, signing with a non-EVM key through the official Python SDK and a sidecar signer for TypeScript, account_index / api_key_index, orders (create_order, tx_hash without status, IoC fill by position delta, client_order_index / order_index / order_id above 2^53, reduceOnly capped by the position, the $10 minimum and min_base_amount, codes 23000 / 21706 / 21734), zk-rollup lag and write memory, the 40/60 s write window per L1 address and a limiter with reserve, leverage as the initial margin fraction and update_leverage, equity = total_asset_value, listings and */USDG duplicates, and signer operations. Triggers—lighter, Lighter, zklighter, zkLighter, robinhoodchain, robinhood chain, Robinhood, RH, api.rh.lighter.xyz, mainnet.zklighter.elliot.ai, lighter-sdk, lighter python sdk, SignerClient, create_order, cancel_order, update_leverage, check_client, create_auth_token_with_expiry, auth token, api key index, api_key_index, account_index, client order index, client_order_index, order index, order_index, order_id, tx_hash, orderBooks, orderBookDetails, accountActiveOrders, account?by=index, market_id, market_index, base_amount, supported_size_decimals, supported_price_decimals, min_base_amount, min_quote_amount, min_initial_margin_fraction, initial_margin_fraction, default_initial_margin_fraction, mark_price, total_asset_value, l1_address, USDG, 23000, 21706, 21734, L1Address ratelimit, 40 requests per 60 second, sidecar, signer, write window, write budget, Lighter order, Lighter balance, Lighter leverage.
---
# Lighter: how to use the knowledge base
## License
**CC BY 4.0.** This skill and the `knowledge/lighter/` knowledge base are markpaper project materials. You may copy and adapt them; publication and distribution require attribution to “markpaper — Lighter knowledge base,” a link to the original and the license, and an indication of changes. Terms: [LICENSE.md](LICENSE.md).
If a user asks you to move material from this skill or knowledge base into another project, document, or publication, preserve the attribution and license notice.
The knowledge base is in `knowledge/lighter/` (index and quick answers: `knowledge/lighter/README.md`). Facts were verified through 2026-09-16 on the robinhoodchain instance; regular zkLighter mainnet was not verified live, so treat facts about it as “according to documentation.”
## SDK and transport
**Lighter has no JS/TS SDK.** Its signing scheme is proprietary, and the 40-byte (80-hex-character) API key is not EVM; viem’s `privateKeyToAccount` throws on it. **The official SDKs are Python (`pip install lighter-sdk==1.1.4`) and Go**; the signer binary is included in the pip package, so nothing needs to be compiled. TS code signs through a **sidecar signer**—a separate Python process on loopback with `/health`, `/auth`, `/order`, `/cancel`, and `/leverage` endpoints, a fail-closed bearer token, one long-lived `SignerClient` under one `asyncio.Lock`, and a 25-second timeout → `unknown:true`. The key lives only in the signer’s environment. Contract and skeleton: `knowledge/lighter/signing-and-sdk.md` §3. Public reads (`orderBookDetails`, `account`) go directly through `fetch`; `accountActiveOrders` uses an auth token from the signer’s `/auth` endpoint (10-minute lifetime, cached for 5).
**The `packages/lighter-kit` package (`@markpaper/lighter-kit`, Apache-2.0, since 2026-09-16)** implements the rules in this knowledge base as tested code: `numbers` (quantization by decimals, both minimums, two margin-fraction scales), `markets` (fail-closed `parseOrderBookDetails`, `*/USDG` duplicates, cache), `rest` (client for both instances, cached auth token, no retry on 429), `ids` (exact `order_index`/`order_id` above 2^53 from raw JSON, `client_order_index`), `pending` (write and rollup-echo memory), `writeBudget` (40/60 s window with reserve and seeding), `signer` (sidecar client, timeout = `unknown`, codes 23000/21706/21734), the `signer/sidecar.py` signer itself on the official `lighter-sdk`, and `orders` (`planOrderSize`, `measureIocFill`, reflection polling). **Rule: in Lighter code, first use a function from `packages/lighter-kit`**; the “function → knowledge-base file” table is in `packages/lighter-kit/README.md`. Warn the user about anything marked `@experimental` (all of mainnet, spot markets, deduplication by `client_order_index`, codes other than the three known ones). If the user asks for direct signing in TS, say that it was not verified and was rejected as a live-funds risk, and propose the sidecar.
## A. Rule: file first, code second
**Before writing or changing Lighter code, open the relevant topic file under `knowledge/lighter`** and read at least its TL;DR, the needed section, “Pitfalls,” and “Open questions.” If the task touches several topics (order + account + limits), open all of them. Do not write Lighter logic from memory: placement responses, identifiers, limits, and leverage work differently, and each rule below has a verified reason.
If a fact is marked “according to Lighter documentation,” “not verified,” or appears under “Open questions,” tell the user and offer verification (a micro-order slightly above $10 or a live query to the instance) instead of presenting it as confirmed. Always identify the instance: “Robinhood” means robinhoodchain, not mainnet.
| Task topic | File |
|---|---|
| Two instances, hosts, source of the RH host, public reads and auth token, `orderBooks` / `orderBookDetails` / `account` / `accountActiveOrders`, response shapes, required metadata, read transport | `knowledge/lighter/instances-and-api.md` |
| Non-EVM key, `account_index` / `api_key_index`, Python SDK (calls and constants), sidecar endpoints, HTTP contract, skeleton, timeouts, fail closed, TS client, key-binding preflight | `knowledge/lighter/signing-and-sdk.md` |
| RH listings and `*/USDG` duplicates, `market_id`, decimals, integers on the wire, two minimums, sizing policy, mark price, spread | `knowledge/lighter/markets-and-numbers.md` |
| Payload, time in force, reduceOnly, minimums, `client_order_index` / `order_index` / `order_id`, `tx_hash`, IoC by delta, write memory and lag, 21734, cancellations, retries, codes, write budget, send order | `knowledge/lighter/orders.md` |
| Account and position reads, separate sign, equity, two fraction scales, `update_leverage`, 2x default, changing leverage under a position, utilization, fees | `knowledge/lighter/account-and-leverage.md` |
| 40/60 s window and code 23000, limiter with reserve and seeding, operation costs, window capacity for a write sequence, throttling ≠ rejection ≠ minimum, read limits | `knowledge/lighter/rate-limits.md` |
| Signer as a process, process layout, health and instrumentation, failure runbook, micro-cycle before going live | `knowledge/lighter/ops.md` |
| Summary table of dated pitfalls, error classes, anti-patterns | `knowledge/lighter/pitfalls.md` |
## B. Hard facts and invariants (always remember)
**Instances and API**
1. Lighter is one exchange with two independent deployments: zkLighter mainnet at `https://mainnet.zklighter.elliot.ai` and the robinhoodchain instance at `https://api.rh.lighter.xyz` (the host comes from the frontend JS bundle and is not in HTML). They have different markets, accounts, `account_index` values, and write windows. One process, one instance. → `instances-and-api.md` §1
2. Public reads without a token: `/api/v1/orderBooks`, `/api/v1/orderBookDetails`, `/api/v1/account?by=index&value=<account_index>`. Private `/api/v1/accountActiveOrders?account_index=<N>` uses the `authorization: <token>` header; the token comes from `create_auth_token_with_expiry(DEFAULT_10_MIN_AUTH_EXPIRY)`, is cached for 5 minutes, and is refreshed on 401/403. → `instances-and-api.md` §2
3. `accountActiveOrders` without `market_id` (and with `market_id=255`) returns orders across all markets in one request—the list is genuinely complete but **lags behind your own writes**. “Could not read” ≠ “empty.” → `instances-and-api.md` §2.4, `orders.md` §7.2
4. One metadata request, `orderBookDetails`: `market_id`, `supported_size_decimals`, `supported_price_decimals`, `min_base_amount`, `min_quote_amount`, `min_initial_margin_fraction`, `mark_price`, `status` (trade only `active`). Mid = `mark_price`, not `last_trade_price`. Cache for 5 minutes; protect against a bad refresh only for markets where you have a position or order. → `instances-and-api.md` §2.2
5. Numbers arrive as strings; 4xx is an exchange response (do not retry), while 5xx/timeout is retried (3 attempts, 400 ms × n, 15-second timeout). An initial one-minute burst of 429 responses on reads is normal. → `instances-and-api.md` §2.5
**Signing and signer**
6. The API key is not EVM (40 bytes). An address cannot be derived from it; authorization is proven by the signer’s `check_client()` **and** `account.l1_address === expected address`. `privateKeyToAccount` and EVM validation (a 64-hex regular expression) fail on this key. → `signing-and-sdk.md` §1
7. The signer is a thin layer: no retries (silent retry = doubled position), no size substitutions; one long-lived `SignerClient`, all signatures under one lock (the nonce is optimistic); `check_client` at startup, error = `SystemExit`; without a bearer token, it neither starts nor responds. → `signing-and-sdk.md` §3
8. An SDK-call timeout (25 seconds) = `unknown:true` (HTTP 504): the outcome is unknown and the order may have been sent. Do not retry placement, treat cancellation as unconfirmed, and reread the account after `update_leverage`. → `signing-and-sdk.md` §3, `orders.md` §9
9. The trading process waits for the signer for up to 90 seconds at startup (polling `/health` every 3 seconds) rather than exiting after the first failure; after 90 seconds, abort startup. → `ops.md` TL;DR, `signing-and-sdk.md` §4
**Markets and numbers**
10. RH had 40 base perpetuals + 26 `*/USDG` duplicates (2026-08-23); when counting markets, include only the base markets (a duplicate is the same instrument in a second quote currency). Query the instance for listings rather than relying on memory. → `markets-and-numbers.md` §1
11. Compute lot `10^-supported_size_decimals` as `Number((10 ** -n).toFixed(n))`; wire values are integer `base_amount = round(sz × 10^n)` and `price = round(px × 10^m)`. Use one function to quantize both sizing and sending (a one-lot discrepancy between implementations causes endless order replacement). → `markets-and-numbers.md` §3
12. Two minimums: `min_quote_amount` ($10) **and** `min_base_amount` (code 21706; SNDK 0.01 ≈ $16 is stricter than $10, LIT has a 5-unit lot). An entry or partial reduceOnly below either is skipped; a full reduceOnly close uses ceil and is raised above **both**. → `markets-and-numbers.md` §4
13. Spreads on thin instance markets reached ~1.2%; an IoC crossing by 0.5% did not reach them and silently did not fill. With a 1.5% cross, the fill occurs at the best price and the cross only widens the worst case. → `markets-and-numbers.md` §5
**Orders**
14. Placement returns only `tx_hash`—not status or `order_index`. Resting: mark “confirmed placed” and wait for the book. IoC: fill = position delta before/after (poll up to ~4 seconds); no observed delta → REJECTED, never FILLED. → `orders.md` §7
15. There are three identifiers: `client_order_index` (yours; exchange deduplication is unconfirmed), `order_index` (exchange-assigned cancellation parameter; **rounded by `JSON.parse`**, values around 1e16 > 2^53), and `order_id` (the same value as an exact string). An order’s identity is the `order_id` string, and it is sent for cancellation as a string. The check `String(Number(x)) === String(x)` proves nothing. → `orders.md` §5
16. Resting reduceOnly is capped by the live position and does not reverse it (experiment: an RO twice the long size → exactly 0, and the exchange canceled the remainder). An RO order above the position will not be placed. Partial RO uses floor only. → `orders.md` §3
17. Keep write memory for ~45 seconds: for a confirmed placement keyed by the order (for example, “asset + side + price”), do not send a duplicate until the order appears in reads; for a confirmed cancellation keyed by `order_id`, filter it out of reads and do not cancel again; canceling an order also clears its placement key; do not cache IoC. Do not inject synthetic orders into reads. → `orders.md` §7.2
18. Code 21734, “too far from the mark,” is a structural rejection: remember the rejected price for 5 minutes instead of retrying every cycle. → `orders.md` §7.4
19. Cancellation is confirmed only by `ok:true`; without an exact `order_id` from a read, do not send it (“order was absent from the latest read”). Cancellation is always a critical write. Cancellations first, then placements. → `orders.md` §8
20. The result type distinguishes three write outcomes: `rateLimited` (not sent—safe to retry), `ok:false` with a code (exchange rejection), and `unknown` (reconcile, do not retry). The SDK returns code 23000 as an exception, so recognize it in the text. → `orders.md` §9–10
**Limits**
21. 40 writes (placement, cancellation, leverage) in a sliding 60-second window per L1 address, code 23000, text `… 40 requests per 60 second is allowed`. Reads do not count toward the window. Restarting a process does not reset the exchange window. → `rate-limits.md` §1
22. The limiter requires explicit `limit` and `reserve` values: the exchange fact is the 40/60 s ceiling; the caller chooses the safety margin and reserve. Ordinary writes can use `limit − reserve`; the sliding window uses timestamps, a slot is consumed before the request and not returned, and after restart the window can be seeded as exhausted. → `rate-limits.md` §2
23. Replacing one order costs 2 writes (cancel + place). → `rate-limits.md` §3
24. Check a multi-write sequence (for example, cancellations followed by a reduceOnly IoC) against the whole window capacity **before** its first write; measure reduceOnly writes against the critical allowance (the full limit). Otherwise, early steps succeed and the last hits the window. → `rate-limits.md` TL;DR
25. Throttling ≠ rejection ≠ minimum: local-window refusal is temporary (not sent, separate counter, retry next cycle); a minimum skip is persistent; REJECTED is an exchange code. Conflating them turns window waits into false failures or suppresses retries forever. → `rate-limits.md` §4
**Account and leverage**
26. `account?by=index` is public; position = `sign` × `position` (unsigned magnitude); equity = `total_asset_value`, with no free spot balance outside it; one malformed entry makes the entire read untrusted. → `account-and-leverage.md` §1–2
27. Two scales for the same fraction: `orderBookDetails.min_initial_margin_fraction` is in hundredths of a percent (`/10000`), while `positions[].initial_margin_fraction` is a percentage string (`"50.00"`, `/100`). Mixing them up means 200x leverage instead of 2x, margin /100, and ROE ×100. Proof is the identity `Σ notional × imf/100 = cross_initial_margin_requirement = total_asset_value − available_balance`. → `account-and-leverage.md` §3
28. Leverage = the market’s initial margin fraction; `update_leverage(market_index, CROSS_MARGIN_MODE, leverage)`; default 5000 = 50% = 2x, cap `floor(10000 / min_initial_margin_fraction)`. Under cross margin, changing it beneath an open position is safe (maintenance-based liquidation does not move). Every attempt is a write; after a market rejection, use a per-market cooldown (for example, minutes) instead of retrying every cycle; “leverage was not set” is a warning, not a reason to stop trading. → `account-and-leverage.md` §4
29. Fees have not been measured; public materials say standard accounts pay no fees, but this is not verified on RH. → `account-and-leverage.md` §6
## C. Checklist before going live with real funds
Going live, switching dry-run → live, and sending orders require an **explicit user command**.
- [ ] The instance is selected explicitly (`api.rh.lighter.xyz` or mainnet), the signer and process use the same base URL, and `account_index` / `api_key_index` belong to that instance. → `instances-and-api.md` §1
- [ ] One account, one process, one signer; there are no manual orders on the account; no other process shares this L1 address (shared write window). → `ops.md` §1
- [ ] The key exists only in the signer’s environment; a bearer token is set (the signer does not start without it); `/health` → `ok:true` with the correct `account_index`/`api_key_index`; the key is absent from the repository. → `signing-and-sdk.md` §3
- [ ] Preflight: metadata was read, the signer was awaited, `account.l1_address` = expected address (otherwise abort startup), and positions and `total_asset_value` were read. → `signing-and-sdk.md` §4
- [ ] No check requires EVM format from the key; no code attempts to derive an address from it. → `signing-and-sdk.md` §1
- [ ] Quantization and sizing policy use one function; `min_base_amount` reaches it; a full RO close is raised above both minimums; entries are never raised. → `markets-and-numbers.md` §3–4
- [ ] Order identity is the `order_id` string; cancel with the string; test a pair of identifiers that round to one double. → `orders.md` §5
- [ ] The write result distinguishes `rateLimited` / `ok:false` / `unknown`; `unknown` is not retried; cancellation = `ok:true` only. → `orders.md` §8–9
- [ ] IoC fill = polled position delta; “not observed” → REJECTED. Keep write memory for 45 seconds (`placed` by order key, `cancelled` by `order_id`). → `orders.md` §7
- [ ] Limiter: sliding window, explicit `limit` ≤ 40 and `reserve` for cancellations and reduceOnly, seeding after restart, slot before request; leverage counts as a write. Check a write sequence against capacity before the first write; reduceOnly uses the critical allowance. → `rate-limits.md` TL;DR, §2
- [ ] Fraction scales: `/10000` for metadata, `/100` for the account; the margin identity holds on a live account. Leverage was set explicitly (`update_leverage`), not left at the 2x default. → `account-and-leverage.md` §3–4
- [ ] IoC cross ≥ the spread on thin markets (1.5%); the spread was measured on the required markets. → `markets-and-numbers.md` §5
- [ ] Instrumentation shows write-window occupancy; placements held by the limiter have a separate counter and are not counted as failures. → `ops.md` §2
- [ ] Instance micro-cycle: GTT slightly above $10 is placed → appears in `accountActiveOrders` → `order_id` ≠ `String(order_index)`? → canceled by the string → disappears; IoC entry is read by delta; resting RO larger than the position brings it to exactly 0. → `ops.md` §4
- [ ] Tests ran in a clean environment without the live process’s env, and the exit code was checked.
- [ ] The list of items **not verified live** (`client_order_index` deduplication, GTT after 28 days, codes other than 23000/21706/21734, fees, mainnet) was written down and shown to the user.
## D. “Place an order” checklist
- [ ] Instance and `market_id` come from fresh `orderBookDetails` on that instance; `status === 'active'`.
- [ ] Size: `decideSize` (floor for everything; ceil + bump above both minimums only for a full RO close; skip with a reason). Price: `pxToStr` on the instance grid; `base_amount`/`price` are integers.
- [ ] `client_order_index` is unique within the account (monotonic counter seeded from process start time); it is for matching, not deduplication.
- [ ] For resting orders: the order key is absent from `placed` memory and the price is absent from 21734 memory; otherwise SKIPPED without sending. For IoC: the position before the write was read (`null` → REJECTED immediately; do not send).
- [ ] Window slot: reduceOnly → critical (full limit), otherwise ordinary (`limit − reserve`); without a slot → `SKIPPED, throttled`, and do not count the order as placed (retry next cycle).
- [ ] Send through the signer: `{ market_index, client_order_index, base_amount, price, is_ask, reduce_only, ioc }`; GTT with a 28-day expiry, IoC with `DEFAULT_IOC_EXPIRY`.
- [ ] Parse: `rateLimited` → SKIPPED; `unknown` → REJECTED marked “reconcile on the next cycle”; `ok:false` → REJECTED, 21734 → price memory, 23000 → limiter misses some writes; `ok:true` resting → `notePlaced(key)`, status RESTING (without `order_index`); `ok:true` IoC → poll the position for up to ~4 seconds, `moved ≤ 0` → REJECTED, otherwise FILLED with `fillSize = |Δ|`.
- [ ] Send order: cancellations → IoC → GTC reduceOnly → ordinary GTC. No retries inside the executor.
- [ ] Log every write: asset, side, size, price, time in force, reduceOnly, status, and reason.
## E. Incident-analysis checklist
1. **Primary sources, not alerts:** `account?by=index` (positions, `total_asset_value`), `accountActiveOrders` (compare `order_id` with `String(order_index)`), `orderBookDetails` (market status, `mark_price`, minimums), signer `/health`, exchange order history (a “placed — canceled” flap is visible only there).
2. **Classify the rejection by code in the text:** 23000—write window (does the limiter miss writes? another process on the address? restart without seeding?); 21706—lot minimum (gate after sending?); 21734—too far from the mark (price memory?); `unknown`/504—signer timeout (was it retried?); `check_client failed`—key/indexes; “sub-account belongs to another address”—configuration.
3. **Symptom “order does not cancel, cancellation OK”** → rounded identifier. **“Duplicate orders” / “cancels in a loop”** → rollup echo without memory. **“Fewer orders on the exchange than expected, no errors”** → throttling is invisible to instrumentation. **“reduceOnly IoC deferred for minutes”** → RO IoC used the ordinary allowance, or the write sequence was not checked against window capacity.
4. **Margin/ROE off by 100×** → fraction scales; verify the identity `Σ ntl × imf/100 = margin requirement`. **More collateral than planned** → default 2x leverage.
5. **Entry does not fill** → spread versus IoC cross (measure the book), market not `active`, or asset absent from the instance.
6. Add newly verified knowledge to the relevant knowledge-base file with the date and relative measurements; explicitly resolve any contradiction with the older entry.