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

# Token dashboard indexing example

> Show token metadata, holders, and a paginated transfer feed for any token on Whitechain Sepolia with the Blockscout GraphQL and REST v2 APIs, with copy-paste requests and real responses.

export const ApiRunner = ({method = "GET", url, body, inputLabel, inputValue = "", inputPlaceholder, placeholder = "{input}", path, pathLabel = "Result", format, decimals, unit, runLabel = "Run this request"}) => {
  const [field, setField] = useState(inputValue);
  const [status, setStatus] = useState("idle");
  const [result, setResult] = useState(null);
  const [error, setError] = useState(null);
  const getPath = (obj, dotted) => {
    if (!dotted) return undefined;
    return dotted.split(".").reduce((acc, key) => acc === null || acc === undefined ? acc : acc[key], obj);
  };
  const toBigInt = value => {
    try {
      const text = String(value).trim();
      if (text.startsWith("0x") || text.startsWith("0X")) return BigInt(text);
      return BigInt(text.split(".")[0]);
    } catch (err) {
      return null;
    }
  };
  const formatUnits = (amount, places) => {
    if (places <= 0) return amount.toString();
    const scale = BigInt(10) ** BigInt(places);
    const whole = (amount / scale).toString();
    const fraction = (amount % scale).toString().padStart(places, "0").replace(/0+$/, "");
    return fraction ? whole + "." + fraction : whole;
  };
  const formatValue = raw => {
    const suffix = unit ? " " + unit : "";
    if (format === "hex") {
      const parsed = toBigInt(raw);
      return parsed === null ? String(raw) : parsed.toString() + suffix;
    }
    if (format === "wei") {
      const parsed = toBigInt(raw);
      return parsed === null ? String(raw) : formatUnits(parsed, 18) + suffix;
    }
    if (format === "units") {
      const parsed = toBigInt(raw);
      return parsed === null ? String(raw) : formatUnits(parsed, Number(decimals || 0)) + suffix;
    }
    return String(raw) + suffix;
  };
  const substitute = text => {
    if (!text || !inputLabel) return text;
    return text.split(placeholder).join(field.trim());
  };
  const target = substitute(url);
  const payload = substitute(body);
  const run = async () => {
    setStatus("loading");
    setError(null);
    setResult(null);
    try {
      const response = await fetch(target, {
        method,
        headers: method === "POST" ? {
          "content-type": "application/json"
        } : {
          accept: "application/json"
        },
        body: method === "POST" ? payload : undefined
      });
      const text = await response.text();
      let parsed = null;
      try {
        parsed = JSON.parse(text);
      } catch (err) {
        parsed = null;
      }
      if (!response.ok) {
        setError("HTTP " + response.status + ". " + text.slice(0, 300));
        setStatus("error");
        return;
      }
      if (parsed === null) {
        setError("The response was not JSON: " + text.slice(0, 300));
        setStatus("error");
        return;
      }
      if (parsed.error) {
        setError("JSON-RPC error: " + JSON.stringify(parsed.error));
        setStatus("error");
        return;
      }
      if (Array.isArray(parsed.errors) && parsed.errors.length > 0) {
        setError("GraphQL error: " + (parsed.errors[0].message || "query rejected"));
        setStatus("error");
        return;
      }
      if (parsed.status === "0") {
        setError("API error: " + (parsed.message || "request failed"));
        setStatus("error");
        return;
      }
      setResult(parsed);
      setStatus("done");
    } catch (err) {
      setError("Request failed: " + String(err.message || err) + ". Check your network, or a browser extension blocking the request.");
      setStatus("error");
    }
  };
  const readHeadline = () => {
    if (!path) return null;
    try {
      const raw = getPath(result, path);
      if (raw === undefined) return "not present in the response";
      if (raw === null) return "null";
      if (typeof raw === "object") return JSON.stringify(raw);
      return formatValue(raw);
    } catch (err) {
      return "could not be read: " + String(err.message || err);
    }
  };
  return <div className="not-prose my-4 rounded-xl border border-gray-200 bg-gray-50/70 p-4 dark:border-white/10 dark:bg-white/5">
      <div className="flex flex-wrap items-end gap-3">
        {inputLabel ? <label className="flex min-w-[18rem] grow flex-col gap-1 text-xs font-medium text-gray-600 dark:text-gray-300">
            {inputLabel}
            <input type="text" value={field} spellCheck={false} autoComplete="off" placeholder={inputPlaceholder} onChange={event => setField(event.target.value)} className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 font-mono text-xs text-gray-900 dark:border-white/10 dark:bg-black/30 dark:text-gray-100" />
          </label> : null}
        <button type="button" onClick={run} disabled={status === "loading"} className="rounded-lg bg-[#0066FF] px-4 py-2 text-sm font-medium text-white disabled:opacity-60">
          {status === "loading" ? "Running" : runLabel}
        </button>
      </div>

      <p className="mt-2 break-all font-mono text-[11px] text-gray-500 dark:text-gray-400">
        {method} {target}
      </p>

      <div aria-live="polite">
        {status === "error" ? <p className="mt-3 text-sm text-red-600 dark:text-red-400">{error}</p> : null}
        {status === "done" ? <div className="mt-3">
            {path ? <p className="text-sm text-gray-700 dark:text-gray-200">
                <span className="font-medium">{pathLabel}: </span>
                <span className="font-mono">{readHeadline()}</span>
              </p> : null}
            <pre className="mt-2 max-h-72 overflow-auto rounded-lg bg-white p-3 text-xs leading-relaxed text-gray-800 dark:bg-black/40 dark:text-gray-200">
              {JSON.stringify(result, null, 2)}
            </pre>
          </div> : null}
      </div>
    </div>;
};

<Note>
  All requests on this page target the Whitechain Sepolia explorer at `https://explorer.testnet.whitechain.io` (chain id 1874). The API is public, read-only, and needs no API key. The examples use the real testnet token `0x071c373d58A5290982a0E916D529a27849baE6e0` (USDW, an ERC-20 with 6 decimals). Replace it with the token contract address you want to read. The holder and transfer addresses in the sample responses, such as the example address `0xA439Ad519046CCd7056Ddf74fbaAc99d740Bdf09`, are wallets rather than contracts, so they carry no bytecode.
</Note>

Every request below has a **Run this request** button. It sends that exact request from your browser to Whitechain Sepolia and prints the live response, so you can compare it with the sample response above it. Each button carries its own input field, so editing the token address in one runner does not change the others. The requests are read-only GET and POST calls with no key and no wallet connection, and nothing you enter leaves your browser except the address in the request.

## What you can build

A token dashboard takes one token contract address and reads two Blockscout API surfaces to show:

* Metadata: name, symbol, decimals, total supply, and price when the explorer has one.
* Two counters: current holders (non-zero balances) and lifetime transfers.
* The top holders by balance.
* A paginated transfer feed.

## API surfaces and base URLs

| Surface     | Base URL                                                | Use it for                                                                                                          |
| ----------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| GraphQL API | `https://explorer.testnet.whitechain.io/api/v1/graphql` | The transfer feed and per-address lookups. One POST returns exactly the fields you ask for, with cursor pagination. |
| REST API v2 | `https://explorer.testnet.whitechain.io/api/v2`         | Token metadata, the holders list, and the counters.                                                                 |

The split is deliberate. The Blockscout GraphQL schema has no `token` query, so metadata, holders, and counters come from REST v2. GraphQL owns the transfer feed, where field selection and cursor pagination help most.

## Endpoint map

| Task                                  | Interface   | Request                                      |
| ------------------------------------- | ----------- | -------------------------------------------- |
| Name, symbol, decimals, supply, price | REST API    | `GET /api/v2/tokens/{hash}`                  |
| Holder and transfer counts            | REST API    | `GET /api/v2/tokens/{hash}/counters`         |
| Top holders                           | REST API    | `GET /api/v2/tokens/{hash}/holders`          |
| Transfer feed (paginated)             | GraphQL API | `POST /api/v1/graphql` with `tokenTransfers` |
| Single address lookup                 | GraphQL API | `POST /api/v1/graphql` with `address`        |

## Reading metadata, holders, and counters with REST v2

Send GET requests with an `accept: application/json` header. Responses below are trimmed to the fields a dashboard reads.

### Token metadata

```bash Request theme={null}
curl "https://explorer.testnet.whitechain.io/api/v2/tokens/0x071c373d58A5290982a0E916D529a27849baE6e0" \
  -H "accept: application/json"
```

```json Response (trimmed) theme={null}
{
  "address_hash": "0x071c373d58A5290982a0E916D529a27849baE6e0",
  "name": "USDW",
  "symbol": "USDW",
  "decimals": "6",
  "type": "ERC-20",
  "total_supply": "10000000013605001000",
  "exchange_rate": null
}
```

<ApiRunner method="GET" url="https://explorer.testnet.whitechain.io/api/v2/tokens/{input}" inputLabel="Token contract address" inputValue="0x071c373d58A5290982a0E916D529a27849baE6e0" path="decimals" pathLabel="Decimals" />

| Field           | Meaning                          | How to use it                                                |
| --------------- | -------------------------------- | ------------------------------------------------------------ |
| `decimals`      | Fractional digits for this token | Divide every raw amount by 10^`decimals`. Here that is 10^6. |
| `total_supply`  | Raw total supply                 | Divide by 10^`decimals` for the display supply.              |
| `type`          | Token standard                   | `ERC-20`, `ERC-721`, or `ERC-1155`.                          |
| `exchange_rate` | USD price, or `null`             | Show a price only when it is not `null`.                     |

### Counters

```bash Request theme={null}
curl "https://explorer.testnet.whitechain.io/api/v2/tokens/0x071c373d58A5290982a0E916D529a27849baE6e0/counters" \
  -H "accept: application/json"
```

```json Response theme={null}
{ "token_holders_count": "5", "transfers_count": "31" }
```

<ApiRunner method="GET" url="https://explorer.testnet.whitechain.io/api/v2/tokens/{input}/counters" inputLabel="Token contract address" inputValue="0x071c373d58A5290982a0E916D529a27849baE6e0" path="transfers_count" pathLabel="Lifetime transfers" />

Use `token_holders_count` for the holders card and `transfers_count` for the transfers card. Both are strings; parse them before formatting.

### Top holders

```bash Request theme={null}
curl "https://explorer.testnet.whitechain.io/api/v2/tokens/0x071c373d58A5290982a0E916D529a27849baE6e0/holders" \
  -H "accept: application/json"
```

```json Response (trimmed to two items) theme={null}
{
  "items": [
    {
      "address": { "hash": "0xA439Ad519046CCd7056Ddf74fbaAc99d740Bdf09", "is_contract": false },
      "value": "10000000000000000000"
    },
    {
      "address": { "hash": "0x6e057133CFa4a9Ec70c77aaFe29751460FE16307", "name": "UniswapV3Pool", "is_contract": true },
      "value": "2792871783"
    }
  ],
  "next_page_params": null
}
```

<ApiRunner method="GET" url="https://explorer.testnet.whitechain.io/api/v2/tokens/{input}/holders" inputLabel="Token contract address" inputValue="0x071c373d58A5290982a0E916D529a27849baE6e0" path="items.length" pathLabel="Holders on this page" />

Each item pairs an `address` with a raw `value`. Format `value` against the token `decimals` from the metadata call, and keep full precision so small balances stay visible. Show `address.name` (such as `UniswapV3Pool`) when present, otherwise the raw hash.

## Reading the transfer feed with GraphQL

Send POST requests to `https://explorer.testnet.whitechain.io/api/v1/graphql` with a `content-type: application/json` body holding a `query` and its `variables`. You get back only the fields you request.

### The transfers query

```bash Request theme={null}
curl "https://explorer.testnet.whitechain.io/api/v1/graphql" \
  -H "content-type: application/json" \
  -d '{
    "query": "query Transfers($token: AddressHash!, $first: Int!, $after: String) { tokenTransfers(tokenContractAddressHash: $token, first: $first, after: $after) { edges { node { amount fromAddressHash toAddressHash transactionHash tokenIds } } pageInfo { hasNextPage endCursor } } }",
    "variables": { "token": "0x071c373d58A5290982a0E916D529a27849baE6e0", "first": 8, "after": null }
  }'
```

```json Response (trimmed to one edge) theme={null}
{
  "data": {
    "tokenTransfers": {
      "edges": [
        {
          "node": {
            "amount": "207128221",
            "fromAddressHash": "0x6e057133CFa4a9Ec70c77aaFe29751460FE16307",
            "toAddressHash": "0xE637dc119ADAEb23e72f689183Cf6F60a52773Aa",
            "transactionHash": "0xb97ceb21417fe4a7d94162f99b3f2d8c5f7bc3b5ca54ac0b45fadb15c05c3b57",
            "tokenIds": null
          }
        }
      ],
      "pageInfo": { "hasNextPage": true, "endCursor": "WyIyNjE3MzMwIiwxXQ==" }
    }
  }
}
```

<ApiRunner method="POST" url="https://explorer.testnet.whitechain.io/api/v1/graphql" body="{&#x22;query&#x22;:&#x22;query Transfers($token: AddressHash!, $first: Int!, $after: String) { tokenTransfers(tokenContractAddressHash: $token, first: $first, after: $after) { edges { node { amount fromAddressHash toAddressHash transactionHash tokenIds } } pageInfo { hasNextPage endCursor } } }&#x22;,&#x22;variables&#x22;:{&#x22;token&#x22;:&#x22;{input}&#x22;,&#x22;first&#x22;:8,&#x22;after&#x22;:null}}" inputLabel="Token contract address" inputValue="0x071c373d58A5290982a0E916D529a27849baE6e0" path="data.tokenTransfers.edges.length" pathLabel="Transfers returned" />

| Field                               | Meaning                                 | How to use it                                                                                      |
| ----------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `amount`                            | Raw amount moved                        | Divide by the token `decimals`. Here `207128221` is 207.128221 USDW.                               |
| `fromAddressHash` / `toAddressHash` | Sender and recipient                    | Render both; a mint shows `from` as the zero address `0x0000000000000000000000000000000000000000`. |
| `transactionHash`                   | The transaction the transfer belongs to | Link to `/tx/{hash}` on the explorer.                                                              |
| `tokenIds`                          | NFT ids, or `null`                      | `null` for ERC-20. Set for ERC-721 and ERC-1155.                                                   |

<Note>
  The GraphQL server caps operation complexity at 100, and `tokenTransfers` costs about 11 per item, so request 8 at a time (`first: 8`). Explore the full schema at [the GraphQL API docs](https://explorer.testnet.whitechain.io/api-docs?tab=graphql_api).
</Note>

### Address lookup

Use the `address` query when you need one address's native balance and whether it holds contract code, for example to annotate a holder row.

```bash Request theme={null}
curl "https://explorer.testnet.whitechain.io/api/v1/graphql" \
  -H "content-type: application/json" \
  -d '{
    "query": "query AddressLookup($hash: AddressHash!) { address(hash: $hash) { hash fetchedCoinBalance contractCode } }",
    "variables": { "hash": "0xA439Ad519046CCd7056Ddf74fbaAc99d740Bdf09" }
  }'
```

```json Response theme={null}
{
  "data": {
    "address": {
      "hash": "0xA439Ad519046CCd7056Ddf74fbaAc99d740Bdf09",
      "fetchedCoinBalance": "177504708847852442",
      "contractCode": null
    }
  }
}
```

<ApiRunner method="POST" url="https://explorer.testnet.whitechain.io/api/v1/graphql" body="{&#x22;query&#x22;:&#x22;query AddressLookup($hash: AddressHash!) { address(hash: $hash) { hash fetchedCoinBalance contractCode } }&#x22;,&#x22;variables&#x22;:{&#x22;hash&#x22;:&#x22;{input}&#x22;}}" inputLabel="Address" inputValue="0xA439Ad519046CCd7056Ddf74fbaAc99d740Bdf09" path="data.address.fetchedCoinBalance" pathLabel="Native balance" format="wei" unit="WBT" />

A `null` `contractCode` means the address is a wallet, not a contract. `fetchedCoinBalance` is the native balance in wei; divide by 10^18 for WBT.

## Pagination

The transfer feed pages with an opaque cursor. Read `pageInfo.hasNextPage` and `pageInfo.endCursor` from a response, then send that `endCursor` back as the `after` variable to fetch the next page. Stop when `hasNextPage` is `false`. Do not build or parse the cursor yourself; treat it as opaque.

## Errors

GraphQL always returns HTTP `200`. On failure the body has an `errors` array instead of (or alongside) `data`, so check for `errors` before reading `data`. An operation above the complexity cap returns such an error, which is why the page requests 8 transfers at a time. REST v2 returns HTTP `422` with an `errors` array for a malformed token address or parameter, where each entry carries a `title`, a `source.pointer` naming the bad field, and a `detail` string.

## Load sequence

For a full dashboard, issue the three REST reads (metadata, counters, holders) and the first GraphQL transfers page in parallel; none depend on another. Then fetch further transfer pages on demand with the cursor. Refetch everything when the user enters a different token address.

## Related

* [Block explorer](/build/block-explorer/overview)
* [Wallet indexing example](/build/block-explorer/indexer-wallet)
* [Gas and network tracker example](/build/block-explorer/indexer-gas-tracker)
* [Blockscout API reference](https://explorer.testnet.whitechain.io/api-docs)
