Skip to main content
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.
All examples use the info() helper and the key-holding proxy from the series overview.

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.
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:
Either way, the perp payload has the same shape:
marginSummary.accountValue is perps margin only: it does not include vault equity. A wallet with a large vault stake can show a small accountValue here while actually holding far more. Rendering this field alone as “Account Value” or “Total Value” understates such wallets, sometimes by orders of magnitude.Join it with userVaultEquities (one extra Info call per wallet), which returns the wallet’s vault deposits as vault-address/equity pairs. Fetch the two independently so a failure in one doesn’t take down the other, because Promise.all rejects the whole pair the moment either call throws, which is exactly the failure case you’re trying to degrade gracefully from:
Don’t call this a total, and don’t stop at two of three buckets. Perps margin + vault equity still leaves out spot balances, and spotClearinghouseState gives you a cost basis (entryNtl), not a mark price, so valuing spot needs spotMeta and spot mids on top. Naming the perps+vault sum “Total Value” just moves the same understatement to a different set of wallets (spot-heavy ones instead of vault-heavy ones). Call the field something honest about its scope, such as knownValue or “Perps + Vaults”, until spot is actually priced in.Two honesty rules matter more than the arithmetic:
  • if the vault call failed outright, treat the contribution as null, not 0. A figure that silently falls back to perps-only margin looks complete but understates the wallet exactly as before.
  • a single stake whose equity string doesn’t parse (unweighed above) is different from a failed call: the call succeeded, one entry just isn’t numeric. Counting it as 0 keeps knownValue a floor rather than discarding the whole scan over one bad entry, but the UI must say so: show a (floor) qualifier whenever unweighed > 0, so the figure never reads as complete when it isn’t.
That gives three display states, not two:
  • perpsMargin and vaultEquity both known → render the combined figure and a labelled Perps Margin tile side by side. Collapsing them into one number loses the perps-only figure some readers specifically want.
  • perpsMargin known, vaultEquity null → render only the Perps Margin tile, noting the vault call failed. There is no combined figure to show.
  • perps is null → render neither metric; report that the perps request failed.
  • perps is available but perpsMargin is null (blank or unparseable accountValue) → render neither metric; report the value as unavailable. This is a data-quality gap, not a request failure, so don’t word it as one.

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.
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.
Spot balances are flatter:

3. Show open orders

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

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.
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.
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.
With a live price map, unrealized PnL becomes a derivation rather than a fetch:
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.
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.
The payload diverges too. Fills arrive as [address, fill] tuples under a fills key, not in Hyperliquid’s native { data: { user, fills } } envelope:
Because allFills and userFills share that envelope, one parser handles both:
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.
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.

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:

Next

The wallet tracker shows the present. Next, PnL analytics reconstructs the past from fill history.