@markpaper/lighter-kit
Summary (EN). Practical TypeScript toolkit for the Lighter 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 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, integerbase_amount/price; - two order minimums—
min_quote_amount($10) andmin_base_amount(code 21706)—plus a single-function sizing policy; - two scales for the same margin fraction: metadata
/10000, account/100, leverage capfloor(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
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:
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
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
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
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):
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: theorderBooksrow shape beyondsymbolandmarket_id(fees, decimals, minimums) is passed through inrawwithout validation.- Spot markets:
orderBookDetailson RH (2026-09-16) returns spot in a separatespot_order_book_detailsarray; the package does not parse it;*/USDGentries inorderBooksarrived withmarket_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_indexis unconfirmed: the counter is for matching, not duplicate protection. - Error codes other than 23000 / 21706 / 21734 were not observed;
interpretSignerErrorreturnskind: 'unknown'for them. - The
Authorization: Bearer <token>header for private reads was not verified; the client sendsauthorization: <token>. - Signer outside Linux amd64, API-key registration, multiple
api_key_indexvalues 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
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.
Under section 4(d) of the license, distributions of the package or a derivative work must retain 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.