Skip to main content

Query Hyperliquid Balances for Multiple Wallets

Updated on
Oct 15, 2026

31 min read

Overview​

A portfolio screen, a customer report, or a wallet watchlist needs balances for many Hyperliquid addresses at once. The standard /info endpoint answers one wallet per call, and it has separate calls for spot and perpetual state. For a list of wallets, that means many requests.

Quicknode offers two ways to fetch the whole list in a single request. The hl_batchPortfolioStates method returns live balances. SQL Explorer returns timestamped snapshots, and it also covers history and aggregates.

In this guide, you will fetch live balances with curl, TypeScript, and the Quicknode CLI. Then you will query snapshot balances, positions, and trading stats with SQL.

TL;DR
  • Send your wallet list to hl_batchPortfolioStates to get spot balances, perpetual state, and account mode in one call.
  • Check failed_wallets in each response. Malformed addresses land there, and the other wallets still return.
  • Use SQL Explorer for snapshots, history, joins, and aggregates across wallets.
  • Every SQL balance row carries block_number and snapshot_time, so you know the exact point in time it reflects.

What You Will Do​

  • Fetch live spot and perpetual balances for a wallet list with one request.
  • Run the same fetch from TypeScript and from the Quicknode CLI.
  • Query snapshot balances, open positions, and trading stats for the same wallets with SQL.

What You Will Need​

Which Method Should You Use to Query Multiple Hyperliquid Wallets?​

Pick the method by the question you need to answer.

You needUse
Live spot balances, held amounts, and account modehl_batchPortfolioStates
Live perpetual state for very large wallet listshl_batchClearinghouseStates
Live open orders for a wallet listhl_batchOpenOrders
History, aggregates, joins, or large result setsSQL Explorer
A push when a balance changesWebhooks or Streams

Start with the batch method for balances. It reads live state, and it returns the account mode that SQL snapshots do not carry.

How to Get Live Balances for Multiple Hyperliquid Wallets​

HyperCore is Hyperliquid's trading layer. Quicknode serves its batch methods as JSON-RPC (remote procedure call) on the /hypercore path of your Hyperliquid endpoint.

Open your Hyperliquid Mainnet endpoint in the Quicknode dashboard. In the RPC tab, copy the HyperCore URL. It ends with /hypercore. The rest of this guide calls that value QUICKNODE_HYPERCORE_URL.

Send the Batch Request With curl​

The endpoint URL contains your access token. Read it into a variable with a hidden prompt, so it stays out of your shell history:

printf 'HyperCore URL: '
read -rs QUICKNODE_HYPERCORE_URL
printf '\n'
export QUICKNODE_HYPERCORE_URL

Send the wallet list in params.users. Set dex to ALL_DEXES to include perpetual positions on every DEX. The highlighted lines hold the addresses and the DEX option:

curl -s "$QUICKNODE_HYPERCORE_URL" \
-H 'Content-Type: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "hl_batchPortfolioStates",
"params": {
"users": [
"0xffb95570fa0636e4f8c597be2d8449ab5bfdee3f",
"0xffcd8eb9f65449e8c8782798c32040fd6d18bafb"
],
"dex": "ALL_DEXES"
}
}' | jq

The response holds one [address, state] pair for each wallet. With ALL_DEXES, clearinghouseState is keyed by DEX name. Expand the block below to see the response. It omits DEXes where the wallet has no balance and no positions.

Example response
{
"jsonrpc": "2.0",
"result": {
"successful_states": [
[
"0xffb95570fa0636e4f8c597be2d8449ab5bfdee3f",
{
"clearinghouseState": {
"native": {
"marginSummary": {
"accountValue": "0.007769",
"totalNtlPos": "0.0",
"totalRawUsd": "0.007769",
"totalMarginUsed": "0.0"
},
"crossMarginSummary": {
"accountValue": "0.007769",
"totalNtlPos": "0.0",
"totalRawUsd": "0.007769",
"totalMarginUsed": "0.0"
},
"crossMaintenanceMarginUsed": "0.0",
"withdrawable": "0.007769",
"assetPositions": [],
"time": 1792010874125
},
"xyz": {
"marginSummary": {
"accountValue": "0.002131",
"totalNtlPos": "0.0",
"totalRawUsd": "0.002131",
"totalMarginUsed": "0.0"
},
"crossMarginSummary": {
"accountValue": "0.002131",
"totalNtlPos": "0.0",
"totalRawUsd": "0.002131",
"totalMarginUsed": "0.0"
},
"crossMaintenanceMarginUsed": "0.0",
"withdrawable": "0.002131",
"assetPositions": [],
"time": 1792010874125
},
"io": {
"marginSummary": {
"accountValue": "138.180444",
"totalNtlPos": "256.31456",
"totalRawUsd": "280.432204",
"totalMarginUsed": "51.833662"
},
"crossMarginSummary": {
"accountValue": "86.346782",
"totalNtlPos": "0.0",
"totalRawUsd": "86.346782",
"totalMarginUsed": "0.0"
},
"crossMaintenanceMarginUsed": "0.0",
"withdrawable": "86.346782",
"assetPositions": [
{
"type": "oneWay",
"position": {
"coin": "io:ANTH",
"szi": "0.029",
"leverage": {
"type": "isolated",
"value": 6,
"rawUsd": "-49.232626"
},
"entryPx": "2036.6",
"positionValue": "57.0314",
"unrealizedPnl": "-2.03",
"returnOnEquity": "-0.206226063",
"liquidationPx": "1852.0110094044",
"marginUsed": "7.798774",
"maxLeverage": 6,
"cumFunding": {
"allTime": "0.00969",
"sinceOpen": "0.00969",
"sinceChange": "0.00969"
}
}
},
{
"type": "oneWay",
"position": {
"coin": "io:SNDK",
"szi": "-0.1437",
"leverage": {
"type": "isolated",
"value": 6,
"rawUsd": "243.318048"
},
"entryPx": "1449.7",
"positionValue": "199.28316",
"unrealizedPnl": "9.03873",
"returnOnEquity": "0.2603297234",
"liquidationPx": "1612.6059449249",
"marginUsed": "44.034888",
"maxLeverage": 10,
"cumFunding": {
"allTime": "-0.273829",
"sinceOpen": "-0.109427",
"sinceChange": "-0.109427"
}
}
}
],
"time": 1792010874125
}
},
"spotClearinghouseState": {
"balances": [
{
"coin": "USDC",
"token": 0,
"total": "0.0",
"hold": "0.0",
"entryNtl": "0.0"
}
]
},
"userAbstraction": "default"
}
],
[
"0xffcd8eb9f65449e8c8782798c32040fd6d18bafb",
{
"clearinghouseState": {
"io": {
"marginSummary": {
"accountValue": "1841.295084",
"totalNtlPos": "4357.50936",
"totalRawUsd": "6198.804444",
"totalMarginUsed": "1841.295084"
},
"crossMarginSummary": {
"accountValue": "0.0",
"totalNtlPos": "0.0",
"totalRawUsd": "0.0",
"totalMarginUsed": "0.0"
},
"crossMaintenanceMarginUsed": "0.0",
"withdrawable": "0.0",
"assetPositions": [
{
"type": "oneWay",
"position": {
"coin": "io:ANTH",
"szi": "-1.539",
"leverage": {
"type": "isolated",
"value": 3,
"rawUsd": "4125.111491"
},
"entryPx": "2008.506",
"positionValue": "3026.5974",
"unrealizedPnl": "64.4946",
"returnOnEquity": "0.0625939959",
"liquidationPx": "2474.2009242765",
"marginUsed": "1098.514091",
"maxLeverage": 6,
"cumFunding": {
"allTime": "-4.773354",
"sinceOpen": "-0.148113",
"sinceChange": "-0.042977"
}
}
},
{
"type": "oneWay",
"position": {
"coin": "io:SNDK",
"szi": "-0.9597",
"leverage": {
"type": "isolated",
"value": 2,
"rawUsd": "2073.692953"
},
"entryPx": "1439.69",
"positionValue": "1330.91196",
"unrealizedPnl": "50.76755",
"returnOnEquity": "0.0734867234",
"liquidationPx": "2057.8781593454",
"marginUsed": "742.780993",
"maxLeverage": 10,
"cumFunding": {
"allTime": "-1.388804",
"sinceOpen": "-0.216695",
"sinceChange": "-0.084277"
}
}
}
],
"time": 1792010874125
}
},
"spotClearinghouseState": {
"balances": [
{
"coin": "USDC",
"token": 0,
"total": "1877.23508721",
"hold": "1841.295084",
"entryNtl": "0.0"
},
{
"coin": "PURR",
"token": 1,
"total": "0.0",
"hold": "0.0",
"entryNtl": "0.0"
},
{
"coin": "HYPE",
"token": 150,
"total": "0.0",
"hold": "0.0",
"entryNtl": "0.0"
},
{
"coin": "USDE",
"token": 235,
"total": "0.0",
"hold": "0.0",
"entryNtl": "0.0"
},
{
"coin": "USDT0",
"token": 268,
"total": "0.0",
"hold": "0.0",
"entryNtl": "0.0"
},
{
"coin": "USDH",
"token": 360,
"total": "0.0",
"hold": "0.0",
"entryNtl": "0.0"
}
],
"tokenToAvailableAfterMaintenance": [
[
0,
"35.94000321"
]
]
},
"userAbstraction": "unifiedAccount"
}
]
],
"failed_wallets": []
},
"id": 1
}

Read the Response​

Each entry in result.successful_states is an [address, state] pair. Read result.failed_wallets too. It lists the addresses that did not return a state.

Field in each stateWhat it holds
userAbstractionAccount mode: default, unifiedAccount, or disabled
spotClearinghouseState.balancesPer token: coin, total, and hold
spotClearinghouseState.tokenToAvailableAfterMaintenanceAvailable amount per token index, for unified accounts
clearinghouseState.<dex>.marginSummaryPerpetual account value and margin use for each DEX
clearinghouseState.<dex>.assetPositionsOpen perpetual positions on each DEX

In the example, the second wallet uses a unified account. It holds about 1877 USDC, and about 1841 of that is on hold. The available amount is 35.94000321 USDC.

Keep these points in mind:

  • Check failed_wallets. A malformed address lands there, and the other wallets in the batch still return. A valid address with no Hyperliquid activity returns a state with empty balances.
  • Add dex for other perpetual DEXes. Without it, the response covers the native DEX only. Positions on other DEXes do not appear and assetPositions stays empty. Set "dex": "ALL_DEXES" and clearinghouseState becomes an object keyed by DEX name. Each value holds the same fields as the native state.
  • Keep spot amounts per token. Adding USDC and HYPE amounts does not give a portfolio value.
  • Use hl_batchClearinghouseStates for perpetuals only. It accepts a larger list and skips spot data.

Fetch Hyperliquid Balances for a Large Wallet List With TypeScript​

One call accepts a limited number of wallets, so a long list needs more than one request. The script below reads addresses from a file, removes duplicates, sends them in batches, and prints the non-zero balances of each wallet.

  1. Create a project and install the tools:

    mkdir hyperliquid-balances && cd hyperliquid-balances
    npm init -y
    npm pkg set type=module
    npm install --save-dev tsx typescript @types/node
  2. Create a .env file in your editor with your HyperCore URL:

    .env
    QUICKNODE_HYPERCORE_URL=<your HyperCore URL>

    Keep that file out of version control:

    echo '.env' >> .gitignore
  3. Save your wallets in wallets.txt, one address per line:

    wallets.txt
    0xffb95570fa0636e4f8c597be2d8449ab5bfdee3f
    0xffbe314e5a2e5aaa13b207eb0eb5fd12dc7baa5d
    0xffcd8eb9f65449e8c8782798c32040fd6d18bafb
  4. Save this code as balances.ts:

    balances.ts
    import { readFileSync } from "node:fs";

    // Per-call wallet limit. See the hl_batchPortfolioStates reference.
    const MAX_USERS = 500;
    const url = process.env.QUICKNODE_HYPERCORE_URL ?? "";
    if (!url) throw new Error("Set QUICKNODE_HYPERCORE_URL in .env");

    type Balance = { coin: string; token?: number; total: string; hold: string };
    type PortfolioState = {
    userAbstraction?: string;
    // With ALL_DEXES, perpetual state is keyed by DEX name.
    clearinghouseState?: Record<
    string,
    { marginSummary: { accountValue: string }; assetPositions: unknown[] }
    >;
    spotClearinghouseState: { balances: Balance[] };
    };
    type BatchResult = {
    successful_states: [string, PortfolioState][];
    failed_wallets: unknown[];
    };

    // Normalize case and drop duplicates.
    const wallets = [
    ...new Set(
    readFileSync("wallets.txt", "utf8")
    .split("\n")
    .map((line) => line.trim().toLowerCase())
    .filter(Boolean),
    ),
    ];

    async function batch(users: string[]): Promise<BatchResult> {
    const res = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
    jsonrpc: "2.0",
    id: 1,
    method: "hl_batchPortfolioStates",
    params: { users, dex: "ALL_DEXES" },
    }),
    });
    const body = await res.json();
    if (!res.ok || body.error) {
    throw new Error(`Batch failed: ${JSON.stringify(body.error ?? body)}`);
    }
    return body.result;
    }

    for (let i = 0; i < wallets.length; i += MAX_USERS) {
    const { successful_states, failed_wallets } = await batch(
    wallets.slice(i, i + MAX_USERS),
    );

    for (const [address, state] of successful_states) {
    console.log(`\n${address} mode=${state.userAbstraction}`);
    for (const [dex, perps] of Object.entries(state.clearinghouseState ?? {})) {
    const value = perps.marginSummary.accountValue;
    if (Number(value) === 0) continue;
    console.log(` perps ${dex.padEnd(6)} accountValue=${value} positions=${perps.assetPositions.length}`);
    }
    for (const b of state.spotClearinghouseState.balances) {
    if (Number(b.total) === 0) continue;
    console.log(` ${b.coin.padEnd(8)} total=${b.total} hold=${b.hold}`);
    }
    }
    if (failed_wallets.length) {
    console.warn("Failed wallets:", failed_wallets);
    }
    }
  5. Run the script:

    npx tsx --env-file=.env balances.ts

Example output:

0xffb95570fa0636e4f8c597be2d8449ab5bfdee3f mode=default
perps native accountValue=0.007769 positions=0
perps xyz accountValue=0.002131 positions=0
perps io accountValue=138.180444 positions=2

0xffbe314e5a2e5aaa13b207eb0eb5fd12dc7baa5d mode=default
perps native accountValue=0.009294 positions=0
perps xyz accountValue=0.008875 positions=0
perps io accountValue=124.469891 positions=1

0xffcd8eb9f65449e8c8782798c32040fd6d18bafb mode=unifiedAccount
perps io accountValue=1841.295084 positions=2
USDC total=1877.23508721 hold=1841.295084

The script keeps amounts as strings. Keep them as strings in your application if you need exact decimal math.

Call the Batch Method With the Quicknode CLI​

The qn rpc call command sends calls to your Tooling Access endpoint by default. That route does not serve HyperCore methods, so pass your HyperCore URL with --endpoint-url. Use the CLI for quick checks and shell scripts:

qn rpc call hl_batchPortfolioStates \
--endpoint-url "$QUICKNODE_HYPERCORE_URL" \
'{"users":["0xffb95570fa0636e4f8c597be2d8449ab5bfdee3f","0xffcd8eb9f65449e8c8782798c32040fd6d18bafb"]}' \
-o json | jq -c '.successful_states[] | [.[0], .[1].userAbstraction]'

The jq filter prints each address with its account mode:

["0xffb95570fa0636e4f8c597be2d8449ab5bfdee3f","default"]
["0xffcd8eb9f65449e8c8782798c32040fd6d18bafb","unifiedAccount"]

How to Query Hyperliquid Balance Snapshots With SQL Explorer​

SQL Explorer reads indexed Hyperliquid data. Use it when you need history, aggregates, or joins across wallets. Filter the spot snapshot table with user IN (...) to request many addresses in one query.

Open SQL Explorer in the Quicknode dashboard and select the Hyperliquid Mainnet cluster. Its identifier for API requests is hyperliquid-core-mainnet. You can browse tables and columns in the schema browser or the Schema Reference.

Run this query. It reads the latest snapshot and joins token metadata to turn raw integers into token amounts:

SELECT s.user, s.token, s.token_idx,
toString(s.total) AS balance_raw,
m.wei_decimals,
toFloat64(s.total) / pow(10, m.wei_decimals) AS balance,
s.block_number, s.snapshot_time
FROM hyperliquid_spot_clearinghouse_states AS s
LEFT JOIN (
SELECT token_index, argMax(wei_decimals, indexed_at) AS wei_decimals
FROM hyperliquid_spot_markets
GROUP BY token_index
) AS m ON s.token_idx = m.token_index
WHERE s.block_number = (SELECT max(block_number) FROM hyperliquid_spot_clearinghouse_states)
AND s.user IN (
'0x000000000000000000000000000000000000dead',
'0xffb95570fa0636e4f8c597be2d8449ab5bfdee3f',
'0xffcd8eb9f65449e8c8782798c32040fd6d18bafb'
)
ORDER BY s.user, s.token_idx
LIMIT 100

Always filter by block_number. The table adds a new snapshot of every wallet every few minutes, and it keeps the earlier ones. The block filter selects only the latest snapshot, so each wallet returns one row per token. Without the filter, the query reads every past snapshot. It scans much more data, uses more credits, and can time out.

The first rows of the result look like this. The 0x...dead address is a public burn address that holds many tokens:

usertokenbalance_rawwei_decimalsbalance
0x000000000000000000000000000000000000deadUSDC2240582782822.40582782
0x000000000000000000000000000000000000deadPURR47837454.78374
0xffcd8eb9f65449e8c8782798c32040fd6d18bafbUSDC3594000321835.94000321

Every row carries block_number and snapshot_time (UTC). The argMax subquery picks the latest decimals for each token, so repeated metadata rows do not multiply your balance rows. Keep balance_raw for exact math. The Float64 column is for display.

Unified Accounts Report a Lower USDC Amount in SQL

The wallet 0xffcd8eb9f65449e8c8782798c32040fd6d18bafb uses a unified account. A unified account shares one balance between spot trading and perpetual collateral. In this example, SQL shows 35.94000321 USDC. That value matches the available amount from the batch method. The batch method reports a USDC total of about 1877, and about 1841 of that is hold.

For a unified account, hold is not only the amount locked by open spot orders. It also includes the margin locked for isolated perpetual positions. This wallet holds isolated positions on the io DEX, and their margin counts toward hold. In the example response, the held amount equals the io account value. Request "dex": "ALL_DEXES" to see those positions next to the held amount.

Use the batch method when you need total, held, and available amounts together. Use SQL balances as snapshot values.

If a wallet returns no rows, the snapshot has no spot balance for it. That alone does not prove the account is empty.

Paginate the Results​

A wallet can hold many tokens, so one result row is one wallet and token pair. A page holds token rows, not a fixed number of wallets.

To paginate, pin the snapshot block once and reuse it on every page. First, read the latest block:

SELECT max(block_number) AS snapshot_block
FROM hyperliquid_spot_clearinghouse_states

In the balance query, replace the (SELECT max(...)) subquery with that number, and replace the last line with a page size and offset:

LIMIT 20 OFFSET 0

Increase OFFSET by the page size for each request, and stop when a page returns fewer rows than the page size. Keep the same wallet list and ORDER BY s.user, s.token_idx on every page. With a pinned block, the pages reproduce the full result in the same order, with no repeated rows.

Run the Balance Query From Your Code​

After the query returns what you need, run it from a script or a backend. Use the Quicknode CLI for shell scripts, or the REST API for any language.

Run the Query With the Quicknode CLI​

Save the query as spot-balances.sql, then run it:

qn sql query --cluster-id hyperliquid-core-mainnet --file spot-balances.sql -o json

The response has a data array of rows, and rows holds the row count.

Run the Query With the REST API​

You can also send the query to the SQL Explorer REST API from any language. The request body holds the cluster ID and the SQL text. You need an API key with SQL Explorer access. See the authentication instructions to create one.

Store the key in QUICKNODE_API_KEY with the same hidden prompt you used for the HyperCore URL. Here is a sample request with curl. It uses jq to build the JSON body from your SQL file:

jq -Rsc '{clusterId: "hyperliquid-core-mainnet", query: .}' spot-balances.sql \
| curl -s https://api.quicknode.com/sql/rest/v1/query \
-H 'Content-Type: application/json' \
-H "x-api-key: $QUICKNODE_API_KEY" \
-d @- \
| jq '{rows, first: .data[0]}'

The response has the same data and rows fields as the CLI output.

Add Positions and Trading Stats for the Same Wallets​

The same wallet filter works on other tables. These two recipes extend your report.

Query Open Positions for Multiple Wallets​

Position sizes use the market's sz_decimals, so the query joins market metadata:

SELECT p.user, p.clearinghouse, p.coin,
toString(p.size) AS size_raw, m.sz_decimals,
toFloat64(p.size) / pow(10, m.sz_decimals) AS position_size,
p.margin_mode, p.leverage, p.snapshot_time
FROM hyperliquid_clearinghouse_states AS p
LEFT JOIN (
SELECT coin, argMax(sz_decimals, indexed_at) AS sz_decimals
FROM hyperliquid_perpetual_markets GROUP BY coin
) AS m ON p.coin = m.coin
WHERE p.block_number = (SELECT max(block_number) FROM hyperliquid_clearinghouse_states)
AND p.user IN (
'0xffb95570fa0636e4f8c597be2d8449ab5bfdee3f',
'0xffbe314e5a2e5aaa13b207eb0eb5fd12dc7baa5d',
'0xffcd8eb9f65449e8c8782798c32040fd6d18bafb'
)
AND p.size != 0
ORDER BY p.user, p.clearinghouse, p.asset_idx
LIMIT 100

A positive position_size is a long position. A negative one is a short position. Example rows:

usercoinsize_rawsz_decimalsposition_sizemargin_mode
0xffcd8eb9f65449e8c8782798c32040fd6d18bafbio:ANTH-13393-1.339isolated
0xffcd8eb9f65449e8c8782798c32040fd6d18bafbio:SNDK-85594-0.8559isolated

Query Trading Volume, Closed PnL, and Fees​

This query summarizes the last 24 hours of fills for each wallet. It groups by market and fee token, so fees in different tokens stay separate. PnL means profit and loss.

SELECT user, coin, fee_token, count() AS fills,
sum(toFloat64(price) * toFloat64(size)) AS traded_notional,
sum(toFloat64(closed_pnl)) AS realized_pnl,
sum(toFloat64(fee)) AS trading_fees
FROM hyperliquid_fills
WHERE block_time >= now() - INTERVAL 1 DAY
AND user IN (
'0xffb95570fa0636e4f8c597be2d8449ab5bfdee3f',
'0xffbe314e5a2e5aaa13b207eb0eb5fd12dc7baa5d',
'0xffcd8eb9f65449e8c8782798c32040fd6d18bafb'
)
GROUP BY user, coin, fee_token
ORDER BY user, coin, fee_token
LIMIT 100

Example rows for one wallet:

coinfillstraded_notionalrealized_pnltrading_feesfee_token
io:ANTH415446.05-6.9480.317USDC
io:SNDK122764.65-12.0420.142USDC

realized_pnl sums the closed_pnl field of each fill. It excludes funding and does not subtract trading_fees, so it is not total account profit.

Conclusion​

You can now fetch Hyperliquid balances for a whole wallet list. The batch method gives live totals, held amounts, and account mode in one request. SQL Explorer gives timestamped snapshots, positions, and trading stats, and it handles history and large result sets.

Next Steps​

Frequently Asked Questions​

How do I get balances for multiple Hyperliquid wallets in one request?

Call Quicknode's hl_batchPortfolioStates method on the /hypercore path of your Hyperliquid endpoint. Put the wallet addresses in params.users. The response includes spot balances, perpetual state, and account mode for each wallet.

Is there a Hyperliquid API for multiple users at once?

The standard /info endpoint answers one wallet per call. Quicknode adds batch methods for wallet lists: hl_batchPortfolioStates, hl_batchClearinghouseStates, and hl_batchOpenOrders. Each method has its own limit on wallets per call, listed in its reference page.

How fresh are SQL Explorer balances?

SQL Explorer balance tables hold snapshots that refresh every few minutes. Each row carries a block_number and a snapshot_time, so you know the exact point in time it reflects. Use them for history, aggregates, and reports. For real-time balances, call hl_batchPortfolioStates.

What happens if a wallet list contains an invalid address?

hl_batchPortfolioStates lists the address in result.failed_wallets. The other wallets in the batch still return their state in result.successful_states.

How do I convert raw Hyperliquid spot balances in SQL into token amounts?

Divide the raw total by 10 raised to the token's wei_decimals from hyperliquid_spot_markets. Keep the raw integer as a string for exact math. Use sz_decimals for perpetual position sizes.

How do I paginate SQL Explorer results for a wallet list?

Pin one snapshot block, order by user and token_idx, and page with LIMIT and OFFSET. Reuse the same block and wallet list on every page. One result row is one wallet and token pair, so a page holds token rows.