Kerf is a first release and may still have small bugs. Our team is building it around the clock - follow updates and new features onEarly release - small bugs possible. Updates onx.com/UseKerf
Kerf
Chapter 09 · Perps via Lighter

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.

The order-key modelThe order key lives in the signer and can trade; every step in the lower group needs a signature only your wallet can make. Dashed arrows are payloads handed back to the browser to sign. With VENUE=sim nothing leaves the API.
the order key can do this without youonly your wallet can do thisJSONx-signer-secretlighter-sdkmarket / limitaddress + ordercreate_orderVENUE=simaddressChangePubKey payloadl1Signaturechange_pubkeybuild payloadunsigned payloadwallet signatureBrowserwallet · no keyAPI /perp/*EIP-712 envelope on every POSTSignerx-signer-secret · FernetLighterapi.rh.lighter.xyzTrade boxone clickno popupPOST /perp/ordernonce · ts ± 300 s · sigPOST /orderre-fetch mark · 503 if nonemarket: ≥ 500 bps floorband 10 % · notional caporder signedby the order keyPOST /perp/cancelsame envelopePOST /cancelcancel_ordercancelledSimVenueVENUE=simin-memory bookmark ± 5 bpsOnboard buttonPOST /perp/onboardidempotent per addressPOST /onboardkeypair → KEY_STORE_DIRChangePubKey payloadwallet signsChangePubKeyPOST /perp/bindl1SignaturePOST /bindchange_pubkey · boundorder key boundto the accountWithdrawamountPOST /perp/withdraw-intentnever a withdraw routePOST /withdraw-intentunsigned payload onlyno submit code pathwallet signsand submitsLighter withdrawalnever through Kerf

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 keyNeeds your wallet
Place an order (market or limit)yes - POST /order
Cancel an orderyes - POST /cancel
Set leverage on a marketyes - update_leverage before create_order, because Lighter has no per-order leverage
Read the account, positions, resting ordersyes - GET /account, /positions, /orders
Bind a new order keyChangePubKey, signed by the wallet, submitted through /bind
Withdrawno - there is no code pathPOST /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:

Signer order bands (services/signer/app/config.py)
ParameterRangeDefaultMeaning
MARKET_SLIPPAGE_FLOOR_BPS500 bps-hard-coded, not configurable; a market order is priced at mark × (1 ± max(requested, 500) / 1e4)
DEFAULT_SLIPPAGE_BPS> 0 bps100what a market order asks for when the caller sends no slippageBps; the floor lifts it to 500
MARKET_SLIPPAGE_CEILING_BPS≥ floor, ≤ band bps1000a 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 bps1000the 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 USD250 000size × 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

  1. 01
    POST /perp/onboard
    The API verifies the request envelope (below), so only the wallet that controls address can ask for a key, and forwards the address to the signer. The signer's /onboard generates a keypair, looks up the address's Lighter account index if one exists, stores the private key Fernet-encrypted under KEY_STORE_DIR with bound: false, and returns accountIndex, apiKeyIndex (0), publicKey and changePubKeyPayload. Calling it again for the same address returns the same public information and generates nothing - the key store cannot be grown by repetition.
  2. 02
    The wallet signs ChangePubKey
    changePubKeyPayload is 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.
  3. 03
    POST /perp/bind
    The API forwards l1Signature to the signer's /bind, which submits the ChangePubKey through the SDK, records the account index Lighter answered with, and flips the record to bound: true. Until then /order and /cancel answer 409 "address not bound". On the sim venue bind has nothing to bind and answers bound: true straight away.
  4. 04
    GET /perp/account/:address
    Reports accountIndex and apiKeyBound from the signer's key store - the onboarding state, not a Lighter field - alongside equity, margin and funding mapped from Lighter's account. The terminal shows the Onboard call-to-action while apiKeyBound is 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:

#CheckFailure
1the body is an object with a well-formed address400 invalid_address / invalid_body
2auth is present with a well-formed nonce, a finite ts and a hex signature401 unauthorized
3now − ts is within PERP_AUTH_TS_SKEW_SECONDS (300) either way401 stale_ts
4verifyTypedData recovers address over the hash of this body401 unauthorized
5(address, nonce) has not been seen before409 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:

  1. Lighter REST GET /api/v1/orderBookDetails is the source of truth for the stats. /orderBooks is the same list without any numbers - metadata only - which is why the snapshot cannot come from it. open_interest arrives in base units and is multiplied by the mark; daily_price_change is already a percent.
  2. Lighter WebSocket market_stats/all keeps 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 whenever LIGHTER_WS_URL resolves, and since that defaults to wss://api.rh.lighter.xyz/stream it is open in every deployment, sim venue included.
  3. Funding is its own endpoint, GET /api/v1/funding-rates, because orderBookDetails carries a market's funding parameters and never its current rate. The response has one row per reference exchange per market; only the row with exchange: "lighter" is Lighter's own, and its hourly fraction is multiplied by 100 because @kerf/core's basis signal works in percent.
  4. Hyperliquid allMids fills the mark for a crypto market whose row has a hyperliquid id and no Lighter mark - the small Robinhood-native memes have none, so they stay Lighter-only.
  5. Yahoo Finance fills the mark, change, high, low and volume for a stock, index or commodity with a yahoo ticker; 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.

Read the code
  • services/signer/app/main.py /onboard, /bind, /order with the bands, /cancel, /account, /positions, /orders, /withdraw-intent
  • services/signer/app/config.py MARKET_SLIPPAGE_FLOOR_BPS = 500, the band constants, the boot-time checks
  • services/signer/app/key_store.py Fernet at rest, one file per address, mark_bound
  • services/signer/app/lighter_client.py DryRunLighterClient and the SdkLighterClient assumptions
  • services/signer/tests/test_signer.py fail-closed on a missing mark, the floor, the ceiling, the band
  • apps/api/src/routes/perp.ts the /perp/* routes and the wire contract of the envelope
  • apps/api/src/perp/auth.ts requirePerpAuth - the five checks in order
  • packages/core/src/perpAuth.ts buildPerpTypedData, perpPayloadHash, canonicalJson, keccak256
  • packages/core/src/venue.ts the PerpVenue interface every venue satisfies
  • apps/api/src/venue/lighter.ts LighterVenue - the signer client and its strict parsers
  • apps/api/src/venue/sim.ts SimVenue - fills, resting limits, funding, liquidationPrice
  • apps/api/src/marketdata/index.ts MarketData - the fallback order for stats and candles
  • apps/api/src/marketdata/lighter.ts orderBookDetails, funding-rates, candlesticks and the market_stats/all stream