The perps on the Trade page, and the shorts behind the hedged vaults, trade on Lighter. Levee is a client of that venue, not a venue itself. To give you one-click orders without a wallet popup for each one, a Levee service called the signer holds an order key for your Lighter account. This chapter covers what that key can and cannot do, how an order is priced before it is signed, and what runs when no Lighter account is connected.
Who holds which key
Lighter is a zk exchange. Orders are signed with an API key separate from your wallet key, which your wallet authorises once with a ChangePubKey transaction. In Levee the browser holds only your wallet. The API checks that each write was signed by the wallet that owns the address, then forwards it; it holds no key. The signer (services/signer) stores your order key encrypted and signs within fixed bounds, and accepts calls only from the API, which signs each one with a shared secret over a timestamp and a one-time nonce, so a captured call cannot be replayed. Lighter executes.
The order key can place market and limit orders with exits attached, cancel orders, set a market's leverage and read your account. Lighter sets leverage per market on the account, not per order, so the signer applies the slider's value just before the order. Binding a new key needs your wallet's ChangePubKey signature. So does a withdrawal: POST /perp/withdraw-intent returns an unsigned description for your wallet to sign and submit.
Connect your Lighter account
- 01Have a Lighter accountLighter creates it when you deposit USDG on Lighter (Robinhood Chain) from your wallet. Until then onboarding stops with that hint.
- 02Request a keyThe Onboard button signs a
POST /perp/onboard. The signer generates one keypair per address (asking again returns the same key), stores it encrypted, picks a free API key slot from 2 to 254 and prepares aChangePubKey. It returns the text your wallet must sign. - 03Sign and bindYour wallet signs that text with
personal_sign, and a signedPOST /perp/bindcarries it. The signer submits theChangePubKeyand marks the key bound: 409 if nothing was prepared or the slot changed (onboard again), 400 if Lighter refuses. - 04Trade
GET /perp/account/:addressreportsapiKeyBound: trueand the trade box replaces the Onboard prompt.
That is three wallet signatures on Lighter. On SimVenue the first one completes onboarding.
How an order is priced
The signer takes one write at a time per address, and signs an order only if every check passes:
- The address is onboarded (404) and its key is bound (409).
- The request is well formed: side, type, a finite size above zero, leverage of at least 1 (422).
- Lighter has a current mark for the market. Without one the answer is 503 and nothing is signed: a bound is only as good as the price it is measured from.
- The price to sign: a limit order uses your price and rests for up to 28 days. A market order uses
mark × (1 ± s), sent immediate-or-cancel, wheresis the requested slippage but never under 100 bps. A request over 1,000 bps is refused (422), not clamped. - That price is within 1,000 bps of the mark, whichever order type produced it (422).
- The size, rounded down to the market's precision, meets the market's minimum unless the order is reduce-only, and size times mark is at most $250,000 (422).
The signer then sets leverage if you sent it, submits, and reads your account back. It reports success only when a market order has moved your position, or a limit order has moved it or rests on the book. An order Lighter threw away returns its reason. One whose outcome cannot be read yet returns as unconfirmed: refresh orders and positions before sending it again.
The app sends no slippage value today, so every market order from the Perp form is priced at the mark plus or minus 1 %, as the form says beside the button. A tighter bound would turn market orders on a thin book into random misses; a wider one would not help, because Lighter itself rejects orders it flags as accidental prices (it refused a 5 % immediate-or-cancel order on ETH on 2026-09-07). The liquidation price the form shows is Levee's isolated estimate, with maintenance at half the initial margin, not Lighter's own figure.
The hedger bot uses the same path for the hedged vaults' shorts: market orders for the vault's hedge account, which is its funder's address (a contract cannot bind a Lighter key, so the margin sits in the funder's account), reduce-only when it buys back. The funder binds the key once with apps/api/scripts/onboard-hedge-account.ts, the same onboard and bind calls the web makes.
Exit orders
Attach exits when you open a position in the Perp form, or add them later with SL / TP on a position in the terminal or in Portfolio. For a long the stop sits below the reference price and the take-profit above it; for a short, the other way round. The reference is your limit price for a limit entry and the mark otherwise, so a position in profit can carry a stop past its entry. The form shows estimated gross PnL at each level, before fees, funding and slippage, and draws the levels on the chart.
The exits are Lighter's own conditional orders and trigger with your browser closed:
- With a new entry they are linked to it: one exit activates when the entry fills, two activate together and cancel each other. Their size is what the entry actually filled, partial fills included. A reduce-only entry cannot carry exits.
- On an existing position the order must be reduce-only, on the closing side and change no leverage; its size is capped at the position. The signer refuses new levels (409) while exits already rest in that market. Cancel those first, which removes their protection at once.
- Every exit is reduce-only, triggered by Lighter's mark price, executes no more than 1 % beyond the trigger, and expires after 28 days.
A trigger is not a guaranteed fill: a gap or a thin book can leave part of the position open, so check it after one fires. When a reduce-only market order closes a position flat, the signer also cancels the remaining exits in that market, and says so plainly if it could not confirm the cleanup.
Signed requests
A request's address names an account, and a plain HTTP request proves nothing about who owns it. So every POST /perp/onboard, /bind, /order, /cancel and /withdraw-intent carries an auth envelope: a nonce (0x plus 16 to 64 random 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: 'Levee', version: '1', chainId }. The route fixes action. payloadHash is the keccak256 of the body without auth, as canonical JSON (keys sorted at every level, no whitespace), so your wallet signs the exact request and a signature cannot be moved to another body. The API and the web build this message from the same file, packages/core/src/perpAuth.ts.
requirePerpAuth checks, in order: a valid body and address (400), a well-formed envelope (401 unauthorized), ts within 300 s of the server clock (401 stale_ts), a signature that recovers to address over this body (401 unauthorized), and a nonce this address has not used (409 nonce_used). The nonce is spent only after the signature verifies, so a forged request cannot burn the owner's next nonce. It is remembered for 300 s plus 60 s of slack, after which the timestamp check already fails. The venue receives the address the signature recovered to. Reads carry no envelope.
Prices and charts
Marks, 24 h change, high, low, volume, open interest and funding come from MarketData in the API: one view of all 57 markets in the symbol table, cached for 15 s. Lighter's REST API is the source of truth, its WebSocket feed fills gaps between polls (reconnecting with backoff up to 30 s), and funding comes from Lighter's own funding-rate row, as a percent per hour. Where Lighter has no mark, Hyperliquid fills crypto markets and Yahoo Finance fills stocks, indices and commodities. A market no source answers is still listed without a mark, and a row filled by a fallback has no funding or open interest, which the app reads as "no perp leg". Chart candles follow the same order, cached for 60 s.
When the API cannot be reached, development and staging builds show fixture data tagged Sample data. Production builds never show fixtures; pages stay empty and a status notice says the data is unavailable.
The simulated venue
The API picks its venue from VENUE: lighter builds a client for the signer, and the default, sim, builds SimVenue. It is independent of how you sign in. SimVenue is a paper book priced from the real marks, so Trade and Portfolio work without a Lighter account or a key:
- Each new account starts with 10,000 USDG.
- Market orders fill 5 bps from the mark, against you. Limit orders fill when marketable, or rest until the mark crosses them.
- Margin is isolated per market, maintenance is half the initial margin (a 2x long is liquidated about 25 % down), and funding accrues hourly, longs paying a positive rate.
- Stop-loss and take-profit work as on Lighter, with the same 28-day expiry.
Nothing reaches Lighter, a withdrawal answers 503 sim_no_withdraw, and every write is still checked for a valid envelope.
Keys at rest
The signer keeps one file per address under KEY_STORE_DIR (/data in the Docker image). The private key in it is Fernet-encrypted with a key derived by SHA-256 from KEY_ENCRYPTION_SECRET, and the file is written under a temporary name, restricted to its owner, then renamed. The service will not start without SIGNER_SECRET and KEY_ENCRYPTION_SECRET, compares the shared secret in constant time, runs as an unprivileged user, and never logs key material. GET /health is its only open route, and it does not say whether the deployment is live.
The key store must be persistent. If it is lost, no funds move, since no stored key could withdraw, but every address must onboard again with a new key and a new ChangePubKey.
Reference
Signer settings
| Name | Allowed | Default | Effect |
|---|---|---|---|
MARKET_SLIPPAGE_FLOOR_BPS | 100 bps | - | fixed in code; no market order is priced tighter |
DEFAULT_SLIPPAGE_BPS | integer bps | 100 | when a market order sends none |
MARKET_SLIPPAGE_CEILING_BPS | floor to band bps | 1,000 | more is refused, not clamped |
LIMIT_PRICE_BAND_BPS | above 0 bps | 1,000 | furthest a signed price may sit from the mark |
MAX_ORDER_NOTIONAL_USD | above 0 USD | 250,000 | size × mark per order |
INTEGRATOR_ACCOUNT_INDEX | account index | unset | sent with orders for Lighter fee-share attribution |
Errors from /perp/*
| Answer | Meaning |
|---|---|
400 invalid_* | a malformed field |
401 unauthorized / stale_ts, 409 nonce_used | the envelope failed a check |
| 404, 409, 422, 400 from the signer | not onboarded; not bound or exits already resting; outside a bound; refused by Lighter |
502 signer_error / signer_unreachable / signer_bad_response | the signer failed (a missing mark included), gave no answer within 10 s, or sent an unexpected shape |
503 signer_not_configured / sim_no_withdraw | the API has no signer secret; a withdrawal on SimVenue |
services/signer/app/main.py/onboard, /bind, /order and its checks, /withdraw-intentservices/signer/app/config.pythe floor, ceiling, band and notional capservices/signer/app/key_store.pyencrypted key filesservices/signer/app/lighter_client.pythe dry-run stand-in and the SDK client, exits includedapps/api/src/routes/perp.tsthe /perp/* routes and the envelope contractapps/api/src/perp/auth.tsrequirePerpAuthpackages/core/src/perpAuth.tsthe EIP-712 message and body hashapps/web/lib/usePerp.tsonboarding, orders and cancels from the browserapps/api/src/venue/sim.tsSimVenueapps/api/src/marketdata/index.tsMarketData and its source order