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

# 1. Wallet Tracker

> Track any Hyperliquid address: perp positions, spot balances, open orders, live unrealized PnL, and a real-time fill feed.

The wallet tracker answers *what does this address hold right now?*: open perp
positions with entry, mark, and liquidation prices, spot balances, resting orders, and a
live fill feed that ticks as the address trades.

This page introduces three patterns the rest of the series reuses: reading account state,
maintaining a live mid-price store, and subscribing to a user-scoped WebSocket channel.

<Info>
  All examples use the `info()` helper and the key-holding proxy from the
  [series overview](/guides/hyperliquid/analytics-dashboard/overview).
</Info>

## 1. Read account state

You have two options, and the choice is about how much normalization you want.

**`portfolioState`** is a Uniblock synthetic type: one call returns perps, spot, and
account-abstraction mode composed into a single object, with each section isolated so a
failure in one doesn't take down the others.

```javascript theme={null}
const portfolio = await info({ type: "portfolioState", user: address });

// {
//   type: "portfolioState",
//   address: "0x...",
//   chainId: 999,
//   perpetuals: { marginSummary, assetPositions, withdrawable, ... } | null,
//   spot: { balances: [...] } | null
// }
```

Note that `perpetuals` and `spot` are **nullable**. A section that failed upstream comes
back `null` rather than throwing, so guard before you read into it.

**`clearinghouseState`** and **`spotClearinghouseState`** are the native Hyperliquid
reads, if you'd rather compose them yourself:

```javascript theme={null}
const [perps, spot] = await Promise.all([
  info({ type: "clearinghouseState", user: address }),
  info({ type: "spotClearinghouseState", user: address }),
]);
```

Either way, the perp payload has the same shape:

```json theme={null}
{
  "marginSummary": {
    "accountValue": "1250.44",
    "totalNtlPos": "8300.10",
    "totalRawUsd": "1250.44",
    "totalMarginUsed": "415.00"
  },
  "crossMarginSummary": { "...": "same fields" },
  "crossMaintenanceMarginUsed": "83.00",
  "withdrawable": "835.44",
  "assetPositions": [
    {
      "type": "oneWay",
      "position": {
        "coin": "BTC",
        "szi": "0.12",
        "entryPx": "62150.0",
        "positionValue": "7500.00",
        "unrealizedPnl": "42.10",
        "returnOnEquity": "0.0562",
        "liquidationPx": "48120.5",
        "leverage": { "type": "cross", "value": 10 },
        "marginUsed": "750.00"
      }
    }
  ],
  "time": 1753660800000
}
```

## 2. Render the positions table

Positions live one level down, under `assetPositions[].position`. The field that trips
people up is `szi`, which is the **signed** position size, so its sign is what tells you
long from short. There is no `side` field.

```javascript theme={null}
const positions = (perps?.assetPositions ?? []).map(({ position: p }) => ({
  coin: p.coin,
  side: Number(p.szi) > 0 ? "long" : "short",
  size: Math.abs(Number(p.szi)),
  entryPx: Number(p.entryPx),
  // null for a cross-margin position that can't be individually liquidated
  liquidationPx: p.liquidationPx === null ? null : Number(p.liquidationPx),
  leverage: `${p.leverage.value}x ${p.leverage.type}`,
  marginUsed: Number(p.marginUsed),
  unrealizedPnl: Number(p.unrealizedPnl),
}));
```

<Note>
  `liquidationPx` is legitimately `null` for some positions. Cross-margin positions
  don't each have their own liquidation price, since the whole account is collateral.
  Render a dash, not a zero.
</Note>

Spot balances are flatter:

```javascript theme={null}
const balances = (spot?.balances ?? []).map((b) => ({
  coin: b.coin,
  total: Number(b.total),
  available: Number(b.total) - Number(b.hold), // `hold` is locked in resting orders
  entryNtl: Number(b.entryNtl),
}));
```

## 3. Show open orders

```javascript theme={null}
const orders = await info({ type: "frontendOpenOrders", user: address });

// [{ coin, side, limitPx, sz, oid, timestamp, origSz, cloid? }]
```

`side` is `"B"` for bid (buy) and `"A"` for ask (sell), Hyperliquid's convention
throughout, in fills as well as orders. `sz` is what's left unfilled; `origSz` is what
the order was placed for, so `origSz - sz` is the filled portion.

<Tip>
  Use `frontendOpenOrders` rather than `openOrders` unless you have a reason not to. Both
  return the same resting orders, but `frontendOpenOrders` adds order-type and trigger
  metadata, and it's served by all four providers, whereas `openOrders` is not served by
  GoldRush. If your request fails over to GoldRush, `frontendOpenOrders` keeps working.
</Tip>

## 4. Keep mid prices live

Positions are only half the picture: `unrealizedPnl` in the snapshot was true at
`time`, and it drifts the moment the market moves. To keep it live you need a current
mark for each coin.

**There is no `allMids` WebSocket channel.** This is the single most common wrong
assumption when building on this stack: the REST type exists, so people look for the
matching stream and don't find one. The working pattern is a hybrid:

* **Seed and correct** from REST `allMids` on a slow interval (30 seconds is plenty).
* **Update tick-by-tick** from the `allFills` WebSocket channel, taking each fill's price
  as the latest trade price for its coin.

```javascript theme={null}
const prices = new Map();

// Seed from REST, and re-seed slowly to correct any drift.
async function seedPrices() {
  const mids = await info({ type: "allMids" }); // { "BTC": "62150.0", "ETH": "2410.5", ... }
  for (const [coin, px] of Object.entries(mids)) prices.set(coin, Number(px));
}
await seedPrices();
setInterval(seedPrices, 30_000);

// Update from the live trade stream.
ws.send(JSON.stringify({ method: "subscribe", subscription: { type: "allFills" } }));

ws.on("message", (raw) => {
  const msg = JSON.parse(raw.toString());
  if (msg.channel !== "allFills") return;
  for (const [, fill] of msg.fills) {
    if (fill?.coin && fill.px) prices.set(fill.coin, Number(fill.px));
  }
});
```

Two things to note about that subscription, both covered in full in the next section:
`allFills` needs no parameters (it's the whole venue), and its payload arrives as
`[address, fill]` **tuples**, which is why the loop destructures past the address.

<Tip>
  `allFills` is a busy stream, with thousands of events a minute at peak. Don't
  re-render on every message. Batch price updates and flush on a timer (250ms works
  well) so a busy market doesn't pin your UI thread.
</Tip>

With a live price map, unrealized PnL becomes a derivation rather than a fetch:

```javascript theme={null}
function liveUnrealizedPnl(positions) {
  return positions.reduce((sum, p) => {
    const mid = prices.get(p.coin);
    // Fall back to the snapshot value until the first price for that coin arrives.
    if (mid === undefined) return sum + Number(p.unrealizedPnl);
    return sum + Number(p.szi) * (mid - Number(p.entryPx));
  }, 0);
}
```

Because `szi` is signed, that one expression is correct for both directions: a short has
negative `szi`, so a falling price yields a positive product.

## 5. Stream this wallet's fills

Finally, subscribe to the address's own fills for a live activity feed.

```javascript theme={null}
ws.send(
  JSON.stringify({
    method: "subscribe",
    subscription: { type: "userFills", addresses: [address] },
  }),
);
```

<Warning>
  **`userFills` takes `addresses: string[]`, not `user: string`.** This diverges from
  Hyperliquid's native WebSocket contract, and it fails quietly: send the native
  `{ type: "userFills", user: "0x..." }` form and the subscription is accepted but never
  delivers anything. The same applies to `orderUpdates`. If a user-scoped stream is
  silent, check this first.
</Warning>

The payload diverges too. Fills arrive as **`[address, fill]` tuples** under a `fills`
key, not in Hyperliquid's native `{ data: { user, fills } }` envelope:

```json theme={null}
{
  "channel": "userFills",
  "fills": [
    [
      "0xabc...",
      {
        "coin": "BTC",
        "px": "62180.0",
        "sz": "0.02",
        "side": "B",
        "time": 1753660812345,
        "startPosition": "0.10",
        "dir": "Open Long",
        "closedPnl": "0.0",
        "hash": "0x...",
        "oid": 88123456,
        "fee": "0.37",
        "tid": 445566778899
      }
    ]
  ]
}
```

Because `allFills` and `userFills` share that envelope, one parser handles both:

```javascript theme={null}
function parseFillTuples(msg) {
  if (!Array.isArray(msg.fills)) return [];
  return msg.fills.filter(([address, fill]) => typeof address === "string" && fill?.coin);
}

ws.on("message", (raw) => {
  const msg = JSON.parse(raw.toString());
  if (msg.channel !== "userFills") return;
  for (const [, fill] of parseFillTuples(msg)) {
    feed.unshift({
      coin: fill.coin,
      side: fill.side === "B" ? "buy" : "sell",
      px: Number(fill.px),
      sz: Number(fill.sz),
      // Human-readable already: "Open Long", "Close Short", "Long > Short", …
      dir: fill.dir,
      pnl: Number(fill.closedPnl),
      time: fill.time,
    });
  }
});
```

The `dir` field is worth surfacing directly in the feed. It is a pre-formatted
description of what the fill did to the position, which saves you inferring it from
`startPosition` and `szi`.

<Note>
  Subscriptions are matched for unsubscribe by their **exact** body. Send
  `{ method: "unsubscribe", subscription: { type: "userFills", addresses: [address] } }`
  with the identical `addresses` array you subscribed with, or the subscription (and its
  per-minute billing) stays open. When a user switches wallets, unsubscribe the old
  address before subscribing the new one.
</Note>

## Reconnecting

The stream will drop. Handle it with exponential backoff, and re-send every active
subscription on reconnect, because the server keeps no state across connections:

```javascript theme={null}
let retry = 0;

function connect() {
  const ws = new WebSocket("ws://localhost:8787/ws");

  ws.on("open", () => {
    retry = 0;
    for (const sub of activeSubscriptions) {
      ws.send(JSON.stringify({ method: "subscribe", subscription: sub }));
    }
  });

  ws.on("close", () => {
    setTimeout(connect, Math.min(15_000, 500 * 2 ** retry++));
  });

  return ws;
}
```

## Next

The wallet tracker shows the present. Next,
[PnL analytics](/guides/hyperliquid/analytics-dashboard/pnl) reconstructs the past from
fill history.
