State & data fetching
The frontend keeps two kinds of state separate: client state (wallet + tokens, with Zustand) and server state (everything from the API, with React Query).
Zustand — client state
Zustand is a minimal state store. Two stores exist, both persisted to localStorage:
1// wallet store — holds the PRIVATE KEY (self-custody!)2useWalletStore: { wallet, create(), importSecretKey(hex), clear() }3// persisted under localStorage key "xchange-wallet"45// auth store — holds the JWT tokens6useAuthStore: { address, accessToken, refreshToken, set(), clear() }7// persisted under localStorage key "xchange-auth"
localStorage. That's convenient but not the most hardened storage (XSS could read it). Real wallets use browser extensions or hardware keys. It's fine for a demo — and great to show you understand the trade-off.Note the refreshToken is stored but not actually used by the frontend — there's no automatic refresh flow. A candidate improvement to mention.
React Query — server state
TanStack React Query fetches data from the API, caches it, and re-polls on an interval so the pages feel live:
1const { data: book } = useQuery(2["book"],3() => api.orderbook(10),4{ refetchInterval: 3_000 } // refresh every 3 seconds5);
Poll intervals used across the app: order book 3s, trades 5s, height 5s, blocks 6s, balances 8s, validators/positions 10s, health 10s.
The API client
lib/api.ts is one typed wrapper around fetch. The base URL comes from NEXT_PUBLIC_API_URL (localhost in dev, the Railway URL in production). Every endpoint is a method on the api object:
1const BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";23async function request<T>(path: string, init: RequestInit = {}): Promise<T> {4const res = await fetch(`${BASE}${path}`, {5...init,6headers: { "content-type": "application/json", ...init.headers },7});8if (!res.ok) throw new ApiError(res.status, (await res.json()).error);9return res.json();10}1112export const api = {13health: () => request("/health"),14challenge: (address) => request("/auth/challenge", { method: "POST", body: JSON.stringify({ address }) }),15login: (body) => request("/auth/login", { method: "POST", body: JSON.stringify(body) }),16submit: (tx) => request("/tx/submit", { method: "POST", body: JSON.stringify({ tx }) }),17orderbook: (depth) => request(`/trade/orderbook?depth=${depth}`),18// protected ones pass a token:19placeOrder: (token, body) => request("/trade/order", {20method: "POST",21headers: { Authorization: `Bearer ${token}` },22body: JSON.stringify(body),23}),24};
How it connects to the backend
The frontend talks only to the Rust node over HTTP. There is no direct database access, no WebSockets — just JSON requests, many of them polling. This keeps the architecture simple and the API surface small. The Backend → full API reference lists every endpoint this client can call.
The one frontend env var
NEXT_PUBLIC_API_URL— the backend's base URL. Defaults tohttp://localhost:8080in dev; set to the Railway URL in production (also baked intowrangler.toml).