signer/sidecar.py
v0.2.1 · 13.7 KB
"""Lighter signer sidecar for @markpaper/lighter-kit.
WHY THIS EXISTS. Lighter has its own signature scheme and the API private key is not an EVM key
(40 bytes, 80 hex). The official SDKs are Python and Go only; there is no JS SDK, and re-implementing
the cryptography in TypeScript is not worth the risk with live money. So a TypeScript process talks to this
small HTTP service on loopback, and this service signs with the official `lighter-sdk`
(`pip install lighter-sdk`; the signer binary ships inside the wheel, nothing to compile).
WHAT IT DOES NOT DO. No trading decisions, no retries (a silent retry of an order is a second
position), no substitution of sizes or prices (a substitution diverges from what the caller believes
is placed), no "smart" minimum checks. It signs exactly what it was told and returns the raw answer.
The only checks are field presence and `amount/price > 0`.
WHAT THE CALLER MUST KNOW.
* `create_order` returns only a tx_hash. It is NOT a fill confirmation: an IoC fill is measured
as the position delta; a resting order appears in accountActiveOrders with a lag.
* A cancel goes by `order_index` assigned by the exchange; pass it as a STRING (values ~1e16 do
not survive JSON numbers). `int()` accepts the string as is.
* A timeout (HTTP 504, `unknown: true`) means the outcome is UNKNOWN: the order may have gone out.
Do not repeat blindly; reconcile against the book / position.
* Deduplication by `client_order_index` on the exchange side is NOT confirmed; a repeated POST
/order may create a second order.
* One long-lived SignerClient and one asyncio.Lock for all signatures: the SDK's nonce manager is
optimistic, re-creating the client per request or signing in parallel breaks the numbering.
* Fail-closed access: without a bearer token the process does not start and answers nobody.
CONFIGURATION (environment only; nothing is stored on disk):
LIGHTER_SIGNER_BASE_URL https://api.rh.lighter.xyz (robinhoodchain) or https://mainnet.zklighter.elliot.ai
LIGHTER_SIGNER_ACCOUNT_INDEX your account_index on THAT instance
LIGHTER_SIGNER_API_KEY_INDEX your api_key_index on that account
LIGHTER_SIGNER_API_PRIVATE_KEY the API private key (80 hex, not EVM); only here, never in the trading process
LIGHTER_SIGNER_TOKEN bearer token the caller must present (required, non-empty)
LIGHTER_SIGNER_BIND bind address, default 127.0.0.1 (keep it on loopback)
LIGHTER_SIGNER_PORT TCP port (required)
LIGHTER_SIGNER_SDK_TIMEOUT_SEC timeout of one SDK call, default 25 (the caller's HTTP timeout must be longer)
ROUTES (see knowledge base signing-and-sdk.md §3):
GET /health -> { ok, error, account_index, api_key_index, url } (no auth; check_client)
GET /auth -> { ok, token } (bearer; 10-minute auth token)
POST /order -> { ok, tx_hash } body { market_index, client_order_index, base_amount, price, is_ask, reduce_only?, ioc? }
POST /cancel -> { ok, tx_hash } body { market_index, order_index } (order_index as a string)
POST /leverage -> { ok, tx_hash } body { market_index, leverage, margin_mode? }
Status codes: 400 missing field / non-positive amount or price; 401 bad or missing bearer;
502 SDK or exchange returned an error ({ ok:false, error }); 504 SDK call timed out ({ ok:false, unknown:true, error:"timeout" }).
"""
import asyncio
import inspect
import os
import sys
import time
from aiohttp import web
import lighter
# --- configuration -------------------------------------------------------------------------------
def _required(name: str) -> str:
value = os.environ.get(name, "")
if not value:
sys.stderr.write(f"FATAL: {name} is not set\n")
sys.exit(1)
return value
URL = _required("LIGHTER_SIGNER_BASE_URL").rstrip("/")
ACCOUNT_INDEX = int(_required("LIGHTER_SIGNER_ACCOUNT_INDEX"))
API_KEY_INDEX = int(_required("LIGHTER_SIGNER_API_KEY_INDEX"))
API_PRIVATE_KEY = _required("LIGHTER_SIGNER_API_PRIVATE_KEY")
TOKEN = os.environ.get("LIGHTER_SIGNER_TOKEN", "")
BIND = os.environ.get("LIGHTER_SIGNER_BIND", "127.0.0.1")
PORT = int(_required("LIGHTER_SIGNER_PORT"))
SDK_TIMEOUT_SEC = float(os.environ.get("LIGHTER_SIGNER_SDK_TIMEOUT_SEC", "25"))
client: "lighter.SignerClient | None" = None
lock = asyncio.Lock() # the nonce is sequential: parallel signatures break it
def log(*parts: object) -> None:
# Never log the key or the token. Order lines carry market/coi/amount/price only.
print(f"[{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}]", *parts, flush=True)
async def _maybe_await(value):
"""`check_client()` is synchronous in the SDK versions used; tolerate an awaitable anyway."""
if inspect.isawaitable(value):
return await value
return value
# --- access --------------------------------------------------------------------------------------
def authorized(req: web.Request) -> bool:
# FAIL-CLOSED: an empty token means nobody is allowed, not everybody. Start without a token is
# refused in __main__; this is the second belt.
if not TOKEN:
return False
return req.headers.get("authorization", "") == f"Bearer {TOKEN}"
def guard(req: web.Request) -> None:
if not authorized(req):
raise web.HTTPUnauthorized(text='{"ok": false, "error": "unauthorized"}', content_type="application/json")
def bad_request(message: str) -> web.Response:
return web.json_response({"ok": False, "error": f"bad request: {message}"}, status=400)
def sdk_error(err: object) -> web.Response:
# The Python SDK raises the exchange's HTTP 429 (code 23000) as an exception; the text carries the
# code. The caller classifies by the code in the text, not by this status.
return web.json_response({"ok": False, "error": str(err)}, status=502)
def unknown_outcome() -> web.Response:
# A timeout does NOT mean "not placed": the order may have gone out. The outcome is unknown.
return web.json_response({"ok": False, "unknown": True, "error": "timeout"}, status=504)
def tx_hash_of(resp: object) -> "str | None":
tx = getattr(resp, "tx_hash", None)
return str(tx) if tx else None
# --- routes --------------------------------------------------------------------------------------
async def h_health(req: web.Request) -> web.Response:
if client is None:
return web.json_response({"ok": False, "error": "client not initialised"}, status=503)
err = await _maybe_await(client.check_client())
return web.json_response(
{
"ok": not err,
"error": str(err) if err else None,
"account_index": ACCOUNT_INDEX,
"api_key_index": API_KEY_INDEX,
"url": URL,
}
)
async def h_auth(req: web.Request) -> web.Response:
guard(req)
assert client is not None
token, err = client.create_auth_token_with_expiry(lighter.SignerClient.DEFAULT_10_MIN_AUTH_EXPIRY)
if err:
return sdk_error(err)
return web.json_response({"ok": True, "token": token})
async def h_order(req: web.Request) -> web.Response:
guard(req)
assert client is not None
try:
body = await req.json()
except Exception as e: # noqa: BLE001 - any parse failure is a 400
return bad_request(f"invalid json: {e}")
# Required fields are named explicitly: a missing field must be a 400, not a silent default
# that places a different order.
try:
market = int(body["market_index"])
coi = int(body["client_order_index"])
amount = int(body["base_amount"])
price = int(body["price"])
is_ask = bool(body["is_ask"])
reduce_only = bool(body.get("reduce_only", False))
ioc = bool(body.get("ioc", False))
except (KeyError, TypeError, ValueError) as e:
return bad_request(str(e))
if amount <= 0 or price <= 0:
return bad_request("base_amount and price must be > 0")
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=API_KEY_INDEX,
),
timeout=SDK_TIMEOUT_SEC,
)
except asyncio.TimeoutError:
log(f"order TIMEOUT market={market} coi={coi} - outcome UNKNOWN")
return unknown_outcome()
if err:
log(f"order FAIL market={market} coi={coi} err={err}")
return sdk_error(err)
tx_hash = tx_hash_of(resp) or str(resp)
log(
f"order OK market={market} coi={coi} {'ASK' if is_ask else 'BID'} amount={amount} price={price} "
f"ro={reduce_only} ioc={ioc} tx={tx_hash[:16]}"
)
return web.json_response({"ok": True, "tx_hash": tx_hash}) # NOT a fill confirmation
async def h_cancel(req: web.Request) -> web.Response:
guard(req)
assert client is not None
try:
body = await req.json()
except Exception as e: # noqa: BLE001
return bad_request(f"invalid json: {e}")
try:
market = int(body["market_index"])
order_index = int(body["order_index"]) # a string is accepted as is: no precision loss
except (KeyError, TypeError, ValueError) as e:
return bad_request(str(e))
async with lock:
try:
tx, resp, err = await asyncio.wait_for(
client.cancel_order(market_index=market, order_index=order_index, api_key_index=API_KEY_INDEX),
timeout=SDK_TIMEOUT_SEC,
)
except asyncio.TimeoutError:
log(f"cancel TIMEOUT market={market} order_index={order_index} - outcome UNKNOWN")
return unknown_outcome()
if err:
log(f"cancel FAIL market={market} order_index={order_index} err={err}")
return sdk_error(err)
log(f"cancel OK market={market} order_index={order_index}")
return web.json_response({"ok": True, "tx_hash": tx_hash_of(resp)})
async def h_leverage(req: web.Request) -> web.Response:
guard(req)
assert client is not None
try:
body = await req.json()
except Exception as e: # noqa: BLE001
return bad_request(f"invalid json: {e}")
# Leverage on Lighter is the market's initial margin fraction for the account. In CROSS mode it is
# the requirement to OPEN; liquidation uses the maintenance fraction, so changing it under an open
# position changes capacity and does not move the liquidation price (verified live 2026-08-23).
try:
market = int(body["market_index"])
leverage = int(body["leverage"])
mode = int(body.get("margin_mode", lighter.SignerClient.CROSS_MARGIN_MODE))
except (KeyError, TypeError, ValueError) as e:
return bad_request(str(e))
if leverage < 1:
return bad_request("leverage must be >= 1")
async with lock:
try:
res = await asyncio.wait_for(
client.update_leverage(market_index=market, margin_mode=mode, leverage=leverage, api_key_index=API_KEY_INDEX),
timeout=SDK_TIMEOUT_SEC,
)
except asyncio.TimeoutError:
log(f"leverage TIMEOUT market={market} leverage={leverage} - outcome UNKNOWN")
return unknown_outcome()
# The SDK returns a tuple (tx, resp, err); parse tolerant to its length so that an SDK version
# change does not become a silent "success".
err = res[-1] if isinstance(res, tuple) and len(res) > 0 else None
resp = res[1] if isinstance(res, tuple) and len(res) >= 2 else None
if err:
log(f"leverage FAIL market={market} leverage={leverage} err={err}")
return sdk_error(err)
log(f"leverage OK market={market} leverage={leverage} mode={mode}")
return web.json_response({"ok": True, "tx_hash": tx_hash_of(resp)})
# --- lifecycle -----------------------------------------------------------------------------------
async def on_start(app: web.Application) -> None:
global client
client = lighter.SignerClient(url=URL, account_index=ACCOUNT_INDEX, api_private_keys={API_KEY_INDEX: API_PRIVATE_KEY})
err = await _maybe_await(client.check_client())
if err:
# The key does not fit the account: refuse to start. Otherwise every order would be rejected
# and the caller would read that as "the exchange is down".
raise SystemExit(f"FATAL: check_client failed: {err}")
log(f"signer ready: account_index={ACCOUNT_INDEX} api_key_index={API_KEY_INDEX} url={URL} bind={BIND}:{PORT} token=set")
async def on_stop(app: web.Application) -> None:
if client is not None:
await client.close()
def build_app() -> web.Application:
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),
]
)
return app
if __name__ == "__main__":
if not TOKEN:
sys.stderr.write("FATAL: LIGHTER_SIGNER_TOKEN is not set - a signer of a trading key does not start without authentication\n")
sys.exit(1)
web.run_app(build_app(), host=BIND, port=PORT, print=None)