README.md
v0.2.1 · 15.8 KB
# @markpaper/lighter-kit
**Summary (EN).** Practical TypeScript toolkit for the [Lighter](https://lighter.xyz) exchange — zkLighter
mainnet and the robinhoodchain instance. Lighter has no JS SDK and its API key is not an EVM key, so
signing is done by a small Python sidecar on the official `lighter-sdk` (`signer/sidecar.py`, shipped
with the package); this kit covers everything around it: market meta and quantization on the exchange
grid, the two order minimums, the two margin-fraction scales, exact order ids above 2^53, the
40-writes-per-60-s window per L1 address with reserve and restart seeding, rollup-echo memory of own
writes, IoC fills measured as the position delta, a retrying REST read client, and a client for the
sidecar whose write results distinguish `ok` / `rejected` / `unknown` / `rateLimited`. No runtime
dependencies. Every rule comes from the markpaper Lighter knowledge base (`knowledge/lighter/`, CC BY 4.0),
and every rule there has its reason written down. Not financial advice; verify on the instance with minimal size.
---
A practical toolkit for the [Lighter](https://lighter.xyz) exchange: regular zkLighter mainnet and the
robinhoodchain instance. Lighter has no JS SDK, and its 40-byte API key is not EVM, so signing is
performed by a separate Python process on the official `lighter-sdk` (`signer/sidecar.py` is included
in the package), while this package covers everything around signing—the parts repeatedly reimplemented
and broken in individual projects:
- market metadata and quantization on the instance grid: lot `10^-supported_size_decimals`, integer `base_amount` / `price`;
- two order minimums—`min_quote_amount` ($10) **and** `min_base_amount` (code 21706)—plus a single-function sizing policy;
- two scales for the same margin fraction: metadata `/10000`, account `/100`, leverage cap `floor(10000 / imf)`;
- exact order identifiers: values around 1e16 > 2^53 are rounded by `JSON.parse`, so raw text is parsed without loss;
- the 40/60 s write window per L1 address (code 23000): a sliding limiter with reserve for cancellations and reduceOnly, seeded after restart;
- memory of your own writes over lagging book reads (zk-rollup echo), plus 21734 rejection memory;
- IoC fill as a polled position delta; waiting for a resting order to be reflected;
- a REST read client with retry policy (4xx and 429 are not retried) and fail-closed account/order parsing;
- signer client: `health`, `auth`, `order`, `cancel`, `leverage`; timeout means an **unknown** outcome, not rejection.
Almost everything is a pure function. Anything that reads the network accepts an injected `fetch` and is tested without the network.
The rules and their reasons are in `knowledge/lighter/` (index: `knowledge/lighter/README.md`)
and the `.claude/skills/lighter/SKILL.md` skill.
## Installation
```sh
npm i @markpaper/lighter-kit
```
Node.js **22.12+**, ESM. No external dependencies: `fetch` is built in. The signer requires Python 3
and `pip install lighter-sdk aiohttp` (see below).
## Imports
Everything is exported flat from the package root, and each module is also available as a namespace:
```ts
import { createRestClient, createSignerClient, planOrderSize } from '@markpaper/lighter-kit';
import { numbers, markets, ids, pending, writeBudget, rest, signer, orders } from '@markpaper/lighter-kit';
```
| Namespace | Contents |
| --- | --- |
| `numbers` | lot and tick, `quantizeSize` / `quantizePrice`, `toWire` / `fromWire`, `meetsMinimums` (plus exact BigInt version), fraction scales and leverage |
| `markets` | `parseOrderBooks` / `parseOrderBookDetails` (fail closed), `*/USDG` duplicates, `marketByBaseSymbol`, metadata cache with TTL |
| `ids` | `parseJsonExact` / `quoteIntegerFields`, `exactOrderId`, `orderIdPrecision`, `createClientOrderIndex` |
| `pending` | `createPendingMemory`: `notePlaced` / `isPlaced` / `forgetPlaced`, `noteCancelled` / `isCancelled`, 21734 memory, `placeKey` |
| `writeBudget` | `createWriteWindow` (explicit `limit`/`reserve`, 60-second window, seeding), `seedWriteWindow` |
| `rest` | `createRestClient`, `createAuthTokenCache`, `parseAccount`, `parseActiveOrders`, instance hosts |
| `signer` | `createSignerClient`, `interpretSignerError` (23000 / 21706 / 21734), sidecar-contract types |
| `orders` | `planOrderSize`, `measureIocFill`, `resolveResting`, `waitForOrderKeyInBook`, `sortForSending` |
## Functions → knowledge-base file
| Function / object | Knowledge-base file | Section |
| --- | --- | --- |
| `sizeLot`, `quantizeSize`, `quantizePrice`, `toWire`, `fromWire` | `markets-and-numbers.md` | §3 |
| `meetsMinimums`, `meetsMinimumsExact` | `markets-and-numbers.md` | §4 |
| `planOrderSize` | `markets-and-numbers.md`, `orders.md` | §4.3; §3–4 |
| `MARGIN_FRACTION_SCALES`, `imfFromMarketFraction`, `imfFromAccountFraction`, `leverageFromAccountFraction`, `maxLeverageFromImf`, `marginIdentity` | `account-and-leverage.md` | §3–4 |
| `parseOrderBookDetails`, `parseOrderBooks`, `createMarketCache`, `assertMarketsSane` | `instances-and-api.md` | §2.1–2.2 |
| `isUsdgDuplicate`, `baseMarkets`, `marketByBaseSymbol` | `markets-and-numbers.md` | §1.2 |
| `INSTANCE_URLS`, `createRestClient` (15-second timeout, 3 attempts, no retry on 4xx) | `instances-and-api.md`, `rate-limits.md` | §1, §2.5; §5 |
| `createAuthTokenCache`, `authHeader` | `instances-and-api.md` | §2.4 |
| `parseAccount`, `signedPosition`, `accountOwnerMatches` | `account-and-leverage.md`, `signing-and-sdk.md` | §1–2; §4 |
| `parseActiveOrders`, `ordersOnUnknownMarkets` | `orders.md` | §6 |
| `parseJsonExact`, `quoteIntegerFields`, `exactOrderId`, `orderIdPrecision` | `orders.md` | §5 |
| `createClientOrderIndex` | `orders.md` | §1 |
| `createPendingMemory`, `placeKey`, `applyPendingToRead` | `orders.md` | §7.2, §7.4 |
| `createWriteWindow`, `seedWriteWindow` | `rate-limits.md` | §1–2 |
| `createSignerClient` (35 / 10 / 20 s, `unknown` on timeout, 90-second startup wait) | `signing-and-sdk.md`, `ops.md` | §3.5, §4; TL;DR |
| `interpretSignerError`, codes 23000 / 21706 / 21734 | `orders.md`, `rate-limits.md` | §9–10; §1 |
| `measureIocFill` | `orders.md` | §7.3 |
| `resolveResting`, `waitForOrderKeyInBook` | `orders.md` | §5.4, §7.2 |
| `sortForSending` | `orders.md` | §12 |
| `signer/sidecar.py` | `signing-and-sdk.md` | §3 |
## Quick examples
Addresses in the examples are placeholders (`0xYOUR_ADDRESS`). None of this sends orders by itself.
### Metadata, quantization, minimums
```ts
import { createRestClient, quantizePrice, quantizeSize, toWire, meetsMinimums, planOrderSize } from '@markpaper/lighter-kit';
const rest = createRestClient({ baseUrl: 'robinhoodchain' }); // or 'mainnet', or a custom URL
const markets = await rest.orderBookDetails(); // Map<symbol, LighterMarket>, perp only, fail closed
const eth = markets.get('ETH');
if (!eth || eth.status !== 'active') throw new Error('ETH is not traded on this instance');
const pxStr = quantizePrice(eth.markPrice * 0.99, eth.priceDecimals); // grid-aligned string; compare this value
const size = quantizeSize(12 / Number(pxStr), eth.sizeDecimals, 'ceil');
const wire = toWire(size, pxStr, eth.sizeDecimals); // { base_amount, price } are integers
const both = meetsMinimums({ notional: size * Number(pxStr), baseAmount: size, minBaseAmount: eth.minBaseAmount, minQuoteUsd: eth.minQuoteUsd });
const plan = planOrderSize({ intent: 'open', size, px: Number(pxStr), sizeDecimals: eth.sizeDecimals, minBaseAmount: eth.minBaseAmount, minQuoteUsd: eth.minQuoteUsd });
// plan.action === 'skip' with reason 'below_min_base_amount' | 'below_min_quote_amount'—do not bump an entry;
// intent: 'fullClose'—ceil and bump above BOTH minimums.
```
### Signer and writes
```ts
import {
createSignerClient, createWriteWindow, createAuthTokenCache, createPendingMemory, createClientOrderIndex,
placeKey, measureIocFill, interpretSignerError,
} from '@markpaper/lighter-kit';
const writeWindow = createWriteWindow({ // application policy is explicit
limit: WRITE_LIMIT,
reserve: WRITE_RESERVE,
});
writeWindow.seedAsExhausted(); // restart did not reset the exchange window
const signer = createSignerClient({ url: process.env.SIGNER_URL!, token: process.env.SIGNER_TOKEN!, writeWindow });
const health = await signer.waitForHealth(); // up to 90 s, polling every 3 s
if (!health.ok) throw new Error(`signer is not ready: ${health.error}`);
const auth = createAuthTokenCache({ getToken: () => signer.authToken() }); // 10-minute lifetime, cached for 5
const rest = createRestClient({ baseUrl: 'robinhoodchain', authToken: auth });
const account = await rest.account(ACCOUNT_INDEX);
if (account.l1Address !== '0xYOUR_ADDRESS'.toLowerCase()) throw new Error('account_index belongs to another address');
const memory = createPendingMemory(); // 45-second echo, 21734 memory for 5 minutes
const coi = createClientOrderIndex();
// Resting order: confirmation is only tx_hash; remember the “asset, side, price” key until the order appears in the book.
const key = placeKey('ETH', true, pxStr);
if (!memory.isPlaced(key) && !memory.isFarFromMark(key)) {
const r = await signer.placeOrder({ marketIndex: eth.marketId, clientOrderIndex: coi.next(), baseAmount: wire.base_amount, price: wire.price, isAsk: false });
if (r.status === 'ok') memory.notePlaced(key);
else if (r.status === 'rejected' && r.kind === 'far_from_mark') memory.noteFarFromMark(key);
else if (r.status === 'unknown') { /* reconcile against the book on the next tick; DO NOT retry */ }
else if (r.status === 'rateLimited') { /* not sent; next tick */ }
}
// IoC: fill is the position delta.
const fill = await measureIocFill({
readPosition: async () => (await rest.account(ACCOUNT_INDEX)).positions.get('ETH')?.size ?? 0,
send: () => signer.placeOrder({ marketIndex: eth.marketId, clientOrderIndex: coi.next(), baseAmount: wire.base_amount, price: wire.price, isAsk: false, ioc: true }),
isBuy: true,
limitPrice: Number(pxStr),
});
// fill.status: 'FILLED' (fillSize) | 'REJECTED' (including unknownOutcome: true) | 'SKIPPED' (local window)
```
### Reading orders and canceling by the exact identifier
```ts
import { symbolByMarketId, applyPendingToRead } from '@markpaper/lighter-kit';
const orders = await rest.accountActiveOrders(ACCOUNT_INDEX, { symbolByMarketId: symbolByMarketId(markets.values()) });
// orders[i].orderId is the exact string (raw response parsed before JSON.parse); order_index is rounded—do not use it.
const live = applyPendingToRead(
orders.map((o) => ({ ...o, coin: o.symbol ?? String(o.marketIndex) })),
memory,
);
// Cancel only an order from a fresh read: take its identifier from the read; do not guess.
const target = live.find((o) => o.coin === 'ETH' && o.isBuy && o.priceStr === pxStr);
if (target && !memory.isCancelled(target.orderId)) {
const r = await signer.cancelOrder({ marketIndex: eth.marketId, orderId: target.orderId }); // as a string; critical write
if (r.status === 'ok') { memory.noteCancelled(target.orderId); memory.forgetPlaced(key); }
// 'unknown': treat the order as live; 'rateLimited': not sent, so the order is definitely in the book.
}
```
## Signer: setup
The source is `signer/sidecar.py` (aiohttp + official `lighter-sdk`; the signing binary is included in the
pip package and was verified on Linux amd64). It stores nothing on disk: the key, indexes, instance URL,
token, and port come from the environment at startup. Without a token, it does not start or answer anyone.
Windows (PowerShell):
```powershell
py -m venv .venv-lighter-signer
.\.venv-lighter-signer\Scripts\Activate.ps1
pip install -r node_modules/@markpaper/lighter-kit/signer/requirements.txt # lighter-sdk, aiohttp
$env:LIGHTER_SIGNER_BASE_URL = "https://api.rh.lighter.xyz" # or https://mainnet.zklighter.elliot.ai
$env:LIGHTER_SIGNER_ACCOUNT_INDEX = "<account_index on this instance>"
$env:LIGHTER_SIGNER_API_KEY_INDEX = "<api_key_index>"
$env:LIGHTER_SIGNER_API_PRIVATE_KEY = "<80 hex, only here>"
$env:LIGHTER_SIGNER_TOKEN = "<long random bearer token>"
$env:LIGHTER_SIGNER_PORT = "8700" # any available loopback port
python node_modules/@markpaper/lighter-kit/signer/sidecar.py
```
Linux uses the same steps with `python3 -m venv` and `export`. The signer was not verified on Windows/macOS
(`lighter-sdk` was verified only on Linux amd64); this remains an open question in the knowledge base.
Check: `curl http://127.0.0.1:8700/health` → `{ "ok": true, "account_index": …, "api_key_index": …, "url": … }`.
Endpoint contracts and response codes are documented in the file’s docstring and in `knowledge/lighter/signing-and-sdk.md` §3. One key,
one signer, one trading process; two processes on the same L1 address share the write window and break the nonce.
Syntax check before startup: `py -m py_compile signer/sidecar.py` (the file is marked `-text` in `.gitattributes`,
so it uses LF line endings on every OS).
## What is marked `@experimental`
Anything marked “according to documentation” or “not verified” in the knowledge base is marked `@experimental` in JSDoc
and implemented so that failure is safe. Before relying on it, verify it through a live query
or a micro-order (~$12) on the intended instance:
- **`parseOrderBooks`**: the `orderBooks` row shape beyond `symbol` and `market_id` (fees, decimals, minimums) is passed through in `raw` without validation.
- **Spot markets**: `orderBookDetails` on RH (2026-09-16) returns spot in a separate `spot_order_book_details` array; the package does not parse it; `*/USDG` entries in `orderBooks` arrived with `market_type: 'spot'`.
- **`ISOLATED_MARGIN_MODE`**: isolated margin has not been verified live.
- **`sortForSending` / `SEND_ORDER`**: the IoC → GTC reduceOnly → GTC placement order was designed but not verified live; cancellations come first.
- **All of zkLighter mainnet**: the host is in `INSTANCE_URLS`, but response shapes, minimums, and limits were not verified live.
- **Exchange deduplication by `client_order_index`** is unconfirmed: the counter is for matching, not duplicate protection.
- **Error codes** other than 23000 / 21706 / 21734 were not observed; `interpretSignerError` returns `kind: 'unknown'` for them.
- **The `Authorization: Bearer <token>` header** for private reads was not verified; the client sends `authorization: <token>`.
- **Signer outside Linux amd64**, API-key registration, multiple `api_key_index` values per account.
## Knowledge base
Every function grew from a rule in the markpaper Lighter knowledge base: `knowledge/lighter/`
(start with `knowledge/lighter/README.md`); the mapping table is above. The knowledge base and skill
are licensed separately under **CC BY 4.0**.
## Development
```powershell
pnpm exec tsc --noEmit -p packages/lighter-kit/tsconfig.json # types (TS 7 from the monorepo root)
pnpm exec vitest run --root packages/lighter-kit # unit tests, no network
pnpm exec biome check packages/lighter-kit
py -m py_compile packages/lighter-kit/signer/sidecar.py
pnpm --filter @markpaper/lighter-kit run smoke # read-only live smoke test against public RH data
```
The smoke test reads only `orderBooks` and `orderBookDetails` from the robinhoodchain instance (set `LIGHTER_SMOKE_INSTANCE=mainnet`
for mainnet): no addresses, keys, or orders.
## License
Apache License 2.0—see [LICENSE](LICENSE).
Under section 4(d) of the license, distributions of the package or a derivative work must retain [NOTICE](NOTICE)
and the **“markpaper — lighter-kit”** attribution.
## Disclaimer
This is not financial advice or a recommendation to trade. The Lighter API, limits, minimums, and response shapes
change without notice; verify anything marked “not verified” against official documentation and live requests.
Start with dry-run, a minimal deposit, and one or two markets. The software is provided “as is,” without warranties
of any kind; you are responsible for its use.