# Lighter — signing, keys, and SDK: a sidecar signer for TypeScript

Why Lighter cannot be signed directly from TS code, how the official Python SDK works, how to move signing into a separate process (a sidecar), what that process must and must not do, and how to verify that a key is bound to the intended account. Facts were verified on the robinhoodchain instance.

## TL;DR

1. **Lighter uses its own signing scheme; the API key is not EVM.** The private API key is 40 bytes (80 hex characters), and viem’s `privateKeyToAccount` **throws** on it. An address cannot be derived from the key; authorization is proven by the exchange (`check_client`) and by checking the account owner. *Verified 2026-08-20.*
2. **The official SDK is available only for Python and Go; there is no JS SDK.** `pip install lighter-sdk==1.1.4` includes the platform-specific signer binary (for example, `lighter/signers/lighter-signer-linux-amd64.so`), so Go does not need to be installed. Reimplementing cryptography in TS for live funds adds unnecessary risk. *Verified 2026-08-20.*
3. **For TS code, move signing into a separate local process** (Python, aiohttp): the trading process calls it over loopback HTTP. The key lives only in the signer’s environment and never enters the trading process.
4. **The signer is a thin layer with no trading logic:** it must not retry on its own (a silent order retry can double the position) or substitute sizes and prices (substitution creates a mismatch with what the trading process believes it placed). It signs exactly what it is told and returns the raw result.
5. **Use one long-lived `SignerClient` and one `asyncio.Lock` for all signatures.** The SDK’s nonce manager is optimistic: recreating the client for each request or signing concurrently breaks sequencing. *Verified 2026-08-20.*
6. **Timeout means “unknown outcome,” not rejection.** The signer returns this as a separate field (`unknown: true`, HTTP 504); the trading process must reconcile against the book instead of retrying. The same applies to cancellation and `update_leverage`.
7. **Fail closed on access:** the signer does not start without a bearer token and answers nobody if the token is empty. Treating an empty token as “allow everyone” would expose the trading-key signer as an unauthenticated HTTP process after one environment mistake.
8. **Call `check_client()` at startup; an error means `SystemExit`.** If the key does not match the account, every order will be rejected while the trading process concludes that the exchange is unavailable. Failing immediately is more honest.
9. **Account identity:** `account_index` (sub-account) + `api_key_index` (an account can have multiple keys). A wrong `account_index` means trading on someone else’s sub-account, so startup must compare `account.l1_address` with the expected address.

---

## 1. Keys and account

| Entity | What it is | Where it comes from |
|---|---|---|
| L1 address | owner wallet’s EVM address | the same address as in the EVM wallet; the 40/60 s write window is counted against it |
| `account_index` | account/sub-account number on the instance | instance UI or the `account?by=l1_address…` response (the latter was not verified) |
| `api_key_index` | API-key number on the account (multiple keys per account) | assigned when the key is registered |
| API private key | 40 bytes (80 hex characters), Lighter scheme, not secp256k1 EVM | generated by the SDK during key registration |

- **The key is not EVM.** `privateKeyToAccount` and EVM validation (a 64-hex regular expression) fail on it. An address cannot be derived from the key; authorization is proven by (a) the signer’s `check_client()` and (b) `account.l1_address === expected address`.
- **API-key registration** is based on documentation and SDK examples (not verified): the SDK generates a new key pair, registers it through a transaction signed by the owner’s L1 key, and binds it to `api_key_index`; a key can also be registered through the UI.
- **Multiple keys per account:** separate `api_key_index` values for separate processes avoid sharing a nonce. This was not verified, but the SDK keeps the nonce per key.

---

## 2. Python SDK: what is used

The `lighter-sdk` package (pip). Everything goes through `lighter.SignerClient(url, account_index, api_private_keys={api_key_index: key})`.

| Call | Returns | Notes |
|---|---|---|
| `check_client()` | `err \| None` | verifies that the key matches the account; call at startup and in `/health` |
| `create_auth_token_with_expiry(SignerClient.DEFAULT_10_MIN_AUTH_EXPIRY)` | `(token, err)` | token for private reads, 10 minutes |
| `await create_order(market_index, client_order_index, base_amount, price, is_ask, order_type, time_in_force, reduce_only, order_expiry, api_key_index)` | `(tx, resp, err)`; `resp.tx_hash` | only `tx_hash`; this is **not** confirmation of execution |
| `await cancel_order(market_index, order_index, api_key_index)` | `(tx, resp, err)` | by the exchange-assigned `order_index` |
| `await update_leverage(market_index, margin_mode, leverage, api_key_index)` | tuple `(tx, resp, err)` | parse tolerantly with respect to tuple length |
| `await close()` | | at shutdown |

Required SDK constants: `ORDER_TYPE_LIMIT`, `ORDER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL`, `ORDER_TIME_IN_FORCE_GOOD_TILL_TIME`, `DEFAULT_IOC_EXPIRY`, `DEFAULT_28_DAY_ORDER_EXPIRY`, `CROSS_MARGIN_MODE` (= 0), and `DEFAULT_10_MIN_AUTH_EXPIRY`.

The SDK also exposes the following, but they were not used (documented, not verified): `ORDER_TYPE_MARKET`, stop/take-profit types, `ORDER_TIME_IN_FORCE_POST_ONLY`, `cancel_all_orders`, `modify_order`, isolated margin (`ISOLATED_MARGIN_MODE`), and transfers.

`base_amount` and `price` are **integers**: `base_amount = round(sz × 10^supported_size_decimals)`, `price = round(px × 10^supported_price_decimals)`. → `markets-and-numbers.md` §3

---

## 3. Sidecar signer: contract (concept)

The interface below is an example. The port, process name, and environment-variable names are yours; the endpoint semantics and invariants are what matter.

### 3.1 Endpoints

| Method | Path | Auth | What it does |
|---|---|---|---|
| GET | `/health` | none | `check_client()`; returns `{ ok, error, account_index, api_key_index, url }`, showing which account it is bound to |
| GET | `/auth` | bearer | `{ ok, token }`—a 10-minute auth token |
| POST | `/order` | bearer | `create_order`; body `{ market_index, client_order_index, base_amount, price, is_ask, reduce_only?, ioc? }` → `{ ok, tx_hash }` |
| POST | `/cancel` | bearer | `cancel_order`; body `{ market_index, order_index }` → `{ ok, tx_hash }` |
| POST | `/leverage` | bearer | `update_leverage`; body `{ market_index, leverage, margin_mode? }` → `{ ok, tx_hash }` |

Response codes: `400`—a required field is missing or `amount/price ≤ 0` (a missing field must yield 400, not a silent default that places the wrong order); `401`—missing/incorrect bearer; `502`—the exchange/SDK returned `err` (`{ ok:false, error }`); `504`—SDK call timeout (`{ ok:false, unknown:true, error:'timeout' }`).

### 3.2 Example HTTP contract

```http
POST /order HTTP/1.1
Host: SIDECAR_URL
Authorization: Bearer YOUR_SIDECAR_TOKEN
Content-Type: application/json

{ "market_index": 32, "client_order_index": 1712345678, "base_amount": 100,
  "price": 160534, "is_ask": true, "reduce_only": false, "ioc": false }

HTTP/1.1 200 OK
{ "ok": true, "tx_hash": "0x…" }

HTTP/1.1 504 Gateway Timeout
{ "ok": false, "unknown": true, "error": "timeout" }
```

```http
POST /cancel HTTP/1.1
Authorization: Bearer YOUR_SIDECAR_TOKEN

{ "market_index": 32, "order_index": "12345678901234567" }
```

`order_index` is sent to the cancellation endpoint as a **string**: a JSON number cannot carry the exact ~1e16 value, while Python’s `int()` accepts the string without loss. → `orders.md` §6

### 3.3 Minimal skeleton (Python)

```python
import asyncio, os, sys, time
from aiohttp import web
import lighter

URL = os.environ["LIGHTER_BASE_URL"]            # https://api.rh.lighter.xyz or mainnet
ACC = int(os.environ["LIGHTER_ACCOUNT_INDEX"])  # your account_index
KIX = int(os.environ["LIGHTER_API_KEY_INDEX"])  # YOUR_API_KEY_INDEX
KEY = os.environ["LIGHTER_API_PRIVKEY"]         # 80 hex, not EVM; environment only
TOKEN = os.environ.get("SIGNER_TOKEN", "")

client: lighter.SignerClient | None = None
lock = asyncio.Lock()   # keep nonce sequential—concurrent signatures break it

def authorized(req) -> bool:
    if not TOKEN:           # fail closed: empty token = nobody
        return False
    return req.headers.get("authorization", "") == f"Bearer {TOKEN}"

async def guard(req):
    if not authorized(req):
        raise web.HTTPUnauthorized(text='{"error":"unauthorized"}', content_type="application/json")

async def h_health(req):
    err = client.check_client()
    return web.json_response({"ok": not err, "error": str(err) if err else None,
                              "account_index": ACC, "api_key_index": KIX, "url": URL})

async def h_auth(req):
    await guard(req)
    token, err = client.create_auth_token_with_expiry(lighter.SignerClient.DEFAULT_10_MIN_AUTH_EXPIRY)
    if err:
        return web.json_response({"ok": False, "error": str(err)}, status=502)
    return web.json_response({"ok": True, "token": token})

async def h_order(req):
    await guard(req)
    b = await req.json()
    try:   # required fields are explicit: missing = 400, not a silent default
        market = int(b["market_index"]); coi = int(b["client_order_index"])
        amount = int(b["base_amount"]); price = int(b["price"])
        is_ask = bool(b["is_ask"]); reduce_only = bool(b.get("reduce_only", False))
        ioc = bool(b.get("ioc", False))
    except (KeyError, TypeError, ValueError) as e:
        return web.json_response({"ok": False, "error": f"bad request: {e}"}, status=400)
    if amount <= 0 or price <= 0:
        return web.json_response({"ok": False, "error": "amount/price must be > 0"}, status=400)
    tif = (lighter.SignerClient.ORDER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL if ioc
           else lighter.SignerClient.ORDER_TIME_IN_FORCE_GOOD_TILL_TIME)
    expiry = (lighter.SignerClient.DEFAULT_IOC_EXPIRY if ioc
              else lighter.SignerClient.DEFAULT_28_DAY_ORDER_EXPIRY)
    async with lock:
        try:
            tx, resp, err = await asyncio.wait_for(client.create_order(
                market_index=market, client_order_index=coi, base_amount=amount, price=price,
                is_ask=is_ask, order_type=lighter.SignerClient.ORDER_TYPE_LIMIT,
                time_in_force=tif, reduce_only=reduce_only, order_expiry=expiry,
                api_key_index=KIX), timeout=25)
        except asyncio.TimeoutError:
            # A timeout does NOT mean “not placed”: the order may have been sent. Outcome is unknown.
            return web.json_response({"ok": False, "unknown": True, "error": "timeout"}, status=504)
    if err:
        return web.json_response({"ok": False, "error": str(err)}, status=502)
    tx_hash = getattr(resp, "tx_hash", None) or str(resp)
    return web.json_response({"ok": True, "tx_hash": tx_hash})   # NOT fill confirmation

async def h_cancel(req):
    await guard(req)
    b = await req.json()
    try:
        market = int(b["market_index"]); oidx = int(b["order_index"])   # string → int without loss
    except (KeyError, TypeError, ValueError) as e:
        return web.json_response({"ok": False, "error": f"bad request: {e}"}, status=400)
    async with lock:
        try:
            tx, resp, err = await asyncio.wait_for(
                client.cancel_order(market_index=market, order_index=oidx, api_key_index=KIX), timeout=25)
        except asyncio.TimeoutError:
            return web.json_response({"ok": False, "unknown": True, "error": "timeout"}, status=504)
    if err:
        return web.json_response({"ok": False, "error": str(err)}, status=502)
    return web.json_response({"ok": True, "tx_hash": getattr(resp, "tx_hash", None)})

async def h_leverage(req):
    await guard(req)
    b = await req.json()
    try:
        market = int(b["market_index"]); lev = int(b["leverage"])
        mode = int(b.get("margin_mode", lighter.SignerClient.CROSS_MARGIN_MODE))
    except (KeyError, TypeError, ValueError) as e:
        return web.json_response({"ok": False, "error": f"bad request: {e}"}, status=400)
    if lev < 1:
        return web.json_response({"ok": False, "error": "leverage must be >= 1"}, status=400)
    async with lock:
        try:
            res = await asyncio.wait_for(client.update_leverage(
                market_index=market, margin_mode=mode, leverage=lev, api_key_index=KIX), timeout=25)
        except asyncio.TimeoutError:
            return web.json_response({"ok": False, "unknown": True, "error": "timeout"}, status=504)
    # The SDK returns a (tx, resp, err) tuple—parse tuple length tolerantly: an SDK version change
    # must not turn into a silent “success.”
    err = res[-1] if isinstance(res, tuple) else None
    resp = res[1] if isinstance(res, tuple) and len(res) >= 2 else None
    if err:
        return web.json_response({"ok": False, "error": str(err)}, status=502)
    return web.json_response({"ok": True, "tx_hash": getattr(resp, "tx_hash", None)})

async def on_start(app):
    global client
    client = lighter.SignerClient(url=URL, account_index=ACC, api_private_keys={KIX: KEY})
    err = client.check_client()
    if err:
        raise SystemExit(f"FATAL: check_client failed: {err}")   # key does not match the account

async def on_stop(app):
    if client:
        await client.close()

app = web.Application()
app.on_startup.append(on_start); app.on_cleanup.append(on_stop)
app.add_routes([web.get("/health", h_health), web.get("/auth", h_auth),
                web.post("/order", h_order), web.post("/cancel", h_cancel),
                web.post("/leverage", h_leverage)])

if __name__ == "__main__":
    if not TOKEN:
        sys.exit(1)   # a trading-key signer does not start without authentication
    web.run_app(app, host="127.0.0.1", port=int(os.environ.get("SIGNER_PORT", "8700")), print=None)
```

### 3.4 What the signer stores and does not store

- **Stores:** nothing on disk. The key, `account_index`, `api_key_index`, instance URL, access token, and bind address/port come from the environment at startup. Therefore, the signer source contains no secrets.
- **Does not store or maintain:** a nonce on disk (the SDK keeps it in memory), an order journal, book state, or position sizes. Those belong to the trading process.
- **Does not perform:** retries, substitutions, or “smart” minimum checks. Its only validations are required-field presence and `amount/price > 0`.

### 3.5 Signer client in TS (concept)

```ts
export interface SidecarWriteResult {
  ok: boolean;
  /** Not sent by US (write window exhausted); the exchange did not see the request, so retrying is safe. */
  rateLimited?: boolean;
  /** Outcome UNKNOWN (timeout / loopback disconnect). Do not retry; reconcile against the book. */
  unknown?: boolean;
  tx_hash?: string;
  error?: string;
}

async function sidecarPost(path: '/order' | '/cancel' | '/leverage', body: unknown, timeoutMs = 35_000): Promise<SidecarWriteResult> {
  const ctrl = new AbortController();
  const t = setTimeout(() => ctrl.abort(), timeoutMs);
  try {
    const res = await fetch(`${SIDECAR_URL}${path}`, {
      method: 'POST', signal: ctrl.signal,
      headers: { 'content-type': 'application/json', authorization: `Bearer ${SIDECAR_TOKEN}` },
      body: JSON.stringify(body),
    });
    const json = (await res.json().catch(() => ({}))) as SidecarWriteResult;
    if (res.status === 504 || json.unknown) return { ok: false, unknown: true, error: json.error ?? 'timeout' };
    return { ok: json.ok === true, tx_hash: json.tx_hash, error: json.error };
  } catch (e) {
    // The localhost connection failed—the signer may have received the request and sent the order.
    return { ok: false, unknown: true, error: (e as Error).message };
  } finally {
    clearTimeout(t);
  }
}
```

Recommended timeouts: 25 seconds for the SDK call inside the signer; 35 seconds for HTTP from the trading process to the signer (longer than the inner timeout, so the signer’s 504 can arrive); 10 seconds for `/health`; 20 seconds for `/auth`. No retries in this function: repeating placement is a decision for the trading process that sees the book. The client-side write-window limiter sets `rateLimited` (→ `rate-limits.md`), not the signer.

---

## 4. Verifying key binding at startup

Startup order:

1. Read metadata (`orderBookDetails`).
2. **Wait for the signer for up to 90 seconds** (poll `/health` every 3 seconds), rather than failing on the first refusal. Reason: the signer may start later or restart, and a process that comes up before Python responds on `/health` exits with `exit 1`. Failure after 90 seconds is still fatal: without the signer, every order is rejected and the process silently idles.
3. Read `account?by=index` → compare `l1_address` with the expected address. A mismatch **aborts startup**: it means trading on someone else’s account.
4. Read positions and account value. A zero account balance is a warning, not an abort.

Periodically rechecking that “the key is still authorized” means the signer’s `/health` (`check_client`) plus the account owner. If the check itself is unavailable (`ok:false`), do not conclude that the key is unauthorized; the outcome is unknown.

---

## Pitfalls

| What breaks | Why | Correct approach |
|---|---|---|
| Process crashes on a Lighter key | the 40-byte key is not EVM; `privateKeyToAccount` throws | do not derive an address from the key; authorize through `check_client` + account owner |
| Signing failures, broken nonces | concurrent signing or recreating `SignerClient` | one long-lived client, all signatures under one lock |
| Duplicate order after timeout | timeout was interpreted as rejection and retried | `unknown:true` → reconcile against the book, do not retry |
| Trading-key signer is open to everyone | empty token means “allow everyone” | fail closed: without a token, do not start and do not answer |
| Trading process “sees the exchange as unavailable” when the key is wrong | `check_client` was not called at startup | `SystemExit` on `check_client` error |
| Process exits at startup while the signer is coming up | immediate `exit 1` on the first `/health` refusal | wait for `/health` for up to 90 seconds; only then fail fatally |

---

## Open questions / not verified

- Procedure for registering a new API key (which transaction, who signs it, per-account key limit)—known only from documentation and SDK examples.
- Whether the SDK signer works on Windows and macOS was not verified.
- Exchange-side deduplication by `client_order_index` is not confirmed; repeating `/order` with the same index may create a second order. → `orders.md` §5
- Nonce separation across several `api_key_index` values on one account is inferred from the SDK design and was not verified live.
- The Go SDK was not used at all.
- Direct TypeScript signing without a sidecar was not attempted; it was rejected as a live-funds risk, not as impossible.

---

Facts verified through 2026-09-16. The Lighter SDK and signing scheme can change—check call signatures against the current `lighter-sdk` version.

---

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