# 錢包代幣餘額 API：ERC-20 資產與轉帳歷史

> Source: https://docs.blockvectra.com/zh-hant/guides/wallet-assets/

用區塊鏈錢包資料 API 建置錢包資產頁：使用[代幣餘額 API](https://blockvectra.com/zh-hant/data/balances/) 取得非零 ERC-20 持倉，使用[代幣轉帳 API](https://blockvectra.com/zh-hant/data/transfers/) 取得錢包歷史。開發者與 AI Agent 使用同一套認證請求。查詢前請讀取 [GET /v1/status](https://api.blockvectra.com/v1/data)，並檢查所選鏈的 `data_features` 與 `data_status`；餘額涵蓋範圍因鏈而異。請求參數與回應結構定義見 [Data API 參考](https://docs.blockvectra.com/en/api/data/)。

## 本指南協助你完成的任務

* [讀取錢包代幣餘額](#request-1-address-balances)：使用 key 並分頁取得非零 ERC-20 持倉。
* [讀取錢包轉帳歷史](#request-2-address-transfers)：在固定區塊視窗內，為所選地址沿游標取得記錄。
* [補齊代幣中繼資料](#request-3-token-metadata-and-tokensbatch)：為原始整數餘額補上名稱與符號，並保留缺失欄位。

## 錢包資產頁需要的三種資料

錢包資產頁可以顯示地址的 ERC-20 代幣餘額、代幣轉帳歷史與代幣中繼資料。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}` 依合約地址讀取單一代幣的名稱、符號、decimals 與總供應量；`POST /{chain}/tokens:batch` 在一個請求中讀取最多 100 個地址的相同中繼資料。

三者都使用 `https://api.blockvectra.com/v1/data` 作為基底 URL 與 `x-api-key` 請求標頭，並以 `robinhood_mainnet` 為範例鏈。它們分別屬於 `balances`、`transfers` 與 `token_metadata` 能力；哪些鏈提供各項能力，請參閱[支援的鏈](https://docs.blockvectra.com/zh-hant/chains/)頁面。在沒有該能力的鏈上，端點會回傳 `422 no_coverage`。

## 請求 1：地址餘額

此端點需要的參數較少，適合作為頁面的第一個請求：

* `{chain}`（路徑參數，必填）：鏈識別碼，即 `GET /chains` 中某個條目的 `chain` 值（例如 `robinhood_mainnet`）。比對為精確且區分大小寫；不接受別名與數字 chain ID。
* `{address}`（路徑參數，必填）：20 位元組地址；`0x` 前綴為選填，大小寫皆接受。
* `limit`（查詢參數，選填）：頁面大小。預設為 50；大於 500 的值會截斷為 500；`0` 或非整數會回傳 `400 bad_request`。
* `cursor`（查詢參數，選填）：上一個回應的 `next_cursor`，原樣傳回以取得下一頁。游標僅對簽發它的鏈、端點與查詢參數有效；在其他地方重用會回傳 `400 bad_request`。

它採用鍵集分頁：只有在還有下一頁時才會出現 `next_cursor`。在最後一頁，該鍵會完全不存在，絕不會是 `null`。

**cURL**

```bash
export BLOCKVECTRA_API_KEY=rgw_your_api_key

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


  **TypeScript**

```ts
const address = "0x1111111111111111111111111111111111111111";
const url = new URL(
  `https://api.blockvectra.com/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
```


  **Python**

```python
import os
import requests

address = "0x1111111111111111111111111111111111111111"
res = requests.get(
    f"https://api.blockvectra.com/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"])
```


回應結構是 `AddressBalanceListEnvelope`，包含 `data` 與 `meta`。`data` 的每個項目是 `AddressBalance`：

| 欄位         | 型別                  | 說明                                                   |
| ---------- | ------------------- | ---------------------------------------------------- |
| `token`    | `string` (address)  | 代幣合約地址；標準形式為 `0x` 加上 40 個小寫十六進位字元。                   |
| `balance`  | `string` (decimal)  | 原始整數餘額，可能超過 `2^53`，以純十進位字串回傳——絕不是 JSON 數字、科學記號或十六進位。 |
| `symbol`   | `string` or `null`  | 代幣符號，或在無法取得時為 `null`。                                |
| `decimals` | `integer` or `null` | 代幣 decimals，`0`–`255`，或在無法取得時為 `null`。               |

## 請求 2：地址轉帳

轉帳端點需要明確的區塊視窗：`from_block` 與 `to_block` 皆為必填，且必須滿足 `from_block <= to_block`。它需要更多參數：

* `standard`（查詢參數，必填）：`erc20` 或 `erc721`。以地址為範圍的查詢不涵蓋 `erc1155`；傳入它會回傳 `422 no_coverage`。
* `direction`（查詢參數，選填）：`in`、`out` 或 `any`；預設為 `any`，並依相對於該地址的方向篩選。
* `token`（查詢參數，選填）：將結果限制在單一代幣合約。
* `clamp`（查詢參數，選填）：只有字串 `true` 會啟用它；任何其他值都視為 `false`。

視窗邊界與最終性：明確指定的 `to_block` 若高於 `as_of_block`，會回傳 `409 not_indexed_yet`，除非 `clamp=true` 將它截斷至 `as_of_block`；比鏈限制更寬的視窗（`GET /chains` 中的 `limits.max_window_blocks`）會回傳 `409 window_too_large`，除非 `clamp=true` 從較舊的一端截斷（提高 `from_block` 並保持 `to_block` 不變）。如果 `from_block` 本身已超過 `as_of_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`。

**cURL**

```bash
export BLOCKVECTRA_API_KEY=rgw_your_api_key

# 1) Read as_of_block from the balances response.
AS_OF_BLOCK=$(curl -s "https://api.blockvectra.com/v1/data/robinhood_mainnet/addresses/0x1111111111111111111111111111111111111111/balances" \
  -H "x-api-key: $BLOCKVECTRA_API_KEY" | grep -o '"as_of_block":[0-9]*' | cut -d: -f2)

# 2) clamp=true truncates a too-wide window, or a to_block above as_of_block, instead of returning 409.
curl -s "https://api.blockvectra.com/v1/data/robinhood_mainnet/addresses/0x1111111111111111111111111111111111111111/transfers?standard=erc20&from_block=0&to_block=$AS_OF_BLOCK&direction=any&clamp=true" \
  -H "x-api-key: $BLOCKVECTRA_API_KEY"
```


  **TypeScript**

```ts
const address = "0x1111111111111111111111111111111111111111";

// 1) Read as_of_block from any previous response's meta.
const head = await fetch(
  `https://api.blockvectra.com/v1/data/robinhood_mainnet/addresses/${address}/balances`,
  { headers: { "x-api-key": process.env.BLOCKVECTRA_API_KEY! } },
).then((r) => r.json());

// 2) Use as_of_block as the transfer window's upper bound.
const url = new URL(
  `https://api.blockvectra.com/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.as_of_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
```


  **Python**

```python
import os
import requests

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

# 1) Read as_of_block from any previous response's meta.
head = requests.get(
    f"https://api.blockvectra.com/v1/data/robinhood_mainnet/addresses/{address}/balances",
    headers=headers,
).json()

# 2) Use as_of_block as the transfer window's upper bound.
res = requests.get(
    f"https://api.blockvectra.com/v1/data/robinhood_mainnet/addresses/{address}/transfers",
    params={
        "standard": "erc20",
        "from_block": 0,
        "to_block": head["meta"]["as_of_block"],
        "direction": "any",
        "clamp": "true",
    },
    headers=headers,
)
res.raise_for_status()
body = res.json()
print(body["data"], body["meta"])
```


## 翻遍所有轉帳

地址轉帳端點的 `next_cursor` 是樂觀的：只有在該頁恰好回傳 `limit` 列時才會出現，因此某一頁可能帶有 `next_cursor`，但實際上仍是最後一頁。不要在頁面為空時停止；沿著 `next_cursor` 前進，直到該鍵不存在。

以下程式碼會取得視窗內的每一筆轉帳：

**TypeScript**

```ts
const address = "0x1111111111111111111111111111111111111111";
const head = await fetch(
  `https://api.blockvectra.com/v1/data/robinhood_mainnet/addresses/${address}/balances`,
  { headers: { "x-api-key": process.env.BLOCKVECTRA_API_KEY! } },
).then((r) => r.json());
const asOfBlock = head.meta.as_of_block;
const transfers: unknown[] = [];
let cursor: string | undefined;

do {
  const url = new URL(
    `https://api.blockvectra.com/v1/data/robinhood_mainnet/addresses/${address}/transfers`,
  );
  url.searchParams.set("standard", "erc20");
  url.searchParams.set("from_block", "0");
  url.searchParams.set("to_block", String(asOfBlock));
  url.searchParams.set("limit", "500");
  // clamp truncates from the older end
  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; // absent on the last page
} while (cursor);
```


  **Python**

```python
import os
import requests

address = "0x1111111111111111111111111111111111111111"
head = requests.get(
    f"https://api.blockvectra.com/v1/data/robinhood_mainnet/addresses/{address}/balances",
    headers={"x-api-key": os.environ["BLOCKVECTRA_API_KEY"]},
).json()
as_of_block = head["meta"]["as_of_block"]
transfers = []
cursor = None

while True:
    params = {
        "standard": "erc20",
        "from_block": 0,
        "to_block": as_of_block,
        "limit": 500,
        # clamp truncates from the older end
        "clamp": "true",
    }
    if cursor:
        params["cursor"] = cursor
    res = requests.get(
        f"https://api.blockvectra.com/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")  # absent on the last page
    if not cursor:
        break
```


## 請求 3：代幣中繼資料與 tokens:batch

使用 `GET /{chain}/tokens/{token}` 讀取單一代幣；路徑只接受 `{chain}` 與 `{token}`，沒有分頁。回應結構是 `TokenEnvelope`，`data` 是 `Token`：

| 欄位                    | 型別                   | 說明                                                                                                                             |
| --------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `address`             | `string` (address)   | 代幣合約地址。                                                                                                                        |
| `standard`            | `string`             | `erc20`、`erc721` 或 `unknown`。                                                                                                  |
| `name`                | `string` or `null`   | 代幣名稱，或在無法取得時為 `null`。                                                                                                          |
| `symbol`              | `string` or `null`   | 代幣符號，或在無法取得時為 `null`。                                                                                                          |
| `decimals`            | `integer` or `null`  | 代幣 decimals，`0`–`255`，或在無法取得時為 `null`。                                                                                         |
| `total_supply`        | `string` or `null`   | 原始總供應量；API 不套用 `decimals` 換算。無法取得時為 `null`。                                                                                    |
| `first_seen_block`    | `integer` (int64)    | 首次看到該代幣的區塊高度。                                                                                                                  |
| `metadata_updated_at` | `string` (timestamp) | 中繼資料最後更新的 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`。 |

不是有效 20 位元組地址的 `{token}` 會回傳 `400 bad_request`；未知的 `{token}` 回傳 `404 not_found`；未知的 `{chain}` 回傳 `404 unknown_chain`。

餘額端點已包含可用時的 `symbol` 與 `decimals`，但兩者都可能是 `null`。若要補齊錢包中每個代幣的名稱與 decimals，請使用 `POST /{chain}/tokens:batch`：

* 請求主體為 `{"addresses": [...]}`，每個請求最多 100 個地址；超過 100 個項目，或有項目不是有效的 20 位元組地址，會回傳 `400 bad_request`（它會在走到第一個無效地址時失敗）。
* 找不到的地址不會觸發錯誤；它們會列在 `data.missing` 中，而 `data.tokens` 只包含找到中繼資料的代幣。
* 重複的地址會在 `tokens` 與 `missing` 中去重，各自依首次出現的請求順序排列。

**cURL**

```bash
export BLOCKVECTRA_API_KEY=rgw_your_api_key

# Single token
curl -s "https://api.blockvectra.com/v1/data/robinhood_mainnet/tokens/0x1111111111111111111111111111111111111111" \
  -H "x-api-key: $BLOCKVECTRA_API_KEY"

# Batch: up to 100 addresses per request
curl -s -X POST "https://api.blockvectra.com/v1/data/robinhood_mainnet/tokens:batch" \
  -H "x-api-key: $BLOCKVECTRA_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"addresses":["0x1111111111111111111111111111111111111111","0x2222222222222222222222222222222222222222"]}'
```


  **TypeScript**

```ts
// Single token
const single = await fetch(
  "https://api.blockvectra.com/v1/data/robinhood_mainnet/tokens/0x1111111111111111111111111111111111111111",
  { headers: { "x-api-key": process.env.BLOCKVECTRA_API_KEY! } },
).then((r) => r.json());
console.log(single.data);

// Batch: group by 100 addresses to enrich the tokens from the balances response
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://api.blockvectra.com/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
```


  **Python**

```python
import os
import requests

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

# Single token
single = requests.get(
    "https://api.blockvectra.com/v1/data/robinhood_mainnet/tokens/0x1111111111111111111111111111111111111111",
    headers=headers,
).json()
print(single["data"])

# Batch: group by 100 addresses to enrich the tokens from the balances response
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://api.blockvectra.com/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"])
```


## 依 decimals 換算金額

餘額欄位 `balance` 與 ERC-20 轉帳欄位 `amount` 是以十進位字串呈現的原始整數（`UInt256String`）；代幣的 `total_supply` 也是未套用 `decimals` 換算的原始鏈上整數。若要顯示人類可讀的數量，請除以該代幣的 `decimals`。

* `decimals` 來自餘額項目本身的 `symbol`/`decimals`，或來自 `GET /{chain}/tokens/{token}` 與 `POST /{chain}/tokens:batch`；它可以是 `null`。
* 這些值可能超過 `2^53`，因此不要用 JSON 數字進行運算：在 TypeScript 中使用 `BigInt`，在 Python 中使用 `Decimal`，並原樣解析十進位字串以避免精度損失。

**TypeScript**

```ts
function toDisplayAmount(raw: string, decimals: number | null): string {
  if (decimals === null) return raw; // no decimals metadata: keep the raw integer
  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 is a raw decimal string; decimals comes from the same item or tokens:batch.
const display = toDisplayAmount(balance.balance, balance.decimals);
```


  **Python**

```python
from decimal import Decimal


def to_display_amount(raw: str, decimals: int | None) -> str:
    if decimals is None:
        return raw  # no decimals metadata: keep the raw integer
    value = Decimal(raw)  # parse the decimal string exactly
    return format(value.scaleb(-decimals).normalize(), "f")


# balance["balance"] is a raw decimal string; decimals comes from the same item or tokens:batch.
display = to_display_amount(balance["balance"], balance["decimals"])
```


## 資料新鮮度

每個以鏈為範圍的成功回應都帶有 `meta`：

* `as_of_block`：該鏈最新完整寫入的區塊。以區塊為範圍的端點提供到此高度的資料。
* `safe_block`：標示節點共識 `safe` 區塊標籤的標記（未知時為 `null`）。絕不會低於 `finalized_block`，且不會截斷、拒絕或延遲回應。
* `finalized_block`：標示節點共識 `finalized` 區塊標籤的標記（未知時為 `null`）。它不會截斷、拒絕或延遲回應；由用戶端依該標記決定所需的安全性（例如確認狀態）。
* `coverage`：`"full"` 或 `"partial"`。地址轉帳與類似端點在 `clamp` 縮小所服務的視窗，或視窗起點早於該鏈第一個已索引區塊時，回報 `"partial"`。
* `refreshed_at`：回應背後資料的最後更新時間（UTC）。可能是 `null`：`null` 表示資料的更新時間未知，應視為過時；以區塊為基礎的端點一律回傳值。
* 它也會重複 `chain`、`chain_slug` 與 `chain_external_id`。

常見模式：從任何第一個回應讀取 `meta.as_of_block`，以讀到最新的已索引區塊；如果你想顯示已確認狀態，請檢查 `meta.safe_block` / `meta.finalized_block`。

## 單次頁面載入的 CU 估算

每個方法都依其 CU 權重計費，權重從平台方案 API 讀取：

**單次呼叫的 CU 權重**

| 方法 | 單次呼叫 CU |
| --- | --- |
| `data.address_balances` | 25 |
| `data.address_transfers` | 25 |
| `data.tokens_batch` | 10 |

**一次頁面載入（估算）**

1 次餘額請求 + 3 頁轉帳請求 + 1 次 `tokens:batch` 請求，共 5 次呼叫，合計約 110 CU。實際用量取決於翻頁次數與代幣數量。

計費決策與不計費的錯誤回應，請參閱[計費規則](https://docs.blockvectra.com/en/guides/billing-rules/)。如果你需要的不是已索引的轉帳歷史，而是最近區塊的日誌，請先閱讀[最新節點資料與已索引歷史](https://docs.blockvectra.com/zh-hant/guides/logs-vs-transfers/)，再決定是否改用 `eth_getLogs`。

## 下一步

* [瀏覽資料集目錄](https://blockvectra.com/zh-hant/data/)，查看 BlockVectra 索引的全部資料集。
* [查看免費方案與定價](https://blockvectra.com/zh-hant/pricing/#free)，確認你的帳戶包含哪些內容。
* [登入控制台](https://console.blockvectra.com/login/?next=%2Fkeys%2F)以建立 API key。
