Perps via Lighter
The order-key model: a server-side key that can trade but cannot withdraw, and the signer that holds it.
The perps in the terminal and behind the hedged vaults are Lighter's, not Kerf's. Kerf is a client of that venue, and the arrangement it uses is the one trust assumption in the product besides the hedger key: a server holds an order key for your Lighter account. The fact not to miss is the shape of that key - it can place and cancel orders and it cannot withdraw, because a Lighter withdrawal needs a signature from your own wallet, and the service that holds the key contains no code path that submits one.
The order-key model
Lighter is a zk exchange. Orders are signed with an API key that is separate from the wallet key, and the wallet authorises that key once with a ChangePubKey transaction. Kerf runs a small FastAPI service, the signer (services/signer), that generates the key on your behalf, stores it encrypted, and signs orders with it when the API asks. The browser never sees Lighter key material; the API never holds it either - it forwards intent to the signer over HTTP with a shared x-signer-secret header, and reads back the account, positions and resting orders the same way.
Three parties, then, and a fourth that is the point: the browser, which holds your wallet and nothing else; the API, which proves every write came from the wallet that owns the address; the signer, which holds the order key and signs within bands; and Lighter, which executes. The reason for the arrangement is the terminal: a market order that fills in one click, rather than a wallet popup per order, is what makes a perp terminal usable, and it is bought with a key that is bounded to trading.
What the server can and cannot do
| With the order key | Needs your wallet | |
|---|---|---|
| Place an order (market or limit) | yes - POST /order | |
| Cancel an order | yes - POST /cancel | |
| Set leverage on a market | yes - update_leverage before create_order, because Lighter has no per-order leverage | |
| Read the account, positions, resting orders | yes - GET /account, /positions, /orders | |
| Bind a new order key | ChangePubKey, signed by the wallet, submitted through /bind | |
| Withdraw | no - there is no code path | POST /withdraw-intent returns an unsigned payload; the wallet signs and submits it |
"Cannot withdraw" is necessary, not sufficient, and the signer says so in its own header comment: a resting sell at 0.0001, crossed from the attacker's own Lighter account, moves value out without a withdrawal ever being signed. So /order refuses to sign anything it cannot measure. Both order types fetch the current mark from Lighter first and answer 503, signing nothing, when they cannot - fail closed, because a band is only as good as the mark it is measured from. Then:
| Parameter | Range | Default | Meaning |
|---|---|---|---|
MARKET_SLIPPAGE_FLOOR_BPS | 500 bps | - | hard-coded, not configurable; a market order is priced at mark × (1 ± max(requested, 500) / 1e4) |
DEFAULT_SLIPPAGE_BPS | > 0 bps | 100 | what a market order asks for when the caller sends no slippageBps; the floor lifts it to 500 |
MARKET_SLIPPAGE_CEILING_BPS | ≥ floor, ≤ band bps | 1000 | a market order asking for more is rejected (422), not clamped; a sell at 10 000 would sign a price of zero |
LIMIT_PRICE_BAND_BPS | > 0 bps | 1000 | the price that gets signed - the limit price, or the market path's computed one - may not sit further from the mark |
MAX_ORDER_NOTIONAL_USD | > 0 USD | 250 000 | size × mark per order, both types |
The band is measured on the price that gets signed, whichever path computed it. A market order is a limit order at mark × (1 ± slippage), so checking only a request's price field would have let type=market, slippageBps=2000 sign 20 % from the mark while type=limit, price=89 was refused. The service refuses to boot with a ceiling below the floor (every market order would fail) or above the band (a market order could sign a price the limit path refuses). Every rejection is a 422 whose detail names the band and the numbers; a Lighter error on submit is a 400; an address that was never onboarded is 404 and one onboarded but not yet bound is 409.
The hedger bot uses exactly this path for the hedged vaults' shorts - venue.placeOrder with the vault's address - which is why the hedged vaults chapter can say the hedger key can trade and cannot withdraw with the same certainty.
Onboarding
- 01POST /perp/onboardThe API verifies the request envelope (below), so only the wallet that controls
addresscan ask for a key, and forwards the address to the signer. The signer's/onboardgenerates a keypair, looks up the address's Lighter account index if one exists, stores the private key Fernet-encrypted underKEY_STORE_DIRwithbound: false, and returnsaccountIndex,apiKeyIndex(0),publicKeyandchangePubKeyPayload. Calling it again for the same address returns the same public information and generates nothing - the key store cannot be grown by repetition. - 02The wallet signs ChangePubKey
changePubKeyPayloadis the message Lighter needs to associate the new public key with your account. Only your wallet can sign it, and nothing about the order key is usable until it has. - 03POST /perp/bindThe API forwards
l1Signatureto the signer's/bind, which submits theChangePubKeythrough the SDK, records the account index Lighter answered with, and flips the record tobound: true. Until then/orderand/cancelanswer 409 "address not bound". On the sim venuebindhas nothing to bind and answersbound: truestraight away. - 04GET /perp/account/:addressReports
accountIndexandapiKeyBoundfrom the signer's key store - the onboarding state, not a Lighter field - alongsideequity,marginandfundingmapped from Lighter's account. The terminal shows the Onboard call-to-action whileapiKeyBoundis false and the trade box once it is true.
The 500 bps floor
A market order never reaches Lighter as a market order. The signer re-prices it as a limit order at mark × (1 + s) for a buy and mark × (1 − s) for a sell, where s = max(slippageBps ?? DEFAULT_SLIPPAGE_BPS, MARKET_SLIPPAGE_FLOOR_BPS) / 1e4 - never less than 500 bps, whatever the request asked for - and returns the priceUsed with the order id. The floor exists because the mark can move between the quote you saw and the block your order lands in, and on a thin book a tighter bound turns market orders into random rejections; a looser one is not a bound at all, which is what the ceiling and the band above are for. The web's @kerf/core slippage table carries the same number as DEFAULT_SLIPPAGE_BPS.marketFloor, and the Perp form says it beside the button: "Market orders are re-priced by the venue with a 500 bps floor." The liquidation price the form prints is the isolated estimate from lib/trade/spot.ts (MAINTENANCE_FRACTION 0.5, so a 2x long liquidates 25 % down), not Lighter's own figure.
Limit orders carry the price you set; only the 10 % band and the notional cap apply to them.
Request authentication on /perp/*
address in a /perp/* body names an account, and nothing in a plain HTTP request proves the caller owns it. Every POST /perp/{onboard, bind, order, cancel, withdraw-intent} therefore carries, next to the route's own fields, an envelope auth with three members: a nonce (0x plus 16 to 64 random bytes, PERP_NONCE_MIN_BYTES/PERP_NONCE_MAX_BYTES), a ts in unix seconds, and an EIP-712 signature by address over
PerpAction { string action; address account; bytes32 payloadHash; string nonce; uint256 ts }
under the domain { name: 'Kerf', version: '1', chainId } - the same domain the Theses feed signs with. action is fixed per route (PERP_ACTIONS); the client does not choose it. payloadHash is the keccak256 of a canonical JSON of the body without auth: keys sorted recursively, no whitespace, JSON.stringify's number and string forms, undefined dropped. The wallet therefore signs the exact order, cancel or withdrawal it is sending, and a signature lifted from one body cannot be attached to another. The pure half - the typed data, the canonical JSON, a dependency-free keccak - is packages/core/src/perpAuth.ts, which both the API and the web import so the message can only drift in one place; apps/api/src/perp/auth.test.ts cross-checks the keccak against viem's.
requirePerpAuth runs the checks in this order, and the order is the point:
| # | Check | Failure |
|---|---|---|
| 1 | the body is an object with a well-formed address | 400 invalid_address / invalid_body |
| 2 | auth is present with a well-formed nonce, a finite ts and a hex signature | 401 unauthorized |
| 3 | now − ts is within PERP_AUTH_TS_SKEW_SECONDS (300) either way | 401 stale_ts |
| 4 | verifyTypedData recovers address over the hash of this body | 401 unauthorized |
| 5 | (address, nonce) has not been seen before | 409 nonce_used |
The nonce is consumed last and only for an envelope that verified, so a forged or tampered request can never burn a nonce the real signer is about to use. A nonce row lives until ts + 300 s + NONCE_EXPIRY_SLACK_SECONDS (60), the slack covering drift between an API replica and Postgres now(); after that the envelope is stale_ts before the nonce is ever read, so dropping the row is not a replay hole. GET /perp/account, /positions and /orders are reads and carry no envelope. The route only parses its own fields (market, side, size, type, price, leverage, reduceOnly, clientId) after the envelope has passed, and the address it hands the venue is the one the signature recovered to.
Market data and its fallbacks
Marks, 24 h change, high and low, volume, open interest and funding come from MarketData in the API, one cached view (CACHE_MARKETS_MS, 15 s) of every market in the symbol table - 57 rows on 2026-09-07, mirroring what Lighter listed that day. The order of sources is fixed:
- Lighter REST
GET /api/v1/orderBookDetailsis the source of truth for the stats./orderBooksis the same list without any numbers - metadata only - which is why the snapshot cannot come from it.open_interestarrives in base units and is multiplied by the mark;daily_price_changeis already a percent. - Lighter WebSocket
market_stats/allkeeps a warm map of the same snapshots and only fills the gaps between REST polls; REST wins where both answer. It reconnects with exponential backoff capped at 30 s, so a dead socket degrades to slightly staler numbers, not an outage. The server opens it wheneverLIGHTER_WS_URLresolves, and since that defaults towss://api.rh.lighter.xyz/streamit is open in every deployment, sim venue included. - Funding is its own endpoint,
GET /api/v1/funding-rates, becauseorderBookDetailscarries a market's funding parameters and never its current rate. The response has one row per reference exchange per market; only the row withexchange: "lighter"is Lighter's own, and its hourly fraction is multiplied by 100 because@kerf/core's basis signal works in percent. - Hyperliquid
allMidsfills the mark for a crypto market whose row has ahyperliquidid and no Lighter mark - the small Robinhood-native memes have none, so they stay Lighter-only. - Yahoo Finance fills the mark, change, high, low and volume for a stock, index or commodity with a
yahooticker; pre-IPO markets have no public quote and none.
A symbol no source answers is still returned, with mark: null and source: 'none', so a Radar row or a market-list row is never silently dropped; a row filled by a fallback keeps funding and open interest null, which the UI reads as "no perp leg". Candles for the chart follow the same chain with the same cache (CACHE_CANDLES_MS, 60 s): Lighter candlesticks by market id, then Hyperliquid, then Yahoo, whose 60-minute bars are folded into 4-hour ones because it has no native 4 h resolution. Sparklines on the market list are real hourly closes walked in the background one market every 1.5 s. Lighter's own symbols differ in places (GOLD is Lighter's XAU); everything Lighter-facing goes through lighterSymbol() and symbolFromLighter(), so the rest of the API never sees them.
The spot side is separate. The pool leg of a Radar row - TVL, tick, fee tier, and the rolling 24 h volume, fee and trade count - is read straight from the chain by the API (chain/poolSnapshot.ts, chain/poolStats.ts over Swap logs), because the public RPC keeps no archive state and the indexer cannot see pools created before its start block; Indexer, API and bots explains that limit.
When the API itself is unreachable the web does not show a stale number as if it were live: every page hook falls back to a fixture from lib/fixtures/ and renders the Sample data tag from its isSampleData flag. The tag means "what you are looking at did not come from the API", not "one upstream source is down" - a market Lighter has stopped quoting shows a blank, not a fixture.
SimVenue: the simulated venue
The API picks its venue from VENUE: lighter builds LighterVenue, an HTTP client for the signer; anything else, and the default, is SimVenue. It has nothing to do with the web's wallet provider - a Privy app id decides how you sign in, VENUE decides where an order goes.
SimVenue is an in-memory book priced off the real marks from MarketData, so /trade and /portfolio are fully explorable without a Lighter account, a signer or a key. Every new account is credited SIM_STARTING_EQUITY (10 000 USDG) on onboard, which also reports apiKeyBound: true; market orders fill at mark × (1 ± SIM_SLIPPAGE_BPS / 1e4) (5 bps, against the taker); limit orders fill immediately when marketable and otherwise rest until tick() sees the mark cross them; margin is isolated per market with maintenance at MAINTENANCE_FRACTION (0.5) of initial, so a 2x long liquidates 25 % down; funding accrues hourly from each market's funding1h, longs paying a positive rate. State is plain JSON in SimAccountsRepo - in memory, or the sim_accounts table when a database is configured - and reads never persist, so browsing /portfolio for a stranger's address creates no rows. Everything is deterministic under the injected clock and market data, which is how its tests get the same fills every run. Nothing reaches Lighter, /perp/withdraw-intent answers 503 sim_no_withdraw, and the write envelope is still verified on every POST.
Keys at rest and persistence
The signer keeps one JSON file per onboarded address under KEY_STORE_DIR (default ./data; the Dockerfile declares /data as a VOLUME). The private key in it is Fernet-encrypted with a key derived by SHA-256 from KEY_ENCRYPTION_SECRET; the file is written to a temporary name, restricted to owner read/write, and renamed into place. The process refuses to start without SIGNER_SECRET and KEY_ENCRYPTION_SECRET rather than fall back to a default. Key material is never logged - log lines around onboarding and binding carry addresses and public keys only - and it leaves the process only into the SDK client that signs an order.
KEY_STORE_DIR must be persistent storage in production. If it is wiped, every stored order key is lost; nobody's funds move, because the key could not withdraw, but every address must be onboarded again from scratch with a fresh keypair and a fresh ChangePubKey signed by its wallet. GET /health reports keyStoreWritable for the orchestrator and is the one unauthenticated route, which is why it does not say whether the deployment is a dry run.
services/signer/app/main.py/onboard, /bind, /order with the bands, /cancel, /account, /positions, /orders, /withdraw-intentservices/signer/app/config.pyMARKET_SLIPPAGE_FLOOR_BPS = 500, the band constants, the boot-time checksservices/signer/app/key_store.pyFernet at rest, one file per address, mark_boundservices/signer/app/lighter_client.pyDryRunLighterClient and the SdkLighterClient assumptionsservices/signer/tests/test_signer.pyfail-closed on a missing mark, the floor, the ceiling, the bandapps/api/src/routes/perp.tsthe /perp/* routes and the wire contract of the envelopeapps/api/src/perp/auth.tsrequirePerpAuth - the five checks in orderpackages/core/src/perpAuth.tsbuildPerpTypedData, perpPayloadHash, canonicalJson, keccak256packages/core/src/venue.tsthe PerpVenue interface every venue satisfiesapps/api/src/venue/lighter.tsLighterVenue - the signer client and its strict parsersapps/api/src/venue/sim.tsSimVenue - fills, resting limits, funding, liquidationPriceapps/api/src/marketdata/index.tsMarketData - the fallback order for stats and candlesapps/api/src/marketdata/lighter.tsorderBookDetails, funding-rates, candlesticks and the market_stats/all stream