# Lighter — pitfalls and anti-patterns

All Lighter traps in one place: symptom, cause, fix, plus approaches that do not work (and why). The mechanics are detailed in the topic files; this page is the summary and set of invariants.

## TL;DR — ten invariants

1. **An order’s identity is the `order_id` string.** `order_index` is rounded by `JSON.parse` (values around 1e16 > 2^53). Canceling by the number produces a valid transaction for a nonexistent order.
2. **Placement returns only `tx_hash`.** IoC fill = position delta; no delta → REJECTED.
3. **The book lags behind your own writes.** Keep confirmed-write memory for ~45 seconds; do not send a duplicate, and filter out canceled orders.
4. **40 writes / 60 seconds per L1 address.** Use a sliding window, reserve capacity for cancellations and reduceOnly, seed it after restart, and check an entire multi-write sequence against window capacity before its first write.
5. **Two minimums:** $10 and `min_base_amount` (21706). Entries below either minimum are skipped; a full close is raised above both.
6. **Two margin-fraction scales:** metadata `/10000`, account `/100`.
7. **The key is not EVM.** An address cannot be derived from it; authorization = `check_client` + account owner.
8. **Timeout = unknown outcome.** Do not retry placement; an unconfirmed cancellation means the order is still live.
9. **Resting reduceOnly is capped by the position** and does not reverse it; an order larger than the remaining position is either not placed or is truncated.
10. **Throttling ≠ rejection ≠ minimum.** Represent all three states in the result type and in instrumentation.

---

## 1. Pitfall summary

| Symptom | Cause | Correct approach | File |
|---|---|---|---|
| A large share of orders is rejected with 21706 | the dollar minimum passes, but lot-based `min_base_amount` does not | check metadata `minSz` in the same sizing function as the dollar minimum | `markets-and-numbers.md` §4 |
| Stream of unexplained rejections, exchange returns `HTTP 429` | 40 writes/60 seconds per address; the SDK turns 429 into an exception and the signer into an unnamed error | limiter before sending, explicit `limit` and `reserve`; recognize code 23000 in the text | `rate-limits.md` §2 |
| Lighter key fails format validation and the process does not start | format validation expects an EVM key; a Lighter key is 40 bytes | do not validate a Lighter key as EVM; prove authorization with the signer’s `check_client` and the account’s `l1_address` | `signing-and-sdk.md` §1 |
| Entry silently does not fill and is blamed on “minimums” | spread is wider than the IoC cross (1.18% versus 0.5%); the limit does not cross the book | measure the spread; use a 1.5% cross; the fill still occurs at the best price | `markets-and-numbers.md` §5 |
| A noticeable share of orders will not cancel (cancellation OK, order remains in the book), wasting write budget | cancellation uses rounded `order_index`; the exchange responds OK | store and match orders by the `order_id` string; cancel with the string | `orders.md` §5 |
| Pairs of orders collapse into one identifier → duplicates “fixed” in the wrong place | matching by the rounded number | match by the `order_id` string, not the number | `orders.md` §5.3 |
| The same order is canceled repeatedly | the book lags and the confirmed cancellation is forgotten | `cancelled` memory for 45 seconds | `orders.md` §7.2 |
| More orders appear in the book than were intentionally sent | the book lags and placement is repeated | confirmed-placement `placed` memory | `orders.md` §7.2 |
| Rejection after rejection at one price, thousands of wasted writes per day | code 21734: limit order too far from the mark (for example, after a sharp market drop) | remember the rejected price for 5 minutes; expose the first rejection and do not send repeats | `orders.md` §7.4 |
| Leverage is 200x instead of 2x, margin /100, ROE ×100; false protective trigger | the account fraction (`"50.00"` = percent) was divided by 10,000 | `/100` for the account, `/10000` for metadata; prove with the exchange identity | `account-and-leverage.md` §3 |
| More collateral than expected (L/2 times more at target leverage L) | leverage was not set; account remains at default 2x | `update_leverage`; safe under an open cross-margin position | `account-and-leverage.md` §4 |
| reduceOnly IoC orders do not go out for minutes | RO IoC is measured against the ordinary allowance even though it would fit the critical allowance | reduceOnly IoC is a critical write | `rate-limits.md` §2 |
| A real failure remains hidden indefinitely | throttling is recorded as an “exchange minimum” | distinguish the three states | `rate-limits.md` §4 |
| Trading process exits when the signer restarts | exits after the first `/health` failure | wait for the signer at startup (up to 90 seconds) | `ops.md` |

---

## 2. Error classes behind these pitfalls

### 2.1 “A number is not a value”

`order_index` is rounded during JSON parsing; the margin fraction has two scales; `10 ** -n` ≠ `0.0001`; `sign` is separate from `position`. General rule: for every value in an exchange response, ask about its **origin and scale**, and test precision against its string twin rather than with `String(Number(x)) === String(x)` (x has already been rounded, so that test reports “0 distorted” even when identifiers are corrupted).

### 2.2 “Did not read” ≠ “empty,” “success” ≠ “done”

`tx_hash` is not a fill; cancellation `ok` for the wrong identifier is not a cancellation; empty `accounts[]` is not an empty account; one malformed entry makes the entire read untrusted; timeout is not rejection. Every lower-layer gate (lot, `minSz`, minimums, write window) must be represented for the caller (`SKIPPED` with a reason, `deferred`), and the upper layer must not mark an action complete before confirmation.

### 2.3 zk-rollup echo

A write is confirmed, but reads do not show it yet. Both directions (duplicate placement and repeated cancellation) were observed at the same time. The remedy is confirmed-write memory with a TTL—not synthetic orders inserted into reads (they cannot be canceled) and not a longer tick interval.

### 2.4 The write window as a first-class resource

Any wasted write is not merely an “inefficiency”: the budget is exhausted and necessary writes (cancellations, closes) starve. Hence the reserve, seeding, critical allowance for reduceOnly, capacity checks for a write sequence before its first write, 21734 rejection memory, echo memory, and leverage cooldown.

### 2.5 Instrumentation lies first

Throttling recorded as a failure or as an “exchange minimum” distorts the picture. Start incident analysis from exchange primary sources (positions, `accountActiveOrders`, order history), not alerts: the same alert text can represent different states.

---

## 3. Anti-patterns (and why they do not work)

| Approach | Why it does not work |
|---|---|
| Implement Lighter signing in TypeScript instead of a sidecar | rewriting someone else’s cryptography for live funds adds unnecessary risk; the SDK binary already ships in pip |
| Empty signer bearer token means “allow everyone” | one environment mistake exposes the trading-key signer; fail closed |
| Retries inside the signer | a silent order retry can double the position; the calling code that sees the book must decide |
| Substituting size/price in the signer | creates a mismatch with what the calling code believes was placed |
| Write budget “per tick” instead of a sliding window | several ticks fall into the 60-second window, multiplying the excess |
| Hard-block writes for one minute after restart | would delay protective orders when the book is least known; seed gradually |
| Insert a synthetic order into book reads instead of keeping write memory | it has no `order_index` and cannot be canceled; the first cancellation would go nowhere |
| Permanently ban a price after 21734 | the mark moves, so the price would be lost forever; keep it in memory for 5 minutes |
| Precision check `String(Number(x)) === String(x)` | x is already rounded; the check reports 0 distorted values when identifiers are actually corrupted |
| Treat an asset as “unlisted” from memory | the list changes; query `orderBooks` on the correct instance |
| Exit after the signer’s first `/health` failure at startup | a signer restart race takes down the trading process |
| Separate “IoC minimum” below $10 | on Lighter both types used the same `min_quote_amount`; there is no measurement below $10 |

---

## Open questions / not verified

- Summary across files: deduplication by `client_order_index`; GTT after 28 days; codes other than 23000/21706/21734; the 21734 threshold; read limits; write window across sub-accounts; fees; isolated margin; liquidation from the maintenance fraction; all of zkLighter mainnet; WebSocket; key registration.

---

Facts verified through 2026-09-16.

---

<!-- license-footer -->
_© markpaper authors. Licensed under [CC BY 4.0](LICENSE.md): when publishing or adapting this material, credit “markpaper — Lighter knowledge base” and link to the original and the license._
