> ## Documentation Index
> Fetch the complete documentation index at: https://docs.uniblock.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a Hyperliquid Analytics Dashboard

> Build a four-surface Hyperliquid analytics dashboard on Uniblock's Info API and WebSocket feed: wallet tracking, PnL, market data, and leaderboards.

This series walks through building a **Hyperliquid analytics dashboard** on Uniblock: a
wallet tracker, per-trader PnL analytics, a live market and order book explorer, and a
trader leaderboard. Each page is self-contained, but they build in order, and the wallet
tracker introduces the patterns the others reuse.

Everything here runs on two Uniblock surfaces:

* The [**Hyperliquid Info API**](/guides/hyperliquid/info-api/overview), a
  `POST /uni/v1/hyperliquid/info` dispatch endpoint where the body's `type` field
  selects the operation.
* The [**GoldRush WebSocket**](/guides/hyperliquid/websockets/overview) at
  `wss://websocket.uniblock.dev/goldrush`, for order books and live fill streams.

<Info>
  Create your project and grab your API key from the
  [Uniblock dashboard](https://dashboard.uniblock.dev). New to Hyperliquid's
  architecture? Read [HyperCore vs. HyperEVM](/guides/quickstart-guides/hypercore-vs-hyperevm)
  first. This series is entirely HyperCore (the native order book), not the EVM side.
</Info>

## What you'll build

| Surface         | Answers                                         | Page                                                                       |
| --------------- | ----------------------------------------------- | -------------------------------------------------------------------------- |
| Wallet tracker  | What does this address hold right now?          | [Wallet tracker](/guides/hyperliquid/analytics-dashboard/wallet-tracker)   |
| PnL analytics   | How has this address performed over time?       | [PnL analytics](/guides/hyperliquid/analytics-dashboard/pnl)               |
| Market explorer | What's happening across the venue right now?    | [Market explorer](/guides/hyperliquid/analytics-dashboard/market-explorer) |
| Leaderboard     | Who's winning, out of the traders I care about? | [Leaderboard](/guides/hyperliquid/analytics-dashboard/leaderboard)         |

## Architecture: put a proxy in front

Your API key must never reach the browser. For the REST side that's a familiar rule. For
the WebSocket side it's a hard constraint: **browsers cannot set headers on a WebSocket
handshake**, so browser code physically cannot authenticate to
`wss://websocket.uniblock.dev/goldrush` with an `X-API-KEY` header. A small server-side
bridge isn't a stylistic preference here; it's the only way to open the stream from a
web app at all.

The whole server is about forty lines: forward Info request bodies verbatim, and pipe
WebSocket frames in both directions.

```javascript theme={null}
import { createServer } from "node:http";
import { WebSocketServer, WebSocket } from "ws";

const API_KEY = process.env.UNIBLOCK_API_KEY;
const INFO_URL = "https://api.uniblock.dev/uni/v1/hyperliquid/info";
const WSS_URL = "wss://websocket.uniblock.dev/goldrush";

// --- REST: forward the body verbatim, inject the key ---
const server = createServer(async (req, res) => {
  if (req.method !== "POST" || !req.url.startsWith("/api/info")) {
    res.writeHead(404).end();
    return;
  }
  const url = new URL(req.url, "http://localhost");
  const upstream = new URL(INFO_URL);
  upstream.searchParams.set("chainId", url.searchParams.get("chainId") ?? "999");
  // Let the client pin a provider when it needs one (see below).
  const provider = url.searchParams.get("provider");
  if (provider) upstream.searchParams.set("provider", provider);

  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);

  const r = await fetch(upstream, {
    method: "POST",
    headers: { "X-API-KEY": API_KEY, "content-type": "application/json" },
    body: Buffer.concat(chunks),
  });
  res.writeHead(r.status, { "content-type": "application/json" });
  res.end(Buffer.from(await r.arrayBuffer()));
});

// --- WebSocket: bridge browser <-> Uniblock, inject the key ---
const wss = new WebSocketServer({ noServer: true });

server.on("upgrade", (req, socket, head) => {
  if (new URL(req.url, "http://localhost").pathname !== "/ws") return socket.destroy();

  wss.handleUpgrade(req, socket, head, (client) => {
    const upstream = new WebSocket(WSS_URL, { headers: { "X-API-KEY": API_KEY } });
    const pending = [];

    // The browser may subscribe before the upstream socket is open, so buffer until then.
    upstream.on("open", () => {
      for (const msg of pending) upstream.send(msg);
      pending.length = 0;
    });
    upstream.on("message", (data) => {
      if (client.readyState === WebSocket.OPEN) client.send(data);
    });
    client.on("message", (data) => {
      const msg = data.toString();
      if (upstream.readyState === WebSocket.OPEN) upstream.send(msg);
      else pending.push(msg);
    });

    const closeBoth = () => {
      client.close();
      upstream.close();
    };
    client.on("close", closeBoth);
    upstream.on("close", closeBoth);
    upstream.on("error", closeBoth);
  });
});

server.listen(8787);
```

Every REST example in this series calls `POST /api/info` against that proxy. To keep the
snippets readable, they all assume this helper:

```javascript theme={null}
async function info(body, opts = {}) {
  const qs = new URLSearchParams({ chainId: "999", ...opts });
  const res = await fetch(`/api/info?${qs}`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`info ${body.type} failed: ${res.status}`);
  return res.json();
}
```

## Two conventions that apply everywhere

### Numbers arrive as decimal strings

Hyperliquid returns almost every number as a string: `"1234.5"`, not `1234.5`. This is
deliberate: it preserves exact decimal precision that IEEE-754 floats would lose. Keep
values as strings while you carry them around, and convert only at the point you do
display math:

```javascript theme={null}
// Wrong. String concatenation, not addition: "7500.00" + "42.10" === "7500.0042.10"
const wrong = position.positionValue + position.unrealizedPnl;

// Right. Convert at the edge, where you're formatting
const right = Number(position.positionValue) + Number(position.unrealizedPnl);
```

### `chainId` selects the network

`999` is Hyperliquid mainnet (the default), `998` is testnet. Every example here uses
mainnet.

## Which provider serves what

Uniblock routes each Info request through a provider waterfall: Alchemy, Dwellir,
Chainstack, GoldRush, in that priority order. **Different providers serve different Info
types**, and that has a practical consequence: the historical and market-data types this
series leans on are served by GoldRush alone.

| Info `type`              | Alchemy | Dwellir | Chainstack | GoldRush | Used by             |
| ------------------------ | :-----: | :-----: | :--------: | :------: | ------------------- |
| `portfolioState`         |    ✓    |    ✓    |      ✓     |     ✓    | Wallet, Leaderboard |
| `batchPortfolioStates`   |    ✓    |    ✓    |      ✓     |     ✓    | Leaderboard         |
| `clearinghouseState`     |    ✓    |    ✓    |      ✓     |     ✓    | Wallet              |
| `spotClearinghouseState` |    ✓    |    ✓    |      ✓     |     ✓    | Wallet              |
| `openOrders`             |    ✓    |    ✓    |      ✓     |     ✗    | Wallet              |
| `frontendOpenOrders`     |    ✓    |    ✓    |      ✓     |     ✓    | Wallet              |
| `meta`                   |    ✓    |    ✓    |      ✓     |     ✓    | Market explorer     |
| `webData2`               |    ✓    |    ✗    |      ✓     |     ✓    | Market explorer     |
| `allMids`                |    ✗    |    ✗    |      ✗     |     ✓    | Market explorer     |
| `l2Book`                 |    ✗    |    ✗    |      ✗     |     ✓    | Market explorer     |
| `candleSnapshot`         |    ✗    |    ✗    |      ✗     |     ✓    | PnL                 |
| `userFills`              |    ✗    |    ✗    |      ✗     |     ✓    | Wallet, PnL         |
| `userFillsByTime`        |    ✗    |    ✗    |      ✗     |     ✓    | PnL                 |
| `userFunding`            |    ✗    |    ✗    |      ✗     |     ✓    | PnL                 |

You don't normally need to act on this, because the waterfall automatically picks a
provider that serves your `type`. It matters in two situations:

* **Debugging.** If a response looks wrong, pinning `?provider=` lets you compare
  providers directly and find out whether you're looking at a routing artifact or real
  data.
* **Consistency.** If you want two related reads to come from the same source, pin both.

<Note>
  `openOrders` is the one asymmetry worth remembering: GoldRush serves
  `frontendOpenOrders` but not `openOrders`. If you want your open-orders read to
  survive a failover to GoldRush, use `frontendOpenOrders`. It returns the same orders
  with extra frontend metadata.
</Note>

## Cost model

Two meters run, and they behave differently.

**REST is billed per request.** A batch type multiplies: `batchPortfolioStates` over 50
addresses is billed as 50 lookups, not one. That's still far cheaper than 50 separate
requests, but it isn't free.

**WebSocket is billed per subscription-minute**, and the rate depends on the channel:

| Channel                                                                                | Rate                         | Wildcard (no `coin`)  |
| -------------------------------------------------------------------------------------- | ---------------------------- | --------------------- |
| `l2Book`                                                                               | 0.5 / coin / min             | 50 / min (all assets) |
| `l2BookDiff`                                                                           | 0.1 / coin / min             | 10 / min (all assets) |
| `l4Book`                                                                               | 2 / coin / min (**BTC: 20**) | not supported         |
| `allFills`, `userFills`, `orderUpdates`, `builderFills`, `userNonFundingLedgerUpdates` | 1 / min                      | n/a                   |

The shape of that table is the whole argument for scoping your subscriptions:
subscribing to `l2Book` for three coins costs 1.5/min; omitting `coin` to get all of them
costs 50/min, over thirty times more for data you'll mostly discard.

The general rule that follows: **prefer one WebSocket subscription over a polling loop.**
A stream you hold open for an hour costs 60 units; polling the same data every two
seconds costs 1,800 requests. Where you must poll, poll slowly and back off on `429`.

<Info>
  Routing through Uniblock removes Hyperliquid's per-IP request-weight cap, but your
  plan's rate limits still apply, and bursts return `429`. See
  [Rate limits, caching & polling](/guides/hyperliquid/info-api/limits).
</Info>

## Next

Start with the [wallet tracker](/guides/hyperliquid/analytics-dashboard/wallet-tracker):
it introduces account state, live prices, and your first WebSocket subscription.
