> **Can't find what you're looking for?** Use `search_docs` on the docs MCP server at `https://www.openfort.io/api/mcp` to find what you need.
>
> **Have feedback?** Use `submit_feedback` on the same MCP server.

# Executing trades

Every order on Lighter is signed by a registered [API key](https://www.openfort.io/docs/recipes/lighter/api-keys), not your Openfort wallet. This recipe signs orders with a vendored WASM build of `lighter-go` — the only official signer implementations are Go and Python, so a server component is required regardless of which language your app is written in.

## Order types

| Type | Value | Description |
|------|-------|-------------|
| Limit | `0` | Rests on the book at a specified price until filled, canceled, or expired |
| Market | `1` | Fills immediately against the book at the best available price |
| Stop-loss | `2` | Activates a market order once the trigger price is crossed |
| Stop-loss limit | `3` | Activates a limit order once the trigger price is crossed |
| Take-profit | `4` | Activates a market order once the profit target is crossed |
| Take-profit limit | `5` | Activates a limit order once the profit target is crossed |
| TWAP | `6` | Splits a large order into sub-orders executed over time |

Values `7`–`8` are internal (TWAP sub-orders, liquidations). Stop and take-profit types also require a `triggerPrice`.

| Time-in-force | Value | Behavior |
|---------------|-------|----------|
| Immediate-or-Cancel | `0` | Fills what it can immediately and cancels the remainder |
| Good-Till-Time | `1` | Rests on the book until filled, canceled, or `orderExpiry` |
| Post-Only | `2` | Rejected if any part would match immediately — maker-only |

Post-Only is also the only order type a [maker-only API key](https://www.openfort.io/docs/recipes/lighter/api-keys#maker-only-keys) can submit — that restriction is what buys it the lower-latency execution path.

This recipe's TypeScript wrapper (`server/signer/signer.ts`) only exports the constants the demo uses (IOC limit orders) — `SignCreateOrder` accepts the rest, see `types/txtypes/constants.go` in `lighter-go` before wrapping them:

```ts
export const ORDER_TYPE_LIMIT = 0
export const ORDER_TYPE_MARKET = 1

export const TIME_IN_FORCE_IMMEDIATE_OR_CANCEL = 0
export const TIME_IN_FORCE_GOOD_TILL_TIME = 1
```

## The `orderExpiry` regime — no universal default

`orderExpiry` is required on every order, and which value is valid depends on the order's type and time-in-force. There is no value that works for all of them:

| Type / time-in-force | Required `orderExpiry` |
|-----------------------|--------------------------|
| Market order | `0` (`NilOrderExpiry`) |
| Limit + Immediate-or-Cancel | `0` |
| Limit + Good-Till-Time | a future millisecond timestamp, 5 minutes to 30 days out |
| Limit + Post-Only | a future millisecond timestamp, 5 minutes to 30 days out |
| Stop-loss / Take-profit / TWAP | their own combinations — check `create_order.go`'s `Validate()` before implementing |

:::warning
Passing `0` for a Good-Till-Time or Post-Only order, or a timestamp for a market/IOC order, fails with `OrderExpiry is invalid` — make `orderExpiry` an explicit parameter rather than hardcoding a value copied from an example.
:::

## Place an order

```ts
import { signCreateOrder, ORDER_TYPE_MARKET, TIME_IN_FORCE_IMMEDIATE_OR_CANCEL, ORDER_EXPIRY_NIL } from './signer/signer.js'

const signed = signCreateOrder({
  marketIndex: 0, // ETH perp on mainnet
  clientOrderIndex: Date.now(),
  baseAmount: 1000, // integer, scaled by the market's base-amount precision
  price: 300000, // integer, scaled by the market's price precision
  isAsk: false, // buy
  orderType: ORDER_TYPE_MARKET,
  timeInForce: TIME_IN_FORCE_IMMEDIATE_OR_CANCEL,
  reduceOnly: false,
  triggerPrice: 0,
  orderExpiry: ORDER_EXPIRY_NIL, // required 0 for market orders
  nonce,
  apiKeyIndex,
  accountIndex,
})

const result = await sendTx(config, signed.txType, signed.txInfo)
```

* **`price` on a market or IOC order is a slippage bound, not a quote** — the worst execution price you'll accept. The demo derives it from the live price with a 0.5% buffer rather than hardcoding one.
* **`clientOrderIndex`** is a client-chosen identifier bounded to 2^48 − 1 — `Date.now()` fits.

## Nonces

Every signed transaction carries a nonce scoped to its `(accountIndex, apiKeyIndex)` pair — fetch the next one from `GET /api/v1/nextNonce` before signing. A syntactically valid transaction that the sequencer later rejects still **consumes its nonce**, so refetch after failures instead of reusing the old value.

That round-trip is optional. Set the `SkipNonce` attribute (4th in `L2TxAttributes`, `skip_nonce` in the Python SDK, the `skipNonce` argument on every WASM binding) to `1` and Lighter drops the `new_nonce = old_nonce + 1` rule for any strictly increasing value below 2^47 − 1 — so a latency-sensitive signer can keep its own counter and never call `nextNonce` on the hot path. This recipe passes `0` and stays sequential; see [Lower-latency keys](https://www.openfort.io/docs/recipes/lighter/api-keys#lower-latency-keys).

## Confirming fills — `sendTx` won't tell you

A successful `sendTx` response is only `{ code, tx_hash, predicted_execution_time_ms }` — the transaction was accepted, not necessarily executed. Execution is **asynchronous**: reading balances immediately after submitting races Lighter's own state update and misreads filled orders as "no fill."

* `predicted_execution_time_ms` is a **Unix timestamp**, not a duration — typically ~500 ms in the future. It's the earliest moment worth checking, not an upper bound.
* The authoritative record is `GET /api/v1/trades` — it 400s without `sort_by` and `limit`, and requires an auth token.

The recipe's server polls `/api/v1/trades` for a trade matching the submitted `tx_hash` (up to ~8 s past the predicted execution time) and only then responds `filled: true` with the matched size and price. `filled: false` only means no trade was observed in the window — an IOC order that expired unmatched leaves no record, so don't present it as "nothing was charged."

## Precision

Lighter's prices and base amounts are **integers**, scaled per market — not decimal strings. Fetch market metadata to get the right scale before submitting:

```ts
// No market_id → every market; filter=all (default) | perp | spot
const details = await fetch(`${apiBaseUrl}/api/v1/orderBookDetails`).then((res) => res.json())
const market = details.order_book_details.find((book) => book.market_id === 0)

console.log('Size decimals:', market.supported_size_decimals)
console.log('Price decimals:', market.supported_price_decimals)
console.log('Min base amount:', market.min_base_amount)
console.log('Min quote amount:', market.min_quote_amount)
```

Scale your human-readable price and size by the market's decimals before passing them to `signCreateOrder` — a `supported_price_decimals` of `2` means a $3000.00 price is submitted as `300000`. Precision and minimums are per-market and can change, so refetch per session and never reuse one market's scale for another. The `min_base_amount` / `min_quote_amount` minimums apply to maker orders — the larger of the two binds.

:::tip
`GET /api/v1/orderBookDetails` is a superset of `orderBooks`: perp markets in `order_book_details`, spot in `spot_order_book_details`, plus live `mark_price` / `last_trade_price`, `taker_fee` / `maker_fee`, and each market's decimals and minimums — one call instead of one per market. It's what this recipe uses for both discovery and pricing.
:::

:::warning
`status: "active"` does not mean a market can be traded. Testnet lists 176 active markets, and at any given moment only about three of them (ETH, BTC, SOL) hold resting orders on both sides — an order on any of the others is rejected because the book is empty. Nothing in `orderBookDetails` answers this reliably: `daily_quote_token_volume` reads `0` for every testnet market, and `last_trade_price > 0` over-reports (a market that traded once but has no resting orders now still passes it).

Only the book itself is authoritative, so check it before offering a market to a user. Probing all 176 is wasteful — narrow first on `last_trade_price > 0 || open_interest > 0`, which cuts the candidates to a handful, then confirm each with `GET /api/v1/orderBookOrders?market_id=<id>&limit=1` and keep the ones with both a bid and an ask. Cache the result; the set changes slowly.

```ts
const details = await getOrderBookDetails(config)
const candidates = details.filter(
  (d) => d.status === 'active' && (Number(d.last_trade_price ?? 0) > 0 || Number(d.open_interest ?? 0) > 0),
)
const tradeable = []
for (const market of candidates) {
  const book = await getOrderBookOrders(config, market.market_id, 1)
  if (book.bids.length > 0 && book.asks.length > 0) tradeable.push(market)
}
```
:::

## Cancel orders

Cancel a single order by its index, or every open order on a market at once:

```ts
import { signCancelOrder, signCancelAllOrders } from './signer/signer.js'

// Cancel one order
const signed = signCancelOrder({ marketIndex: 0, orderIndex, nonce, apiKeyIndex, accountIndex })

// Cancel everything on every market
const NIL_MARKET_INDEX = 255 // types/txtypes/constants.go: NilMarketIndex int16 = 255
const signedAll = signCancelAllOrders({ nonce, apiKeyIndex, accountIndex })
```

:::info
The "all markets" sentinel is **255**, not `-1` or `0` — the signer doesn't validate it, so a wrong sentinel signs a transaction that silently targets the wrong market.
:::

## Read open orders

Reading account-scoped order state needs a bearer [auth token](https://www.openfort.io/docs/recipes/lighter/api-keys#read-only-auth-tokens), not the raw signature:

```ts
const authToken = createAuthToken(deadlineUnixSeconds, apiKeyIndex, accountIndex)

const response = await fetch(
  `${apiBaseUrl}/api/v1/accountActiveOrders?account_index=${accountIndex}&market_id=0`,
  { headers: { authorization: authToken } },
)
```

## Error codes worth knowing

`sendTx`'s error responses vary widely in how specific they are:

| HTTP | Code | Meaning | Notes |
|------|------|---------|-------|
| 400 | `21120` | Invalid signature | Corrupted bytes, **or a stale key after a [rotation](https://www.openfort.io/docs/recipes/lighter/api-keys#rotation--every-submit-replaces-the-key)** — the recipe's server self-tests for this at startup |
| 400 | `21501` | Invalid tx info | Malformed JSON in the `tx_info` field |
| 400 | `20001` | Invalid param | e.g. a nonexistent account index |
| 400 | `29500` | Internal server error | Generic — server faults, signing for an index with **no** registered key, and other downstream failures all collapse into this. Not diagnostic: check `GET /api/v1/apikeys` first |
| — | `21100` | Account not found | Returned by `GET /api/v1/account`; treat as "not onboarded yet," not a hard error |

A `21120` almost always means your key was rotated out from under you — re-register rather than retrying.
