# 用 Data API 做钱包资产页：余额、转账与代币信息

> 原文地址: https://docs.blockvectra.com/zh/guides/wallet-assets/

## 钱包资产页需要哪三类数据 [#钱包资产页需要哪三类数据]

一个钱包资产页通常要回答三个问题：这个地址现在持有什么、它发生过哪些代币转账、每个代币叫什么、精度是多少。Data API 为这三类问题分别提供接口：

* **余额**：`GET /{chain}/addresses/{address}/balances`，返回该地址的非零 ERC-20 余额，按 `token` 地址升序排列；在可获取时附带代币的 `symbol` 与 `decimals`。没有余额的地址返回 `200`，`data` 为空数组。
* **转账**：`GET /{chain}/addresses/{address}/transfers`，返回该地址在必填区块窗口内的代币转账，按 `(block_number, log_index)` 降序排列。
* **代币元数据**：`GET /{chain}/tokens/{token}` 按合约地址读取单个代币的名称、符号、精度与总供应量；`POST /{chain}/tokens:batch` 一次最多为 100 个地址批量读取同样的元数据。

三个接口都以 `https://dev-api.blockvectra.network/v1/data` 为基地址，用 `x-api-key` 请求头鉴权，链名示例用 `robinhood_mainnet`。它们分别属于 `balances`、`transfers` 与 `token_metadata` 三个能力；某条链是否提供某个能力，以[支持的链](/zh/chains/)页面为准。链上没有该能力时，接口返回 `422 no_coverage`。

## 请求一：地址余额 [#请求一地址余额]

这个接口要求的参数较少，适合作为页面首屏的第一个请求：

* `{chain}`（路径参数，必填）：链标识，即 `GET /chains` 中某个条目的 `chain` 值（例如 `robinhood_mainnet`）；匹配精确且大小写敏感，别名与数字链 ID 不被接受。
* `{address}`（路径参数，必填）：20 字节地址，`0x` 前缀可选、大小写均可。
* `limit`（查询参数，可选）：每页条数。缺省为 50；大于 500 会收敛为 500；传 `0` 或非整数返回 `400 bad_request`。
* `cursor`（查询参数，可选）：上一页响应中 `next_cursor` 的值，原样传回即可取下一页。游标只对签发它的链、端点和查询参数有效，用在其他链或参数上会返回 `400 bad_request`。

它同样使用游标分页：`next_cursor` 只在确实还有下一页时才出现，最后一页该字段整个不存在，绝不会是 `null`。

<Tabs groupId="code-lang" items="['cURL', 'TypeScript', 'Python']">
  <Tab value="cURL">
    ```bash
    export BLOCKVECTRA_API_KEY=rgw_your_api_key

    curl -s "https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/addresses/0x1111111111111111111111111111111111111111/balances?limit=50" \
      -H "x-api-key: $BLOCKVECTRA_API_KEY"
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts
    const address = "0x1111111111111111111111111111111111111111";
    const url = new URL(
      `https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/addresses/${address}/balances`,
    );
    url.searchParams.set("limit", "50");

    const res = await fetch(url, {
      headers: { "x-api-key": process.env.BLOCKVECTRA_API_KEY! },
    });
    const balanceBody = await res.json();
    console.log(balanceBody.data, balanceBody.meta);

    // npx tsx example.mts
    ```
  </Tab>

  <Tab value="Python">
    ```python
    import os
    import requests

    address = "0x1111111111111111111111111111111111111111"
    res = requests.get(
        f"https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/addresses/{address}/balances",
        params={"limit": 50},
        headers={"x-api-key": os.environ["BLOCKVECTRA_API_KEY"]},
    )
    res.raise_for_status()
    balance_body = res.json()
    print(balance_body["data"], balance_body["meta"])
    ```
  </Tab>
</Tabs>

响应信封为 `AddressBalanceListEnvelope`，包含 `data` 与 `meta`。`data` 的每一项是一个 `AddressBalance`：

| 字段         | 类型                 | 说明                                                         |
| ---------- | ------------------ | ---------------------------------------------------------- |
| `token`    | `string`（地址）       | 代币合约地址，规范形式为 `0x` 加 40 位小写十六进制字符。                          |
| `balance`  | `string`（十进制）      | 原始整数余额，可能超过 `2^53`，以纯十进制字符串返回，绝不使用 JSON number、科学计数法或十六进制。 |
| `symbol`   | `string` 或 `null`  | 代币符号；不可用时为 `null`。                                         |
| `decimals` | `integer` 或 `null` | 代币精度，取值 `0`–`255`；不可用时为 `null`。                            |

## 请求二：地址转账 [#请求二地址转账]

转账接口要求一个显式的区块窗口：`from_block` 与 `to_block` 都必填，且必须满足 `from_block <= to_block`。它比余额接口多几个参数：

* `standard`（查询参数，必填）：`erc20` 或 `erc721`。按地址查询不提供 `erc1155`，传它会返回 `422 no_coverage`。
* `direction`（查询参数，可选）：`in`、`out` 或 `any`，默认 `any`，按相对该地址的方向过滤。
* `token`（查询参数，可选）：只返回某个代币合约的转账。
* `clamp`（查询参数，可选）：仅当取值为字面量字符串 `true` 时才生效，其他值都按 `false` 处理。

窗口边界与最终性：显式传入高于 `finalized_block` 的 `to_block` 会返回 `409 finality_exceeded`，除非 `clamp=true` 将其截断到 `finalized_block`；跨度超过 100,000 个区块的窗口返回 `409 window_too_large`，除非 `clamp=true` 从较老的一端截断（抬高 `from_block`、`to_block` 不变）。若 `from_block` 本身已经越过水位线，即使 `clamp=true` 也仍是硬 `409`。发生截断或窗口部分覆盖时，响应 `meta.coverage` 为 `"partial"`，否则为 `"full"`。

转账记录里，ERC-20 项除公共字段外还有 `amount`；ERC-721 项有 `token_id`。两类记录都包含 `token`、`standard`、`from`、`to`、`block_number`、`block_timestamp`、`tx_hash`、`tx_index`、`log_index`。

<Tabs groupId="code-lang" items="['cURL', 'TypeScript', 'Python']">
  <Tab value="cURL">
    ```bash
    export BLOCKVECTRA_API_KEY=rgw_your_api_key

    # 先用任意一次响应读出 meta.finalized_block，把它作为窗口上界。
    # clamp=true 会把过宽窗口或高于 finalized_block 的 to_block 截断，而不是返回 409。
    curl -s "https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/addresses/0x1111111111111111111111111111111111111111/transfers?standard=erc20&from_block=0&to_block=$FINALIZED_BLOCK&direction=any&clamp=true" \
      -H "x-api-key: $BLOCKVECTRA_API_KEY"
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts
    const address = "0x1111111111111111111111111111111111111111";

    // 1) 从任意一次响应的 meta 里读出最终性水位线。
    const head = await fetch(
      `https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/addresses/${address}/balances`,
      { headers: { "x-api-key": process.env.BLOCKVECTRA_API_KEY! } },
    ).then((r) => r.json());

    // 2) 用 finalized_block 作为转账窗口的上界。
    const url = new URL(
      `https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/addresses/${address}/transfers`,
    );
    url.searchParams.set("standard", "erc20");
    url.searchParams.set("from_block", "0");
    url.searchParams.set("to_block", String(head.meta.finalized_block));
    url.searchParams.set("direction", "any");
    url.searchParams.set("clamp", "true");

    const res = await fetch(url, {
      headers: { "x-api-key": process.env.BLOCKVECTRA_API_KEY! },
    });
    const body = await res.json();
    console.log(body.data, body.meta);

    // npx tsx example.mts
    ```
  </Tab>

  <Tab value="Python">
    ```python
    import os
    import requests

    address = "0x1111111111111111111111111111111111111111"
    headers = {"x-api-key": os.environ["BLOCKVECTRA_API_KEY"]}

    # 1) 从任意一次响应的 meta 里读出最终性水位线。
    head = requests.get(
        f"https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/addresses/{address}/balances",
        headers=headers,
    ).json()

    # 2) 用 finalized_block 作为转账窗口的上界。
    res = requests.get(
        f"https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/addresses/{address}/transfers",
        params={
            "standard": "erc20",
            "from_block": 0,
            "to_block": head["meta"]["finalized_block"],
            "direction": "any",
            "clamp": "true",
        },
        headers=headers,
    )
    res.raise_for_status()
    body = res.json()
    print(body["data"], body["meta"])
    ```
  </Tab>
</Tabs>

## 分页拉全转账 [#分页拉全转账]

按地址转账接口的 `next_cursor` 是「乐观」的：仅当这一页恰好返回 `limit` 条记录时才出现，所以某一页可能带着 `next_cursor` 却已经是最后一页。不要用「本页是否为空」判断结束，正确做法是循环跟随 `next_cursor`，直到该字段不存在。

* `limit` 缺省 50，最大 500。
* 传回 `cursor` 时保持原值不变；游标只对签发它的链、端点和查询参数有效，换一条链或改动参数都要重新从第一页开始。
* 游标本身携带区块位置：如果翻页期间链的首个已索引区块向前移动，下一页会变成 `partial`（低于该位置的记录不再返回）或 `422 no_coverage`。

下面的代码把窗口内的转账全部取回：

<Tabs groupId="code-lang" items="['TypeScript', 'Python']">
  <Tab value="TypeScript">
    ```ts
    const address = "0x1111111111111111111111111111111111111111";
    const finalizedBlock = head.meta.finalized_block;
    const transfers: unknown[] = [];
    let cursor: string | undefined;

    do {
      const url = new URL(
        `https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/addresses/${address}/transfers`,
      );
      url.searchParams.set("standard", "erc20");
      url.searchParams.set("from_block", "0");
      url.searchParams.set("to_block", String(finalizedBlock));
      url.searchParams.set("limit", "500");
      // 窗口超过规格上限时返回 409 window_too_large，clamp 会从较老一端截断
      url.searchParams.set("clamp", "true");
      if (cursor) url.searchParams.set("cursor", cursor);

      const res = await fetch(url, {
        headers: { "x-api-key": process.env.BLOCKVECTRA_API_KEY! },
      });
      const page = await res.json();
      transfers.push(...page.data);
      cursor = page.next_cursor; // 最后一页不存在该字段
    } while (cursor);
    ```
  </Tab>

  <Tab value="Python">
    ```python
    import os
    import requests

    address = "0x1111111111111111111111111111111111111111"
    finalized_block = head["meta"]["finalized_block"]
    transfers = []
    cursor = None

    while True:
        params = {
            "standard": "erc20",
            "from_block": 0,
            "to_block": finalized_block,
            "limit": 500,
            # 窗口超过规格上限时返回 409 window_too_large，clamp 会从较老一端截断
            "clamp": "true",
        }
        if cursor:
            params["cursor"] = cursor
        res = requests.get(
            f"https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/addresses/{address}/transfers",
            params=params,
            headers={"x-api-key": os.environ["BLOCKVECTRA_API_KEY"]},
        )
        res.raise_for_status()
        page = res.json()
        transfers.extend(page["data"])
        cursor = page.get("next_cursor")  # 最后一页不存在该字段
        if not cursor:
            break
    ```
  </Tab>
</Tabs>

## 请求三：代币元数据与 tokens:batch [#请求三代币元数据与-tokensbatch]

单个代币用 `GET /{chain}/tokens/{token}` 读取，路径只接受 `{chain}` 与 `{token}` 两个参数，没有分页。响应信封为 `TokenEnvelope`，`data` 是一个 `Token`：

| 字段                    | 类型                 | 说明                                                                                                                             |
| --------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| `address`             | `string`（地址）       | 代币合约地址。                                                                                                                        |
| `standard`            | `string`           | `erc20`、`erc721` 或 `unknown`。                                                                                                  |
| `name`                | `string` 或 `null`  | 代币名称；不可用时为 `null`。                                                                                                             |
| `symbol`              | `string` 或 `null`  | 代币符号；不可用时为 `null`。                                                                                                             |
| `decimals`            | `integer` 或 `null` | 代币精度，取值 `0`–`255`；不可用时为 `null`。                                                                                                |
| `total_supply`        | `string` 或 `null`  | 原始总供应量，接口不会套用 `decimals` 缩放；不可用时为 `null`。                                                                                      |
| `first_seen_block`    | `integer`（int64）   | 首次出现该代币的区块高度。                                                                                                                  |
| `metadata_updated_at` | `string`（时间戳）      | 元数据最近一次更新的 UTC 时间。                                                                                                             |
| `metadata_block`      | `integer`（int64）   | 读取该代币元数据时的区块高度。                                                                                                                |
| `metadata_status`     | `string`           | `ok`、`partial` 或 `unavailable`。                                                                                                |
| `metadata_issues`     | `object`           | 逐字段的问题记录，键为 `name`、`symbol`、`decimals`、`total_supply`，取值可能为 `reverted`、`no_data`、`invalid_encoding`、`temporarily_unavailable`。 |

`{token}` 不是合法的 20 字节地址时返回 `400 bad_request`；不是已知代币时返回 `404 not_found`；`{chain}` 未知时返回 `404 unknown_chain`。

余额接口会在可用时直接给出 `symbol` 与 `decimals`，但这两者都可能是 `null`。要把钱包里每个代币的名称与精度补齐，用 `POST /{chain}/tokens:batch`：

* 请求体为 `{"addresses": [...]}`，一次最多 100 个地址；超过 100 个，或某个条目不是合法的 20 字节地址，都会返回 `400 bad_request`（遇到第一个非法地址即失败）。
* 查不到的地址不会触发错误，而是出现在 `data.missing` 数组里；`data.tokens` 只包含成功找到元数据的代币。
* 请求中重复的地址会在 `tokens` 与 `missing` 里各自去重，并保持首次出现的请求顺序。

<Tabs groupId="code-lang" items="['cURL', 'TypeScript', 'Python']">
  <Tab value="cURL">
    ```bash
    export BLOCKVECTRA_API_KEY=rgw_your_api_key

    # 单个代币
    curl -s "https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/tokens/0x1111111111111111111111111111111111111111" \
      -H "x-api-key: $BLOCKVECTRA_API_KEY"

    # 批量：一次最多 100 个地址
    curl -s -X POST "https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/tokens:batch" \
      -H "x-api-key: $BLOCKVECTRA_API_KEY" \
      -H 'Content-Type: application/json' \
      -d '{"addresses":["0x1111111111111111111111111111111111111111","0x2222222222222222222222222222222222222222"]}'
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts
    // 单个代币
    const single = await fetch(
      "https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/tokens/0x1111111111111111111111111111111111111111",
      { headers: { "x-api-key": process.env.BLOCKVECTRA_API_KEY! } },
    ).then((r) => r.json());
    console.log(single.data);

    // 批量：按 100 个地址分组，把余额里的代币补齐
    const BATCH_SIZE = 100;
    const addresses = balanceBody.data.map((item: { token: string }) => item.token);
    const tokens = new Map<string, unknown>();
    const missing: string[] = [];

    for (let i = 0; i < addresses.length; i += BATCH_SIZE) {
      const res = await fetch("https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/tokens:batch", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "x-api-key": process.env.BLOCKVECTRA_API_KEY!,
        },
        body: JSON.stringify({ addresses: addresses.slice(i, i + BATCH_SIZE) }),
      });
      const body = await res.json();
      for (const token of body.data.tokens) tokens.set(token.address, token);
      missing.push(...body.data.missing);
    }

    // npx tsx example.mts
    ```
  </Tab>

  <Tab value="Python">
    ```python
    import os
    import requests

    headers = {"x-api-key": os.environ["BLOCKVECTRA_API_KEY"]}

    # 单个代币
    single = requests.get(
        "https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/tokens/0x1111111111111111111111111111111111111111",
        headers=headers,
    ).json()
    print(single["data"])

    # 批量：按 100 个地址分组，把余额里的代币补齐
    BATCH_SIZE = 100
    addresses = [item["token"] for item in balance_body["data"]]
    tokens = {}
    missing = []

    for i in range(0, len(addresses), BATCH_SIZE):
        res = requests.post(
            "https://dev-api.blockvectra.network/v1/data/robinhood_mainnet/tokens:batch",
            json={"addresses": addresses[i : i + BATCH_SIZE]},
            headers={**headers, "Content-Type": "application/json"},
        )
        res.raise_for_status()
        body = res.json()
        for token in body["data"]["tokens"]:
            tokens[token["address"]] = token
        missing.extend(body["data"]["missing"])
    ```
  </Tab>
</Tabs>

## 金额按精度换算 [#金额按精度换算]

余额字段 `balance` 与 ERC-20 转账字段 `amount` 都是十进制字符串表示的原始整数（`UInt256String`）；代币的 `total_supply` 规格也明确注明它是原始链上整数、不会套用 `decimals` 缩放。要显示成人可读的数量，需要用对应代币的 `decimals` 做除法。

* `decimals` 来自余额条目本身的 `symbol`/`decimals`，或来自 `GET /{chain}/tokens/{token}` 与 `POST /{chain}/tokens:batch` 的元数据；它可能是 `null`。
* 这些值可能超过 `2^53`，不要用 JSON number 直接运算：TypeScript 用 `BigInt`，Python 用 `Decimal`，按十进制字符串原样解析，避免精度丢失。

<Tabs groupId="code-lang" items="['TypeScript', 'Python']">
  <Tab value="TypeScript">
    ```ts
    function toDisplayAmount(raw: string, decimals: number | null): string {
      if (decimals === null) return raw; // 没有精度信息时退回原始整数
      const value = BigInt(raw);
      const base = 10n ** BigInt(decimals);
      const whole = value / base;
      const fraction = (value % base)
        .toString()
        .padStart(decimals, "0")
        .replace(/0+$/, "");
      return fraction ? `${whole}.${fraction}` : whole.toString();
    }

    // balance.balance 是原始十进制字符串，decimals 取自同一条余额或 tokens:batch。
    const display = toDisplayAmount(balance.balance, balance.decimals);
    ```
  </Tab>

  <Tab value="Python">
    ```python
    from decimal import Decimal


    def to_display_amount(raw: str, decimals: int | None) -> str:
        if decimals is None:
            return raw  # 没有精度信息时退回原始整数
        value = Decimal(raw)  # 精确解析十进制字符串
        return format(value.scaleb(-decimals).normalize(), "f")


    # balance["balance"] 是原始十进制字符串，decimals 取自同一条余额或 tokens:batch。
    display = to_display_amount(balance["balance"], balance["decimals"])
    ```
  </Tab>
</Tabs>

## 数据新鲜度 [#数据新鲜度]

每个链维度的成功响应都带有 `meta`：

* `as_of_block`：计算该响应最终性水位时所依据的已索引头部区块高度。
* `finalized_block`：按区块读取的端点会提供的最高区块高度；它按链以固定区块数落后于 `as_of_block`，是重组安全水位线，而不是共识最终性信号。
* `coverage`：`"full"` 或 `"partial"`。按地址转账等接口在被 `clamp` 收窄窗口，或窗口起点早于该链首个已索引区块时返回 `"partial"`。
* `refreshed_at`：这份响应背后的数据最近一次更新的 UTC 时间。
* 此外还有 `chain`、`chain_slug` 与 `chain_external_id`。

余额与代币元数据这类没有天然区块作用域的快照/元数据接口同样会返回 `as_of_block` 与 `finalized_block`，但不会拿请求去和它们比较。转账接口只提供不高于 `finalized_block` 的数据。

一个常见做法：先用任意一次响应读出 `meta.finalized_block`，把它作为转账窗口的 `to_block`，就不必手写区块高度。

## 一次页面加载的 CU 估算 [#一次页面加载的-cu-估算]

每个方法都按其 CU 权重计费，权重在构建时从平台计划接口读取，下文不写死具体数字：

<WalletAssetsUsageEstimate lang="zh" />

计费判定与不计费的错误响应见[计费规则](/zh/guides/billing-rules/)。如果你需要的不是已索引的历史转账，而是最新区块里尚未跨过最终性水位线的日志，请先阅读[节点近况与已索引历史](/zh/guides/logs-vs-transfers/)，再决定是否改用 `eth_getLogs`。

## 下一步 [#下一步]

* [浏览数据集目录](https://blockvectra.com/zh/data/)，查看 BlockVectra 索引的全部数据集。
* [查看免费额度与定价](https://blockvectra.com/zh/pricing/#free)，确认账户可用的方案。
* [登录控制台](https://console.blockvectra.com/zh/login/?next=%2Fzh%2Fkeys%2F)创建 API key。
