How Prism is built
Prism is a single trading interface over two very different execution venues: an order-book perpetuals exchange and an on-chain automated market maker. This page walks the whole system from the browser down to the contracts, including the pieces that are deliberately not in the browser.
- Settlement
- Robinhood Chain (4663)
- Perpetuals
- Lighter order book
- Spot
- Uniswap v3 and v4
What Prism actually is
One front end, two independent execution paths, and a shared settlement layer.
Prism does not run a matching engine and does not custody funds. It is a client and a thin server tier that route user intent to two venues that already exist on Robinhood Chain. Which venue handles an order depends entirely on the product surface the user is on.
Leverage (perpetuals)
Backed by Lighter, a zero-knowledge order-book perpetuals exchange. Orders are cryptographically signed and submitted off-chain, then settled in batches. Collateral is USDG. Prism lists 59 markets here: 16 crypto pairs plus 43 tokenized equities, pre-IPO names, index ETFs, and commodities.
Spot (swaps)
Backed by Uniswap v3 and v4 deployed directly on Robinhood Chain. Every quote and every fill is an on-chain call. There is no off-chain routing service anywhere in this path.
The two paths share almost nothing except the wallet session and the chain. Perps state lives inside Lighter and is read through our own API proxy. Spot state lives in contract storage and is read through RPC and a block explorer. That split explains most of the architecture below.
How the tiers fit together
Four tiers, and one of them exists purely so that private keys never reach the browser.
Browser
React 19 client · wagmi + viem · Privy session
│
├── WebSocket ─────────────────► Lighter stream (order books, mark price)
├── WebSocket ─────────────────► Hyperliquid ws (fallback mids, L2 book)
├── JSON-RPC ──────────────────► Robinhood Chain (quotes, balances, swaps)
│
▼
Next.js server (App Router route handlers on Vercel)
/api/lighter/* proxy and auth boundary
/api/markets ticker aggregation, 15s revalidate
/api/candles OHLCV aggregation, 15s revalidate
/api/trades Blockscout indexing
/api/contact feedback intake, Upstash Redis
│
▼
Python signer service (FastAPI, private network)
encrypted key store · lighter-sdk SignerClient
│
▼
Lighter API (api.rh.lighter.xyz)
│
▼
Robinhood Chain (Arbitrum Orbit L2, chain id 4663)Reads that are public and latency-sensitive skip the server entirely and stream straight into the browser. Anything needing a secret, a signature, or an API key is forced through the server tiers. The Python service is the only component that ever holds a Lighter private key.
The application layer
A Next.js App Router project with no marketing surface — the root path opens directly on the trade terminal.
| Framework | Next.js 16.2, App Router, Turbopack |
|---|---|
| UI runtime | React 19.2 with Server and Client Components |
| Language | TypeScript in strict mode |
| Styling | Tailwind CSS v4, with design tokens declared in a @theme block |
| Charts | Lightweight Charts v5 for candles, hand-rolled SVG for sparklines |
| Chain access | viem 2 for encoding and RPC, wagmi 3 for React bindings |
| Server state | TanStack Query, one client instance per app mount |
| Hosting | Vercel, with route handlers deployed as functions |
Routing is deliberately flat. There is no landing page: / redirects to /trade from next.config.ts, so a cold visit lands straight on the leverage terminal.
| Route | Rendering | What it does |
|---|---|---|
| /trade | Static shell, client data | Perpetuals terminal: chart, order book, order entry, positions |
| /swap | Static shell, client data | Spot terminal: chart, swap widget, on-chain holdings |
| /portfolio | Static shell, client data | Combined perps and spot balances, positions, and history |
| /docs | Static | This page |
| /contact | Static shell | Feedback intake form |
| /admin/[key] | Dynamic | Password-gated feedback review; the path segment is itself a secret |
The design system lives in one file. src/app/globals.css declares the neutral canvas, the pastel spectrum, and the functional market colours as CSS custom properties, which Tailwind v4 then exposes as utilities. Product surfaces stay neutral and the spectrum is reserved for the logo and brand moments, so colour never competes with price data.
Neutrals and brand
- Canvas#fbf9f6
- Panel#ffffff
- Ink#17181b
- Slate#656a73
- Accent#5b47bd
Functional and spectrum
- Up#16866b
- Down#c94f63
- Warning#a66b14
- Blush#f6b6c8
- Mint#a9e6d5
Robinhood Chain
A permissionless, EVM-compatible Arbitrum Orbit L2. Everything Prism touches settles here.
| Chain ID | 4663 |
|---|---|
| Native gas token | ETH, 18 decimals |
| Default RPC | https://rpc.mainnet.chain.robinhood.com |
| Explorer | https://robinhoodchain.blockscout.com |
| Multicall3 | 0xcA11bde05977b3631167028862bE2a173976CA11 |
| Definition | src/lib/chains.ts, built with viem defineChain |
The chain is defined once and imported everywhere, so the wagmi transport, the Privy supported-chain list, and the explorer link helpers cannot drift apart. The RPC endpoint is overridable per deployment through NEXT_PUBLIC_RH_RPC_URL without touching code.
Wallets and sessions
Privy owns the session, wagmi owns the chain calls, and the two are wired together in a single provider tree.
RootLayout
└─ Providers
└─ PrivyProvider login: wallet · chains: [Robinhood Chain]
└─ QueryClientProvider
└─ WagmiProvider transport: http, batched
└─ appPrivy handles connection and, for users arriving without a wallet, provisions an embedded one on both Ethereum and Solana. The external wallets offered are MetaMask, Phantom, and Temple. Robinhood Chain is the only supported chain, so a user on the wrong network gets an explicit switch prompt instead of a silent failure.
The whole wallet layer is conditional. src/components/Providers.tsx checks for a real NEXT_PUBLIC_PRIVY_APP_ID and, when it is missing or still the placeholder value, skips mounting Privy and wagmi entirely. A usePrivyEnabled() context lets each surface degrade into a read-only state rather than crash, which is what keeps the app buildable and previewable without credentials.
The perpetuals engine
Three hops between a click and a resting order, each one there for a reason.
Perpetuals run on Lighter, reached at https://api.rh.lighter.xyz. Orders must be signed with a Lighter API private key. That key cannot live in the browser, and using it from a Vercel function would mean running the Python lighter-sdk inside a Node runtime, so it lives in a dedicated FastAPI service instead.
src/lib/lighter.ts browser client, calls our own API only
▼
src/app/api/lighter/* Next.js route handlers, force-dynamic
▼ x-signer-secret header
lighter-service/ FastAPI, holds encrypted keys, signs
▼ lighter-sdk SignerClient
api.rh.lighter.xyz order book, matching, settlementThe browser never learns a Lighter hostname for anything requiring authentication. Every function in src/lib/lighter.ts targets a local route, and the server forwards it on with a shared secret header.
Route handlers
| Endpoint | Verb | Purpose |
|---|---|---|
| /api/lighter/account | GET | Resolve Lighter account indexes for a wallet address |
| /api/lighter/balance | GET | USDG collateral and available margin |
| /api/lighter/positions | GET | Open positions with entry, PnL, and liquidation price |
| /api/lighter/orders | GET | Resting orders; requires a Lighter auth token |
| /api/lighter/trades | GET | Fill history; requires a Lighter auth token |
| /api/lighter/markets | GET | Market metadata, cached 60s inside the signer |
| /api/lighter/orderbooks | GET | Public market list, proxied direct and cached 300s |
| /api/lighter/order | POST | Place a market or limit order |
| /api/lighter/cancel | POST | Cancel a resting order |
| /api/lighter/close | POST | Reduce-only market close |
| /api/lighter/tpsl | POST | Attach take-profit and stop-loss triggers |
| /api/lighter/leverage | POST | Set cross or isolated leverage for a market |
| /api/lighter/register | POST | API key lifecycle: generate, authorize, confirm |
| /api/lighter/withdraw | POST | Fast withdrawals and internal transfers |
Everything except /api/lighter/orderbooks is marked force-dynamic, because caching a position or a balance across users would be a correctness bug rather than an optimisation. The order books route is public and identical for everyone, so it is the one endpoint allowed to sit behind a 300 second revalidate.
The signer service
A FastAPI app under lighter-service/ listening on port 8787. It wraps the official lighter-sdk and exposes a narrow internal API covering key management, authenticated reads, and signed writes. Two behaviours are worth knowing:
- Market orders re-fetch the live mark price immediately before signing and apply a slippage guard with a floor of 500 basis points, so a stale client quote cannot produce a wildly mispriced fill.
- Authenticated reads mint a short-lived Lighter bearer token that expires after an hour, rather than reusing a long-lived credential.
Key management and order signing
The wallet authorises a trading key once. After that, individual trades need no wallet popup.
This is the least obvious part of the system and the part that most shapes how trading feels. Lighter separates the wallet that owns an account from the API key that signs its orders, which is what lets Prism offer click-to-trade without a signature prompt on every order.
Deposit creates the account
The user approves and deposits USDG into the Lighter zk contract on Robinhood Chain. That first deposit is what mints theiraccount_index. Until it lands there is nothing to trade against.The server generates an API keypair
The signer callscreate_api_key()and immediately encrypts the private half. Only the public key and the key index are returned to the browser.The wallet authorises the key
The signer builds aChangePubKeymessage and the user signs it with a gaslesspersonal_sign. That signature is attached as an L1 signature and submitted, binding the new key to the account.Confirmation polls for propagation
Registration is not instant. The app retries confirmation up to five times at two second intervals before reporting failure.Trading runs server-side
From here every order, cancel, close, and TP/SL is signed by the service using the stored key. The wallet is only needed again for deposits, withdrawals, and transfers.
Key storage
- Keys are encrypted with Fernet, using a key derived from
KEY_ENCRYPTION_SECRET. - The ciphertext lives in a single file under the directory named by
KEY_STORE_DIR, which defaults to./data. - Each record holds the account index, key index, encrypted private key, owning L1 address, and registration state.
Swaps, quoting, and execution
No hosted routing API. Prism quotes and executes directly against Uniswap contracts on Robinhood Chain.
The hosted Uniswap Trading API does not route this chain, so the entire spot path is on-chain. Quotes come from Quoter contracts over RPC and fills go through the Universal Router. This is slower to quote than a hosted router, but it puts no third party in the trade path.
| Pair shape | Protocol | Notes |
|---|---|---|
| Native ETH and an equity or ETF token | Uniswap v4 | Pools are denominated in native ETH, so equities settle in ETH |
| ETH or WETH and USDG | Uniswap v3 | The stablecoin on-ramp and off-ramp |
| Anything else | Unsupported | The widget refuses to quote |
Quoting is debounced at 400ms and probes several fee tiers, keeping whichever pool returns the best output. Because a quote is a contract read rather than a cached price, it is re-fetched immediately before execution so the slippage bound is computed against fresh state.
Execution path
Network check
If the wallet is on another chain, request a switch to Robinhood Chain first.Approvals, only for ERC-20 inputs
Two transactions:approvethe token to Permit2, then Permit2approvethe Universal Router with a 30 day expiry. Native ETH inputs skip both.Bound the output
minOutis the fresh quote reduced by the selected slippage tolerance, then reduced again by the platform fee when one is configured.Encode and execute
Commands and inputs are encoded forUniversalRouter.executewith a 20 minute deadline. v4 routes take the fee with aTAKE_PORTIONaction; v3 routes usePAY_PORTIONfollowed by a sweep.
The platform fee is off by default. Setting NEXT_PUBLIC_SWAP_FEE_BPS above zero alongside a valid NEXT_PUBLIC_FEE_RECIPIENT turns it on, capped at 500 basis points, and the fee is skimmed from the output currency on-chain rather than collected separately.
The market data pipeline
Live prices stream over WebSocket; everything historical is aggregated server-side and cached.
Prism lists markets that not every venue carries, so the data layer is built around fallbacks rather than a single source. Resolution order differs by asset class: crypto prefers Lighter and then Hyperliquid, while real-world assets fall through to Yahoo Finance.
| Feed | Transport | Cadence |
|---|---|---|
| Lighter order book and market stats | WS | Real time, 60s ping, 2s reconnect |
| Hyperliquid mids and L2 book | WS | Real time, 30s ping |
| /api/markets | GET | 15s revalidate, polled every 20s by the client |
| /api/candles | GET | 15s revalidate, polled every 60s by the client |
| Account state: positions, orders, fills, balance | GET | Polled every 15s while the terminal is open |
WebSocket connections are singletons. One socket per upstream is shared across every component that subscribes, so switching markets changes a subscription rather than opening a new connection.
Order placement gets special handling. Lighter needs a moment to index a new fill, so after a successful write the client fires an immediate refresh plus retries at 1.5 and 4 seconds. Without that, a filled order appears to vanish for a polling cycle.
LIGHTER_CANDLES_ENABLED, because their candles endpoint currently returns 403 to our infrastructure. Charts use Hyperliquid or Yahoo instead.Reconstructing spot trade history
There is no database of swaps. History is rebuilt from chain data on every request.
Perps history comes from Lighter directly. Spot history has no such service, so /api/trades reconstructs it from Blockscout, issuing three parallel queries per address: normal transactions, token transfers, and internal transactions.
Filter down to real swaps
Keep only transactions sent to the Universal Router whose calldata begins with theexecuteselector 0x3593564c, and drop anything that reverted.Rebuild both legs
Combine native value sent, ERC-20 transfers in and out, and internal transactions carrying unwrapped ETH back to the user.Classify direction
Whether an equity token was the input or the output determines buy versus sell. ETH and USDG pairs are classified on their own axis.Price and aggregate
USDG is treated as one dollar and ETH is priced from the live feed, which yields average entry, average exit, and notional volume.
The client polls this every 30 seconds and additionally retries at 3, 8, and 15 seconds after a swap completes, because the explorer needs time to index a transaction the user just watched confirm.
Deployed contracts
Every address Prism interacts with on Robinhood Chain, declared in one module.
Addresses and ABIs are centralised in src/lib/rhContracts.ts, so no component hardcodes an address inline.
Uniswap
- Universal Router v2.00x53BF6B0684Ec7eF91e1387Da3D1a1769bC5A6F77
- Permit20x000000000022D473030F116dDEE9F6B43aC78BA3
- v4 PoolManager0x8366a39CC670B4001A1121B8F6A443A643e40951
- v4 Quoter0x8dc178efb8111bb0973dd9d722ebeff267c98f94
- v4 StateView0xf3334192d15450cdd385c8b70e03f9a6bd9e673b
- v3 QuoterV20x33e885ed0ec9bf04ecfb19341582aadcb4c8a9e7
- v3 Factory0x1f7d7550B1b028f7571E69A784071F0205FD2EfA
- WETH90x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73
Lighter
- zk deposit contract0x94bAB9693Ba2f6358507eFfcbd372b0660AFfF9d
- USDG collateral token0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168
USDG carries 6 decimals and sits at asset index 3. Deposits route to perps with route type 0.
The tradeable token set is native ETH, WETH, and USDG alongside 139 tokenized equities and index ETFs, all standard ERC-20s with 18 decimals except USDG. Native ETH is represented by the canonical Uniswap sentinel address rather than by a contract.
Issuers mint many look-alike contracts with identical symbols and names, so a listing is only added once its address has been quoted against the live v4 pools and the implied price checked against the underlying market. Address correctness is a safety property here, not a detail.
Configuration and deployment
Two deployable units with very different requirements: a stateless web app and a stateful signer.
Next.js app
Stateless and horizontally scalable. Runs on Vercel, where route handlers become serverless functions. Needs no persistent disk.
Signer service
Stateful. Requires a persistent volume for the encrypted key store, and should not be publicly reachable — only the Next.js tier needs to talk to it.
Web application
| Variable | Required | Purpose |
|---|---|---|
| NEXT_PUBLIC_PRIVY_APP_ID | Yes | Enables wallet connection; without it the app runs read-only |
| LIGHTER_SIGNER_URL | Yes | Signer service base URL, defaults to 127.0.0.1:8787 |
| LIGHTER_SIGNER_SECRET | Yes | Shared secret sent as the x-signer-secret header |
| NEXT_PUBLIC_RH_RPC_URL | No | Override the default Robinhood Chain RPC |
| NEXT_PUBLIC_SWAP_FEE_BPS | No | Platform fee in basis points, maximum 500 |
| NEXT_PUBLIC_FEE_RECIPIENT | No | Address receiving the platform fee |
| KV_REST_API_URL | No | Upstash Redis for feedback storage; falls back to a local file |
| ADMIN_PATH | No | Secret path segment for the admin dashboard |
Signer service
| Variable | Required | Purpose |
|---|---|---|
| SIGNER_SECRET | Yes | Must match LIGHTER_SIGNER_SECRET on the web tier |
| KEY_ENCRYPTION_SECRET | Yes | Derives the Fernet key protecting stored private keys |
| KEY_STORE_DIR | Yes | Directory on the persistent volume, defaults to ./data |
| LIGHTER_BASE_URL | No | Lighter API host, defaults to api.rh.lighter.xyz |
| INTEGRATOR_ACCOUNT_INDEX | No | Partner account for integrator fee sharing |
| DEFAULT_SLIPPAGE_BPS | No | Baseline slippage guard, 100 by default |
What is protected, and how
The short version: the browser is treated as hostile, and the signer is treated as the crown jewels.
Never reaches the browser
- Lighter API private keys
- The signer shared secret
- Upstash Redis credentials
- Admin credentials and the admin path
Stays under user control
- The wallet private key, always
- Deposits, withdrawals, and transfers, each wallet-signed
- Token approvals, revocable on-chain at any time
- Server-side signing is a deliberate trade-off. It buys click-to-trade with no wallet popup per order, at the cost of the service holding a key that can place orders. That key cannot move funds off the account: withdrawals require a fresh wallet signature.
- Slippage is enforced twice. The client computes a bound and the signer independently re-prices against the live mark before signing, so a tampered client cannot submit an unbounded market order.
- Account reads are never cached. Every account-scoped route is
force-dynamic, which removes any possibility of one user being served another user balance from an edge cache. - Feedback intake is rate limited. The contact endpoint sits behind Upstash rate limiting, and the admin dashboard is protected by both a secret path and credentials.