Documentation

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
01Start here

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.

02Topology

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.

Market data has deliberate redundancy. When Lighter has no feed for a symbol, the app falls back to Hyperliquid for crypto and Yahoo Finance for real-world assets, so charts stay populated even for markets that are not yet listed on the perps venue.
03Front end

The application layer

A Next.js App Router project with no marketing surface — the root path opens directly on the trade terminal.

FrameworkNext.js 16.2, App Router, Turbopack
UI runtimeReact 19.2 with Server and Client Components
LanguageTypeScript in strict mode
StylingTailwind CSS v4, with design tokens declared in a @theme block
ChartsLightweight Charts v5 for candles, hand-rolled SVG for sparklines
Chain accessviem 2 for encoding and RPC, wagmi 3 for React bindings
Server stateTanStack Query, one client instance per app mount
HostingVercel, 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.

RouteRenderingWhat it does
/tradeStatic shell, client dataPerpetuals terminal: chart, order book, order entry, positions
/swapStatic shell, client dataSpot terminal: chart, swap widget, on-chain holdings
/portfolioStatic shell, client dataCombined perps and spot balances, positions, and history
/docsStaticThis page
/contactStatic shellFeedback intake form
/admin/[key]DynamicPassword-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
04Settlement

Robinhood Chain

A permissionless, EVM-compatible Arbitrum Orbit L2. Everything Prism touches settles here.

Chain ID4663
Native gas tokenETH, 18 decimals
Default RPChttps://rpc.mainnet.chain.robinhood.com
Explorerhttps://robinhoodchain.blockscout.com
Multicall30xcA11bde05977b3631167028862bE2a173976CA11
Definitionsrc/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.

Lighter signs its layer-two transactions against chain id 466324, which is distinct from the settlement chain id 4663. The signer auto-detects it, but the two numbers looking alike has caused confusion before.
05Identity

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
                      └─ app

Privy 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.

06Leverage

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, settlement

The 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

EndpointVerbPurpose
/api/lighter/accountGETResolve Lighter account indexes for a wallet address
/api/lighter/balanceGETUSDG collateral and available margin
/api/lighter/positionsGETOpen positions with entry, PnL, and liquidation price
/api/lighter/ordersGETResting orders; requires a Lighter auth token
/api/lighter/tradesGETFill history; requires a Lighter auth token
/api/lighter/marketsGETMarket metadata, cached 60s inside the signer
/api/lighter/orderbooksGETPublic market list, proxied direct and cached 300s
/api/lighter/orderPOSTPlace a market or limit order
/api/lighter/cancelPOSTCancel a resting order
/api/lighter/closePOSTReduce-only market close
/api/lighter/tpslPOSTAttach take-profit and stop-loss triggers
/api/lighter/leveragePOSTSet cross or isolated leverage for a market
/api/lighter/registerPOSTAPI key lifecycle: generate, authorize, confirm
/api/lighter/withdrawPOSTFast 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.
07Trust boundary

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.

  1. Deposit creates the account

    The user approves and deposits USDG into the Lighter zk contract on Robinhood Chain. That first deposit is what mints their account_index. Until it lands there is nothing to trade against.
  2. The server generates an API keypair

    The signer calls create_api_key() and immediately encrypts the private half. Only the public key and the key index are returned to the browser.
  3. The wallet authorises the key

    The signer builds a ChangePubKey message and the user signs it with a gasless personal_sign. That signature is attached as an L1 signature and submitted, binding the new key to the account.
  4. Confirmation polls for propagation

    Registration is not instant. The app retries confirmation up to five times at two second intervals before reporting failure.
  5. 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.
The key store is a file on disk. Any host that gives the signer an ephemeral filesystem will silently lose every user key on redeploy and force everyone through re-authorisation. A persistent volume is mandatory, not an optimisation.
08Spot

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 shapeProtocolNotes
Native ETH and an equity or ETF tokenUniswap v4Pools are denominated in native ETH, so equities settle in ETH
ETH or WETH and USDGUniswap v3The stablecoin on-ramp and off-ramp
Anything elseUnsupportedThe 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

  1. Network check

    If the wallet is on another chain, request a switch to Robinhood Chain first.
  2. Approvals, only for ERC-20 inputs

    Two transactions: approve the token to Permit2, then Permit2 approve the Universal Router with a 30 day expiry. Native ETH inputs skip both.
  3. Bound the output

    minOut is the fresh quote reduced by the selected slippage tolerance, then reduced again by the platform fee when one is configured.
  4. Encode and execute

    Commands and inputs are encoded for UniversalRouter.execute with a 20 minute deadline. v4 routes take the fee with a TAKE_PORTION action; v3 routes use PAY_PORTION followed 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.

09Data

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.

FeedTransportCadence
Lighter order book and market statsWSReal time, 60s ping, 2s reconnect
Hyperliquid mids and L2 bookWSReal time, 30s ping
/api/marketsGET15s revalidate, polled every 20s by the client
/api/candlesGET15s revalidate, polled every 60s by the client
Account state: positions, orders, fills, balanceGETPolled 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.

Candlestick data from Lighter is disabled by default behind LIGHTER_CANDLES_ENABLED, because their candles endpoint currently returns 403 to our infrastructure. Charts use Hyperliquid or Yahoo instead.
10Indexing

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.

  1. Filter down to real swaps

    Keep only transactions sent to the Universal Router whose calldata begins with the execute selector 0x3593564c, and drop anything that reverted.
  2. Rebuild both legs

    Combine native value sent, ERC-20 transfers in and out, and internal transactions carrying unwrapped ETH back to the user.
  3. 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.
  4. 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.

11On-chain

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.

Lighter

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.

12Operations

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

VariableRequiredPurpose
NEXT_PUBLIC_PRIVY_APP_IDYesEnables wallet connection; without it the app runs read-only
LIGHTER_SIGNER_URLYesSigner service base URL, defaults to 127.0.0.1:8787
LIGHTER_SIGNER_SECRETYesShared secret sent as the x-signer-secret header
NEXT_PUBLIC_RH_RPC_URLNoOverride the default Robinhood Chain RPC
NEXT_PUBLIC_SWAP_FEE_BPSNoPlatform fee in basis points, maximum 500
NEXT_PUBLIC_FEE_RECIPIENTNoAddress receiving the platform fee
KV_REST_API_URLNoUpstash Redis for feedback storage; falls back to a local file
ADMIN_PATHNoSecret path segment for the admin dashboard

Signer service

VariableRequiredPurpose
SIGNER_SECRETYesMust match LIGHTER_SIGNER_SECRET on the web tier
KEY_ENCRYPTION_SECRETYesDerives the Fernet key protecting stored private keys
KEY_STORE_DIRYesDirectory on the persistent volume, defaults to ./data
LIGHTER_BASE_URLNoLighter API host, defaults to api.rh.lighter.xyz
INTEGRATOR_ACCOUNT_INDEXNoPartner account for integrator fee sharing
DEFAULT_SLIPPAGE_BPSNoBaseline slippage guard, 100 by default
13Threat model

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.