# Lighter — orders

The `create_order` payload, types and time-in-force values, reduceOnly semantics, minimums, identifiers (`client_order_index` / `order_index` / `order_id`), what placement returns (`tx_hash`, not status), measuring an IoC fill as a position delta, reflection lag after a write, cancellations, retries, error codes, and write budget. Facts were verified on the robinhoodchain instance; writes go through a sidecar built on the official Python SDK.

## TL;DR

1. **Placement returns only `tx_hash`.** It is not execution status: there is no `filled`, `resting`, or `order_index`. Measure an IoC fill as the **position delta** before and after the write; a resting order appears in `accountActiveOrders` after a delay. *Verified 2026-08-20.*
2. **There are three identifiers.** `client_order_index` is yours and set at placement (account-level uniqueness is your responsibility). `order_index` is assigned by the exchange and used for cancellation. `order_id` is the same value as a string. Values around 1e16 exceed 2^53, so `JSON.parse` silently rounds `order_index`; **an order’s identity is the `order_id` string**, and that string is sent to the exchange for cancellation. *Verified 2026-08-21: canceling by the rounded number leaves the order in place, and distinct identifiers collapse into one number.*
3. **Resting reduceOnly is capped by the live position** and does not reverse it: the exchange cancels the remainder when the position reaches zero. Therefore, a reduceOnly order **larger** than the remaining position is either not placed or is truncated. *Verified with a live order 2026-08-20.*
4. **Minimums:** $10 (`min_quote_amount`) **and** the lot-based `min_base_amount` (code 21706). An entry or partial reduceOnly below either minimum is skipped; a full reduceOnly close uses ceil and is raised above both. *Verified 2026-08-20.* → `markets-and-numbers.md` §4
5. **The order list lags behind your own writes** (zk-rollup): place → the next read does not show it yet → a bot without memory places a duplicate; cancel → the next read still shows it → the bot cancels it repeatedly. Keep confirmed-write memory for ~45 seconds: **do not send** a duplicate, and **filter out** a confirmed cancellation from the read. *Observed 2026-08-21.*
6. **Timeout = unknown outcome**, not rejection. Do not retry placement; reconcile against the book first. An exchange rejection (`err` in the SDK response) is a known outcome: nothing was placed. A refusal by the local write limiter is also known: no request was sent, so retrying on the next tick is safe. These three states must be distinct in the result type.
7. **Cancellation is confirmed only by `ok:true` from the signer.** Under `unknown`, treat the order as live and do not place another over it. Canceling with the rounded identifier is a valid transaction for a nonexistent order: the exchange answers OK while the order remains live. *Verified 2026-08-21.*
8. **The write window is 40/60 s per L1 address** (code 23000): replacing one order costs 2 writes (cancel + place). *Verified 2026-08-20.* → `rate-limits.md`
9. **Dispatch order:** IoC → GTC reduceOnly (protection) → ordinary GTC. If more entry orders are queued than fit in the window while protection waits behind them, protection starves first. Recommendation: sort before sending.
10. **Code 21734, “too far from the mark,”** is a structural rejection: the order cannot pass until the market reaches it. Remember the rejected price for ~5 minutes rather than retrying every cycle. *Verified 2026-08-22.*

---

## 1. Placement payload

Through the SDK (`SignerClient.create_order`); from TS, through the sidecar (`POST /order`, → `signing-and-sdk.md` §3).

| Field | Type | Meaning |
|---|---|---|
| `market_index` | int | `market_id` from `orderBookDetails` |
| `client_order_index` | int | your identifier; must be unique within the account |
| `base_amount` | int | size × `10^supported_size_decimals` |
| `price` | int | price × `10^supported_price_decimals` |
| `is_ask` | bool | `true` = sell |
| `order_type` | const | only `ORDER_TYPE_LIMIT` was verified |
| `time_in_force` | const | `ORDER_TIME_IN_FORCE_GOOD_TILL_TIME` or `ORDER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL` |
| `reduce_only` | bool | see §3 |
| `order_expiry` | int | `DEFAULT_28_DAY_ORDER_EXPIRY` for GTT, `DEFAULT_IOC_EXPIRY` for IoC |
| `api_key_index` | int | signing key |

A convenient `client_order_index` is a monotonic counter seeded from process start time modulo 2·10⁹: `seq = (seq + 1) % 2_000_000_000`, with the initial value `floor(Date.now()/1000) % 1e9`. The exchange does **not guarantee** deduplication by this field (it has not been confirmed), so use it for matching, not as duplicate protection.

---

## 2. Types and time in force

| Need | Implementation | Notes |
|---|---|---|
| Limit order in the book | `ORDER_TYPE_LIMIT` + GTT + `DEFAULT_28_DAY_ORDER_EXPIRY` | “GTC” on Lighter is GTT with a 28-day expiry; what happens on expiry has not been verified |
| “Market” / market entry | `ORDER_TYPE_LIMIT` + IoC crossing from the mark | native `ORDER_TYPE_MARKET` exists in the SDK but was not used |
| reduceOnly | `reduce_only` flag on either type | resting is capped by the position (§3) |
| Post-only, stops, TP/SL | available in the SDK according to documentation | not used, not verified |

- **IoC cross** from `mark_price`: 0.5% did not reach the ask on a thin market (spread 1.18%); 1.5% is enough on the instance. The fill occurs at the best price; the cross only widens the worst case. *Verified 2026-08-20.*
- IoC on an empty book was not tested separately; by observation, it does not fill and the position does not move, which is read as REJECTED (§7).

---

## 3. reduceOnly

### 3.1 Resting reduceOnly is capped by the position — verified experimentally

Experiment on 2026-08-20 on a market with no other orders or positions of ours:

1. opened a minimum-size long (slightly above $10);
2. placed a **resting** GTT reduceOnly SELL twice the position size at a price guaranteed to execute;
3. the position went exactly to 0 and remained there for 8 measurements (20 seconds), did **not** reverse short, and the exchange canceled the remainder itself—no order remained in the market’s book.

The experiment cost a few dollars and matched Lighter documentation (“executing partially if the order size exceeds the position, with any remaining portion canceled once the position reaches zero”). The documentation warrants an experiment because the flag is **load-bearing**: decisions about canceling protective orders during a close depend on it, and the cost of an error is a reversed position.

Consequence: a full close does not require canceling every order for the asset. Canceling N orders would cost N writes from the 40/60 s window, and the position would remain open throughout.

### 3.2 Consequences

- A reduceOnly order **larger than the remaining position** will not be placed (or will be placed and truncated).
- Partial reduceOnly is not protected from oversizing: partial sizes use floor only; otherwise, skip.
- A full reduceOnly close is not constrained by the minimum: use ceil and raise above both minimums; the exchange caps it at the position.
- Classify whether your own order is protective or an entry by the `reduce_only` flag from the book, not by side.

---

## 4. Minimums

- `min_quote_amount` = $10 on RH—read it from metadata, not memory. Check it **after** quantizing size, using the order price.
- How it was verified (2026-08-20): an order slightly above $10 was placed, found in the book, and canceled; an order that passes the dollar gate can still be rejected by the lot minimum (21706). No binary search below $10 was performed; no separate “IoC minimum” was observed—both types used the same $10.
- Lot-based `min_base_amount` is the second minimum, code **21706**. → `markets-and-numbers.md` §4
- Full reduceOnly close: ceil + bump to both minimums; entries and partial closes: never bump.

---

## 5. Identifiers: `client_order_index`, `order_index`, `order_id`

### 5.1 What appears where

| Identifier | Assigned by | Where it appears | Purpose |
|---|---|---|---|
| `client_order_index` | you, at placement | on every order in `accountActiveOrders` | match “what I placed” ↔ “what is in the book” |
| `order_index` | exchange | `accountActiveOrders` (number, **rounded** by `JSON.parse`) | `cancel_order` parameter |
| `order_id` | exchange | `accountActiveOrders` (string, **exact**) | order identity; this exact value is sent for cancellation |
| `tx_hash` | exchange | `create_order` response | not an order identifier; only evidence that the transaction was accepted |

### 5.2 Identifiers above 2^53 (verified 2026-08-21)

The exchange numbers orders with values around 1e16, outside the exact range of a double (2^53 ≈ 9.007e15). `JSON.parse` silently rounds them; the response contains **both** fields, and a mismatch between `order_id` and `order_index` is the lost bit. This breaks two things at once:

- **Cancellation fails.** The signer signs a cancellation for a nonexistent identifier. The transaction is valid, the exchange answers OK, the order remains in the book, and repeated cancellations waste write budget.
- **Two orders are treated as one.** Pairs of identifiers round to the same number; code matching by the number sees one order instead of two, so the second is never canceled. This is how duplicates survive.

The check `String(Number(x)) === String(x)` **proves nothing**: `x` was already rounded during JSON parsing. Test precision loss against the string twin in the response. Treat any new identifier field on any exchange as a string when its values are around 1e16.

### 5.3 Exact identity from the response

Store and match orders by the `order_id` string, not the number. Cancel only an order present in the latest complete book read: without an exact identifier, do not send a cancellation, because a guess signs a nonexistent identifier (§8).

```ts
/** Exact identity from the response: order_id string; fallback is the rounded order_index. */
export function exactOrderId(raw: { order_id?: unknown; order_index?: unknown }): string | null {
  const s = raw.order_id;
  if (typeof s === 'string' && /^[0-9]+$/.test(s)) return s;
  if (typeof s === 'number' && Number.isSafeInteger(s) && s >= 0) return String(s);
  const n = Number(raw.order_index);
  return Number.isFinite(n) && n >= 0 ? String(n) : null;
}
```

### 5.4 Matching `client_order_index` ↔ `order_index`

The placement response contains no `order_index`; it appears in `accountActiveOrders` next to your `client_order_index`. Match by polling the book:

```ts
/** Wait for the placed order to appear in the book (zk-rollup reflects the write with a lag). */
async function resolveOrderByClientIndex(
  readActiveOrders: () => Promise<Array<{ order_id: string; client_order_index: number; market_index: number }>>,
  marketIndex: number, clientOrderIndex: number,
  opts = { tries: 8, gapMs: 700 },
): Promise<{ orderId: string } | null> {
  for (let i = 0; i < opts.tries; i++) {
    const hit = (await readActiveOrders()).find(
      (o) => o.market_index === marketIndex && Number(o.client_order_index) === clientOrderIndex,
    );
    if (hit) return { orderId: hit.order_id };
    await new Promise((r) => setTimeout(r, opts.gapMs));
  }
  return null; // absent after ~5 s: no-resting-remainder IoC, rejection, or longer lag—reconcile on the next tick
}
```

Exchange-side deduplication by `client_order_index` has not been confirmed.

---

## 6. Reading your orders

`GET /api/v1/accountActiveOrders?account_index=<N>` with an auth token → `{ orders: [...] }`. Without `market_id`, it returns all markets. → `instances-and-api.md` §2.4

| Field | Type | Notes |
|---|---|---|
| `order_id` | string | exact identity |
| `order_index` | number | rounded; compare against `order_id` |
| `client_order_index` | number | your index |
| `market_index` | number | map to a symbol through metadata |
| `is_ask` | bool | `true` = sell |
| `price` | string | on the instance grid |
| `remaining_base_amount` | string | **live remainder**—compare the required size against this, not `initial_base_amount` |
| `initial_base_amount` | string | original size |
| `reduce_only` | bool | classification as “protective / entry” |
| `status` | string | not needed for reconciliation |

Read rules:

- Skip `remaining_base_amount ≤ 0`.
- An order whose `market_index` is absent from metadata is a **warning**, not a silent skip.
- A malformed entry (missing `order_id`/`order_index`, or nonnumeric `price` or `remaining`) is an **error for the entire read**, not “the remaining orders”: a truncated read is indistinguishable from an empty account.
- Filter out confirmed cancellations (from write memory, §7.2).
- When a placed order appears in the book, remove it from “placed” memory (§7.2).
- Snapshot sandwich: positions → orders → positions; only if the position map is byte-for-byte identical before and after are order sizes calculated from positions trustworthy.

---

## 7. Placement response, IoC fill, and reflection lag

### 7.1 Only `tx_hash`

A successful `create_order` means the sequencer accepted the transaction. The response contains neither execution nor `order_index`. Consequences:

- resting order: mark it “confirmed placed” and wait for it to appear in the book (§7.2);
- IoC: measure fill only as a position delta (§7.3);
- “SUBMITTED” ≠ “FILLED”; write the order journal before sending and update it from the book/position.

### 7.2 Memory of your own writes (zk-rollup lag)

The order list catches up with a write after a delay of seconds. Both directions were observed live (2026-08-21): duplicate placements (more orders in the book than intended; extra **protective** orders are harmless, while extra **entry** orders acquire more than intended during a sharp move) and repeated cancellations (the same order is canceled again and again while reads still show it, wasting write budget).

The remedy is temporary memory with a ~45-second TTL (**write echo**):

- `placed`: placed-order key (for example, `asset|side|price`) → confirmation time. If the bot decides to place the same order again, **do not send it** (SKIPPED, “already confirmed; read is lagging”). When the order appears in the book, forget it. When that order is canceled, forget it as well (otherwise, your own memory blocks a legitimate replacement).
- Exclude size from the key: if size is recalculated between cycles (from equity and price), a size-bearing key changes every time, memory does not match, and several same-side orders accumulate at one price. *Observed 2026-08-21.*
- `cancelled`: exact `order_id` → cancellation-confirmation time. Such an order is **filtered out** of book reads; do not send another cancellation.
- Do not cache IoC: it leaves no trace in the book, and repeating it is a deliberate bot decision.

Why not inject a synthetic order into reads: `order_index` is unknown until the order appears in the book; an invented identifier cannot be canceled.

```ts
const placed = new Map<string, number>();     // order key → confirmation time
const cancelled = new Map<string, number>();  // order_id → cancellation confirmation time
const ECHO_TTL_MS = 45_000;

export const orderKey = (coin: string, isBuy: boolean, pxStr: string) => `${coin}|${isBuy ? 'B' : 'A'}|${pxStr}`;
const sweep = (m: Map<string, number>, now: number) => { for (const [k, at] of m) if (at <= now - ECHO_TTL_MS) m.delete(k); };

export function rememberPlaced(key: string, now = Date.now()) { sweep(placed, now); placed.set(key, now); }
export function wasPlaced(key: string, now = Date.now()) { sweep(placed, now); return placed.has(key); }
export function releaseKey(key: string) { placed.delete(key); }
export function rememberCancel(orderId: string, now = Date.now()) { sweep(cancelled, now); cancelled.set(orderId, now); }
export function wasCancelled(orderId: string, now = Date.now()) { sweep(cancelled, now); return cancelled.has(orderId); }

/** Wait for a write to appear in the book with a timeout: order appeared → true; absent → false (not “no order”). */
export async function waitForOrderKeyInBook(
  readKeys: () => Promise<Set<string>>, key: string, timeoutMs = 5_000, gapMs = 700,
): Promise<boolean> {
  const until = Date.now() + timeoutMs;
  while (Date.now() < until) {
    if ((await readKeys()).has(key)) { releaseKey(key); return true; }
    await new Promise((r) => setTimeout(r, gapMs));
  }
  return false;
}
```

### 7.3 IoC fill = position delta

```ts
/** IoC on an exchange with no status in the response: fill = position delta. No observed delta → REJECTED. */
async function placeIocMeasured(
  readPosition: (coin: string) => Promise<number | null>,   // signed position size; null = untrusted read
  send: () => Promise<{ ok: boolean; unknown?: boolean; rateLimited?: boolean; error?: string }>,
  coin: string, isBuy: boolean, pxUsed: number,
): Promise<{ status: 'FILLED' | 'REJECTED' | 'SKIPPED'; fillSize?: number; fillAvgPx?: number; rawError?: string }> {
  const before = await readPosition(coin);
  if (before === null) return { status: 'REJECTED', rawError: 'position before IoC was unavailable; fill cannot be observed' };
  const res = await send();
  if (res.rateLimited) return { status: 'SKIPPED', rawError: res.error };            // not sent
  if (res.unknown) return { status: 'REJECTED', rawError: `unknown-outcome: ${res.error}` }; // reconcile on the next tick
  if (!res.ok) return { status: 'REJECTED', rawError: res.error };
  // zk-rollup: time passes between tx_hash and the state update—poll for up to ~4 s.
  let after: number | null = null;
  for (let i = 0; i < 6; i++) {
    await new Promise((r) => setTimeout(r, 700));
    after = await readPosition(coin);
    if (after !== null && after !== before) break;
  }
  if (after === null) return { status: 'REJECTED', rawError: 'position after IoC was unavailable' };
  const moved = isBuy ? after - before : before - after;
  if (moved <= 0) return { status: 'REJECTED', rawError: 'position did not change; IoC was not filled or sequenced yet' };
  return { status: 'FILLED', fillSize: Math.abs(moved), fillAvgPx: pxUsed };
}
```

Why the asymmetry “not observed → REJECTED”: on the next tick, an unrecorded fill is recalculated from a fresh position read and will not execute twice; recording a nonexistent fill corrupts position accounting and leaves the intended reduction unfinished. The response contains no average fill price: `fillAvgPx` is the limit price, while the actual price is known only from trade history (which was not used).

### 7.4 Code 21734, “too far from the mark”

An order rejected for distance from the mark cannot pass until the market approaches it. Retrying every cycle produces rejection after rejection and wastes writes from the 40/60 s window, while the stream of identical log entries hides real failures. The remedy is temporary rejected-price memory (5 minutes; because the mark moves, a permanent ban would lose the price forever). The first rejection is visible as REJECTED; repeats during the window are SKIPPED without sending. Match code `21734` or the text `too far from the mark`. *Verified 2026-08-22.*

---

## 8. Cancellations

- Cancel by the **exact** `order_id` string. Without an exact `order_id` from the latest read (§5.3), return “order was absent from the latest book read” and **do not send a cancellation**. A guess signs an unrelated or nonexistent identifier.
- Do not repeat a confirmed cancellation: the exchange said it was canceled and the book is lagging. A repeat is not harmless—it consumes a write-window slot.
- **Cancellation is always a critical write** (it may use reserved capacity): an uncanceled order remains live and can execute against you, while an unplaced entry simply does not exist.
- Outcomes: `ok:true`—canceled; remember `order_id` in `cancelled` and **release the key** of the removed order from `placed` (pass the removed order’s key); `rateLimited`—not sent, the order is definitely still live → “not canceled,” hold any replacement; `unknown`—unconfirmed → treat the order as live, do not place another; `ok:false`—exchange rejection, log it.
- Order within one cycle: all cancellations first, then placements.

---

## 9. Retries and unknown outcome

| Write outcome | What is known | Action |
|---|---|---|
| `rateLimited` (local limiter) | exchange did not see the request | SKIPPED, retry next cycle; do not advance state |
| SDK `err` / `ok:false` | exchange rejected it (codes below) | REJECTED; for 21734, remember the rejected price; for 23000, the limiter was wrong—tighten it |
| `unknown` (25-second SDK timeout, 35 seconds to sidecar, loopback disconnect) | the order **may** have been sent | do not retry; reconcile against book/position; for cancellation, treat the order as live; for `update_leverage`, reread the account |
| `ok:true` + `tx_hash` | transaction accepted | resting → `placed` memory; IoC → position delta |

Idempotent and safe to retry: cancellation (using exact `order_id`), `update_leverage` (but each attempt is a write and takes a window slot; after a market rejection, do not retry every cycle—use a per-market cooldown, → `account-and-leverage.md`). Not idempotent: placement (deduplication by `client_order_index` is unconfirmed), especially an entry or position increase and a partial reduceOnly.

The Python SDK turns the exchange’s HTTP 429 (code 23000) into an exception → the sidecar returns 502 with text; to the caller this is an “exchange rejection” with text `Too Many Requests!: L1Address ratelimit reached … 40 requests per 60 second is allowed`. Recognize it by code `23000`, not HTTP status.

---

## 10. Observed error codes

| Code | Text (fragment) | Meaning | Response |
|---|---|---|---|
| **23000** | `Too Many Requests!: L1Address ratelimit reached … 40 requests per 60 second is allowed` | 40/60 s write window per L1 address | should not occur with a working limiter; if it does, the limiter misses some writes (leverage? cancellations?) |
| **21706** | (minimum size) | `base_amount` < `min_base_amount` | skip entries/partials; bump full close |
| **21734** | `too far from the mark` | limit order farther from the mark than the exchange accepts | remember rejected price for 5 minutes |

No other codes were observed; text arrives as a string in SDK `err`.

---

## 11. Write budget

- **Replacing one order costs 2 writes** (cancel + place). Ordinary writes can use the explicitly configured `limit − reserve`; critical writes (cancellations, reduceOnly) can use the entire configured `limit`. The exchange ceiling is 40/60 s. → `rate-limits.md`
- A full close without reduceOnly capping (cancel every order for the asset) would cost one write per order, with the position remaining open throughout—this is why §3 matters.
- A rejection by the **local** write window is temporary (retry next cycle), while a **minimum** rejection is persistent (until size changes). Distinguish them in the result type.

---

## 12. Dispatch order and protection

- Sort before sending: `Ioc` → `Gtc && reduceOnly` → other `Gtc`. Without this, protection waits behind entry orders; with a 40/60 s write window, the wait grows with order count, and once the budget is exhausted, protection starves first. The limiter’s reduceOnly reserve partly compensates, but dispatch order is more reliable.
- The exchange allows several live orders at the same price: duplicate protection is client-side (`placed` memory, §7.2), and cancellation must release the key.

---

## Pitfalls

| What breaks | Why | Correct approach |
|---|---|---|
| Order does not cancel even though the exchange returned OK | cancellation used rounded `order_index` | identity is the `order_id` string; cancel with the string (verified 2026-08-21) |
| Duplicate orders and the second cannot be canceled | two identifiers collapsed into one double | store and match by the `order_id` string |
| The same order is canceled repeatedly | book lags and confirmed cancellation is forgotten | `cancelled` memory for ~45 seconds; filter it from reads |
| More orders are in the book than intended | book lags and placement was repeated | `placed` memory for ~45 seconds |
| Several orders at the same price | `placed` memory key included size | key is “asset + side + price,” without size |
| Legitimate replacement is blocked by local memory | cancellation did not release the key in `placed` | cancel receives the removed order’s key and calls `releaseKey` |
| Rejection after rejection at one price, wasted writes | 21734, limit order too far from the mark | rejection memory for 5 minutes |
| Double position after timeout | `unknown` was interpreted as rejection | do not retry; reconcile |
| Fill recorded when none occurred | “success” = `tx_hash` | position delta; no delta → REJECTED |
| Position underfilled and blamed on “minimums” | spread wider than IoC cross | measure spread; use a 1.5% cross |
| Protection was not placed while entry orders were | dispatch order; more entries than fit in the window | IoC → GTC RO → GTC; window reserve |
| reduceOnly IoC orders do not go out for minutes | RO IoC was measured against the ordinary allowance | RO IoC is a critical write (window reserve) |

---

## Open questions / not verified

- Exchange-side deduplication by `client_order_index`; error text for a repeated index.
- GTT behavior after 28 days (canceled? extended?) and valid `order_expiry` bounds.
- IoC on an empty book, partial IoC fill, and its exact price (not in the response; trade history is required).
- `ORDER_TYPE_MARKET`, post-only, stops/TP-SL, `modify_order`, and `cancel_all_orders` exist in the SDK but were not verified.
- The 21734 threshold as a percentage from the mark; complete rejection-code list (only 23000, 21706, and 21734 were observed).
- Exact write-reflection lag in `accountActiveOrders` (45-second echo memory provided ample coverage; it was not measured) and position-update lag after IoC (polling for up to ~4.2 seconds was enough).
- Dispatch order IoC → GTC RO → GTC was not verified live.
- Whether reduceOnly IoC **larger** than the position is capped like resting reduceOnly (documentation says yes; not tested separately).

---

Facts verified through 2026-09-16. The Lighter API changes—recheck error codes, limits, and response shapes; before the first live order on a new instance, run the micro-cycle from the skill checklist.

---

<!-- 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._
