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
- 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
privateKeyToAccountthrows 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. - The official SDK is available only for Python and Go; there is no JS SDK.
pip install lighter-sdk==1.1.4includes 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. - 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.
- 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.
- Use one long-lived
SignerClientand oneasyncio.Lockfor all signatures. The SDK’s nonce manager is optimistic: recreating the client for each request or signing concurrently breaks sequencing. Verified 2026-08-20. - 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 andupdate_leverage. - 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.
- Call
check_client()at startup; an error meansSystemExit. 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. - Account identity:
account_index(sub-account) +api_key_index(an account can have multiple keys). A wrongaccount_indexmeans trading on someone else’s sub-account, so startup must compareaccount.l1_addresswith 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.
privateKeyToAccountand 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’scheck_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_indexvalues 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
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" }
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)
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)
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:
- Read metadata (
orderBookDetails). - Wait for the signer for up to 90 seconds (poll
/healthevery 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/healthexits withexit 1. Failure after 90 seconds is still fatal: without the signer, every order is rejected and the process silently idles. - Read
account?by=index→ comparel1_addresswith the expected address. A mismatch aborts startup: it means trading on someone else’s account. - 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_indexis not confirmed; repeating/orderwith the same index may create a second order. →orders.md§5 - Nonce separation across several
api_key_indexvalues 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.
© markpaper authors. Licensed under CC BY 4.0: when publishing or adapting this material, credit “markpaper — Lighter knowledge base” and link to the original and the license.