# Executing trades

Every order on Lighter is signed by a registered [API key](/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 |

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. Nonces may also skip ahead (any value above the last used, up to 2^47 − 1), which helps concurrent signers; the recipe stays sequential.

## 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
const books = await fetch(`${apiBaseUrl}/api/v1/orderBooks`).then((res) => res.json())
const market = books.order_books.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
The recipe uses the undocumented `GET /api/v1/orderBookDetails` instead — a superset of `orderBooks` that returns perp *and* spot markets, live prices, and per-market precision in one call.
:::

## 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](/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](/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.
