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.
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
- Lighter is one exchange with two independent deployments: zkLighter mainnet at
https://mainnet.zklighter.elliot.aiand the robinhoodchain instance athttps://api.rh.lighter.xyz(the host comes from the frontend JS bundle and is not in HTML). They have different markets, accounts,account_indexvalues, and write windows. One process, one instance. →instances-and-api.md§1 - 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 theauthorization: <token>header; the token comes fromcreate_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 accountActiveOrderswithoutmarket_id(and withmarket_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- 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 onlyactive). Mid =mark_price, notlast_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 - 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.xyzor mainnet), the signer and process use the same base URL, andaccount_index/api_key_indexbelong 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:truewith the correctaccount_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 andtotal_asset_valuewere 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_amountreaches 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_idstring; 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;unknownis not retried; cancellation =ok:trueonly. →orders.md§8–9 - IoC fill = polled position delta; “not observed” → REJECTED. Keep write memory for 45 seconds (
placedby order key,cancelledbyorder_id). →orders.md§7 - Limiter: sliding window, explicit
limit≤ 40 andreservefor 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.mdTL;DR, §2 - Fraction scales:
/10000for metadata,/100for 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_indexdeduplication, 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_idcome from freshorderBookDetailson 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:pxToStron the instance grid;base_amount/priceare integers. -
client_order_indexis 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
placedmemory 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 withDEFAULT_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:trueresting →notePlaced(key), status RESTING (withoutorder_index);ok:trueIoC → poll the position for up to ~4 seconds,moved ≤ 0→ REJECTED, otherwise FILLED withfillSize = |Δ|. - 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
- Primary sources, not alerts:
account?by=index(positions,total_asset_value),accountActiveOrders(compareorder_idwithString(order_index)),orderBookDetails(market status,mark_price, minimums), signer/health, exchange order history (a “placed — canceled” flap is visible only there). - 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. - 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.
- Margin/ROE off by 100× → fraction scales; verify the identity
Σ ntl × imf/100 = margin requirement. More collateral than planned → default 2x leverage. - Entry does not fill → spread versus IoC cross (measure the book), market not
active, or asset absent from the instance. - Add newly verified knowledge to the relevant knowledge-base file with the date and relative measurements; explicitly resolve any contradiction with the older entry.