JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

與鏈對話:JSON-RPC、節點供應商與索引器

你的應用程式沒辦法直接 curl「區塊鏈」——它對話的對象是某一個節點,而每個節點只會講一種語言:JSON-RPC。本篇要近距離拆解這個介面、說清楚為什麼幾乎人人都選擇租節點而非自己跑,以及為什麼那些棘手的讀取——歷史紀錄、加總、前一百名——需要動用另一台完全不同的機器:像 The Graph 這樣的索引器。

這條鏈沒有櫃台

你在錢包裡按下傳送,或打開一個迎面寫著「餘額:500 ETH」的 dApp。感覺上,這個應用程式好像呼叫了某處的一台伺服器。但根本沒有一台「Ethereum.com 伺服器」替你保管餘額。以太坊是數萬個 點對點 節點,每一個都各自保有一份 世界狀態 的副本。想知道任何事,你的應用程式都得去問其中一個節點——而每個節點都對外開著同一道用來發問的門:一個 JSON-RPC 介面。

應用程式需要這道門來辦兩件截然不同的事。讀取——我的餘額是多少、我的交易確認了沒、這個合約上週發出了什麼——以及寫入——把我簽好的交易廣播出去,好讓它改變這條鏈。兩者都走同一條 JSON-RPC,但行為天差地別:讀取是免費的,從某個節點的本機狀態瞬間回答你;寫入則要付 gas,而且唯有當某個區塊把它收進去,才算完成。本篇要打開這道門、說明為什麼多數團隊選擇向節點供應商租用而非自己跑,並揭示為什麼真正棘手的讀取需要動用另一整台機器:索引器

JSON-RPC:這條鏈唯一的 API

JSON-RPC 是一套小巧的約定:你 POST 一個 JSON 物件——`{ jsonrpc, method, params, id }`——然後收回 `{ jsonrpc, id, result }`(或一個 `error`)。方法名稱有命名空間之分,而應用程式做的幾乎每件事,都是大約四十個 `eth_` 方法之一。其中四個扛起了大半工作,並且乾淨地對應到讀與寫。

  1. eth_getBalance(address, block)——回傳某帳戶以 wei 計的餘額,直接從世界狀態讀出。這是最單純的一種讀取。
  2. eth_call(tx, block)——對著 `block` 當下的狀態模擬一次合約呼叫。節點在本機跑 EVM、把函式的回傳值交給你,但什麼都不改變:不花 gas、沒有交易、不出區塊。這正是讀取合約的方式,例如 ERC-20 的 balanceOf。
  3. eth_sendRawTransaction(signedTx)——唯一的一種寫入。你把一筆已簽好的交易交給節點;它驗證後將其散播進記憶池(mempool),回傳的只有交易雜湊。鏈此刻還沒改變——被收進區塊是之後的事。
  4. eth_getLogs(filter)——抓取符合某個過濾條件(位址、topics、區塊範圍)的過往事件紀錄。這是原始 RPC 提供的、通往歷史的唯一一扇窗——而正如下一節所示,你一問出真正的問題,它就吃力了。

兩個常絆倒新手的細節。第一,每個數值都是以 wei 表示的十六進位字串——餘額、gas、區塊高度皆然——所以 `0x1b1ae4d6e2ef500000` 是 500 ETH,而不是個小數字。第二,讀取要帶一個區塊標籤:`latest`、`safe`、`finalized`,或某個明確高度。`eth_call` 預設用 `latest`(當前狀態);若向它要過去某個高度——「這個餘額一年前是多少?」——就只有對著 封存節點 才問得到,這個分別在接下來幾節裡關係重大。

# READ a plain balance — straight from the world state. No gas, no wallet, instant.
curl https://eth-mainnet.example/v2/$KEY -s -X POST \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_getBalance",
       "params":["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","latest"]}'
# -> {"jsonrpc":"2.0","id":1,"result":"0x1b1ae4d6e2ef500000"}
#    Quantities are hex, in wei: 0x1b1ae4d6e2ef500000 = 500.0 ETH.

# READ a contract — eth_call SIMULATES balanceOf(addr) on this one node's state.
#   data = 4-byte selector 0x70a08231 (= keccak256("balanceOf(address)")[:4])
#          followed by the 32-byte left-padded address argument.
curl ... -X POST -d '{"jsonrpc":"2.0","id":2,"method":"eth_call","params":[
  {"to":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
   "data":"0x70a08231000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045"},
  "latest"]}'
# -> {"jsonrpc":"2.0","id":2,"result":"0x0000000000000000000000000000000000000000000000000000000005f5e100"}
#    0x05f5e100 = 100000000 = 100.000000 USDC (6 decimals). Nothing was mined; no gas.

# WRITE — the ONLY state-changing call. You sign locally; the node merely relays.
curl ... -X POST -d '{"jsonrpc":"2.0","id":3,"method":"eth_sendRawTransaction",
  "params":["0x02f8b201808459682f00...<a fully signed EIP-1559 transaction>...c0"]}'
# -> {"jsonrpc":"2.0","id":3,"result":"0x9f2e6b...c1"}   // returns ONLY the tx hash
#    Inclusion is NOT confirmed yet — poll eth_getTransactionReceipt(hash) for that.
同一道 JSON-RPC 門,三種用法:兩種當場回傳資料的讀取(eth_getBalance、eth_call),以及那唯一一種只回傳雜湊與一紙承諾的寫入(eth_sendRawTransaction)。

閘道難題:自己跑節點,還是用租的

回頭看看上面每一個呼叫,留意它們默默假設了什麼:一個節點。光是要做一次 `eth_call`,你就需要一台跑著 執行層用戶端、完全同步、保有當前世界狀態的機器。自己跑是 無信任 的理想——你拿自己重跑一遍的規則去驗證每一個答案——這也是「不必信任任何人」的根本。但 節點那一篇 老實說過代價:一個全節點要約 1 TB 的高速 NVMe、外加好幾天的初始同步,而 封存節點(唯一能回答歷史 `eth_call` 的那種)更會膨脹到好幾 TB。沒幾個應用團隊願意全天候照看這個,還要撐住一個上線產品所要求的在線率。

於是一個替你代勞的市場長了出來:節點供應商——Infura、Alchemy、QuickNode、Ankr——他們經營一整批已同步的節點,發給你一個帶 API 金鑰的 HTTPS 網址。你的 dApp 把 JSON-RPC 呼叫指向那個網址,從此不必碰任何伺服器:封存查詢、WebSocket 訂閱、寬鬆的速率上限,全都按月租用。絕大多數 dApp 與錢包就是這樣實際在讀寫鏈的——包括 MetaMask,它過去預設就接 Infura。

讀取難題:為什麼原始 RPC 答不出真正的問題

`eth_getBalance` 與 `eth_call` 在「恰好一件事」上極為出色:你叫得出名字的某一個槽位的當前值。真正的應用程式問的問題更難。列出這個錢包收過的每一種代幣。這個池子今天最大的十筆兌換是哪些?持有量前一百名是誰?把我過去一年的餘額畫成圖。這些沒有一個是單一、叫得出名字的槽位——它們是橫跨歷史、橫跨眾多帳戶的查詢——而 JSON-RPC 的 API 對其中任何一個,根本沒有對應的方法。

原始 RPC 給你的唯一歷史性工具是 `eth_getLogs`,它掃描的是 事件紀錄——而事件正是這條鏈刻意留下的「變更日誌」,因為合約會為每一個值得留意的變化發出一筆紀錄(一筆 ERC-20 的 `Transfer(from, to, value)`、一次兌換、一次鑄造)。但 `eth_getLogs` 是把鈍器:

  1. 它按區塊範圍掃描,而供應商會把這個範圍卡得很死——每次呼叫常只准幾千個區塊、或約 10,000 筆結果。於是「從創世到現在」意味著要靠人工把數百個分頁、又受速率限制的請求縫在一起。
  2. 它回傳的是未經解碼的原始紀錄——被索引的 topics 加上一團不透明的 data,你得自己照 ABI 解碼。節點不會替你做關聯(join)、排序、加總,也不會替你按非索引欄位過濾。
  3. 加總在伺服器端做不到。「每位持有者的當前餘額」意味著要把自部署以來的每一筆 Transfer 紀錄重播一遍,再在你自己的程式裡加總。
  4. 某個時間點的狀態——「在第 15,000,000 個區塊時的餘額」——需要對著過去的區塊標籤呼叫 eth_call,那只有封存節點才答得出,而且即便如此也是一次一個帳戶。

把這條路走到底,你最後會寫出一支腳本:抓進數百萬筆紀錄、解碼、塞進你自己的資料庫,好讓你終於能查詢它們。就在這一刻,你發現你需要一個索引器。這份工作——把鏈上的事件吃進來、轉換、再存成可查詢的樣子——原來夠普世,於是它變成了一個獨立的基礎設施層,而不是每個團隊各自重造、又造得很差的東西。

索引器與 The Graph:一面可查詢的鏈之鏡

索引器是一個服務:它盯著鏈、隨著每個新區塊到來就處理其中的事件,並把結果寫進一個會講豐富查詢語言的普通資料庫。在以太坊上居主導地位的標準是 The Graph,其中一份索引器定義稱為一個子圖(subgraph)。一個子圖其實就只是三個檔案:

  1. 清單檔(subgraph.yaml):哪個合約位址、哪份 ABI、從哪個區塊開始索引、要監聽哪些事件——把每個事件接到一個處理函式上。
  2. 結構描述(schema.graphql):你想儲存與查詢的實體——比方說一個帶位址與餘額的 Holder,以及一個帶 from/to/amount/block 的 Transfer。這份描述就是你的 GraphQL API。
  3. 對應(mapping.ts,以 AssemblyScript 撰寫):把原始事件變成實體的處理函式——handleTransfer 載入兩位持有者、調整他們的餘額、再存下一筆 Transfer 紀錄。

把它部署上去,索引器(graph-node)就會從起始區塊往前同步,把每一個符合條件的事件重新跑過你的對應、一列一列地把實體表建起來。關鍵是它會記住每個區塊的雜湊,因此能處理重組(re-org):若它已索引的某個區塊被孤立掉,它會把那些實體變更回滾,而不是端出過時的資料。建好的儲存以一個 GraphQL 端點對外開放——於是「持有量前一百名」或「這個錢包的轉帳、最新在前、第 2 頁」就成了一次帶 `where`、`orderBy` 與分頁的查詢:正是原始 RPC 永遠做不到的那些事。

// schema.graphql — the shape of your queryable data == your GraphQL API
type Holder @entity {
  id: ID!                 // the holder's address
  balance: BigInt!
  received: [Transfer!]! @derivedFrom(field: "to")
}
type Transfer @entity(immutable: true) {
  id: ID!                 // txHash-logIndex, globally unique
  from: Holder!
  to: Holder!
  amount: BigInt!
  block: BigInt!
}

// mapping.ts (AssemblyScript) — runs ONCE per Transfer log the chain emits
export function handleTransfer(ev: TransferEvent): void {
  let to = Holder.load(ev.params.to.toHex())
  if (to == null) { to = new Holder(ev.params.to.toHex()); to.balance = BigInt.zero() }
  to.balance = to.balance.plus(ev.params.value)
  to.save()                       // ...and symmetrically subtract from ev.params.from
  let t = new Transfer(ev.transaction.hash.toHex() + "-" + ev.logIndex.toString())
  t.from = ev.params.from.toHex()
  t.to = ev.params.to.toHex()
  t.amount = ev.params.value
  t.block = ev.block.number
  t.save()
}

# A query the frontend runs — impossible as a single eth_getLogs call:
query {
  holders(first: 100, orderBy: balance, orderDirection: desc) { id balance }
}
一個迷你子圖:一份結構描述(要存什麼)、一份對應(每個 Transfer 事件如何更新它),以及一個 UI 實際送出的 GraphQL 查詢——在伺服器端就排好序、分好頁。

完整的讀取路徑:從一個合約事件到螢幕

把這兩半拼起來,你就能把一個 dApp 裡的任何一塊資料,從鏈上一路追到使用者眼前。寫入向外走 `eth_sendRawTransaction`;而那些有意思的讀取走的是一條更長的回程,因為它們根本不再來自節點——它們來自索引器。

  1. 使用者的交易在一個 智慧合約 裡執行;過程中途,合約跑了 emit Transfer(from, to, amount),把一筆 紀錄 寫進交易收據與該 區塊
  2. 索引器(graph-node)——透過 JSON-RPC 連到一個 執行層節點、並訂閱了那個合約——看見新區塊、抓出符合的紀錄、跑 handleTransfer,更新它儲存裡的 Holder 與 Transfer 實體。
  3. 前端不再用迴圈猛打 eth_getLogs,而是向子圖送出一次 GraphQL 查詢:「轉給我的、最新在前、第 1 頁。」
  4. 索引器在毫秒之內就從它的資料庫回答了——早已解好碼、排好序、分好頁——UI 隨即把清單畫出來。
  5. 至於即時的單一數值,應用程式還是直接打 JSON-RPC:用 eth_getBalance 取當前 ETH 餘額、用 eth_call 取當前的鏈上價格——因為那些是單一、當下的槽位,節點瞬間就能回答。

這份分工,正是幾乎每個 dApp 底下那套安靜的架構:寫入與簡單的當前讀取走 JSON-RPC;歷史與加總交給索引器。錢包簽章、供應商轉送、合約 執行、索引器記憶、前端用 GraphQL 發問。收尾的那一篇會把這一切——錢包、RPC、合約、索引器、前端——串進使用者的一次點擊;而在這裡,你已經造好了在應用程式與鏈之間運送每一個位元組的那兩條管線。