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.