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

一個 dApp 的完整堆疊:錢包、RPC、合約與前端

按一下送出代幣,背後串起瀏覽器、金鑰保險庫、RPC 伺服器、八卦傳播網路與一台 EVM——再繞回來。這個總結章把整條學習階梯的每一層接成一個能運作的 dApp。

按一下,背後十幾台機器

你在某個網頁應用裡按下 送出 1 USDC。錢包視窗滑出來;你按 確認;轉圈圈大約十二秒;一個綠色勾勾出現。它「感覺」像在傳一則聊天訊息。其實完全不是。在那十二秒裡,你這一按穿過了瀏覽器、一個從不放開你祕密的金鑰保險庫、一台 JSON-RPC 伺服器、一張點對點的八卦傳播網路,以及某位驗證者的 EVM——然後答案再一路繞回來,只為了把畫面上一個數字重畫一次。整條學習階梯裡幾乎每一個觀念,都剛剛按順序跑了一遍,就為了搬動一枚穩定幣。

一個 去中心化應用程式(dApp)不是單一一支程式,而是一條由五個鬆耦合層串成的管線,每一層你都已經見過:(1)跑在瀏覽器裡的前端、(2)守護你金鑰並負責簽章的錢包、(3)作為你通往網路那道門的 RPC 端點、(4)鏈本身——交易池、區塊產出、EVM 執行、最終性——以及(5)把鏈上資料讀回成 UI 可查詢形狀的索引器。這份指南會帶著一個使用者動作走完這五層,在每一跳都附上真實的程式碼與真實的線路格式,並誠實面對「去中心化」這部分究竟在哪裡悄悄倚靠了中心化的基礎設施。

第一層——前端與 Web3 函式庫

前端就是一個普通的網頁應用——React、一顆按鈕、一些狀態。讓它成為 dApp 的,是一個 Web3 函式庫(ethers.js,或較新的 viem),它把人類的意圖翻譯成 EVM 的呼叫慣例。回想 EVM 那一階:一次合約呼叫不過是一團 ABI 編碼後的 calldata——4 位元組的函式選擇器(簽名經 keccak-256 後的開頭幾個位元組)加上補滿成 32 位元組的引數。這段編碼由函式庫代勞,你永遠不必親手寫原始十六進位。

第一件要內化的事,是讀取與寫入的分野。讀取(某人的餘額、某個池子的價格)不花一毛錢、不產生任何 交易、也不需要簽章——它只是透過 `eth_call` 在某個節點上本地把合約程式碼跑一遍。寫入(轉移一枚代幣)會改變 以太坊的世界狀態,因此必須是一筆付出 gas 的已簽章交易。viem 把這分成兩個 client:負責讀的 `publicClient` 與負責寫的 `walletClient`。

import { createPublicClient, http, parseAbi } from 'viem'
import { mainnet } from 'viem/chains'

// A "public client" = a read-only connection to an RPC endpoint.
const publicClient = createPublicClient({
  chain: mainnet,
  transport: http('https://eth-mainnet.g.alchemy.com/v2/<API_KEY>'),
})

// The ABI is the calling convention: names, argument types, returns.
const erc20 = parseAbi([
  'function balanceOf(address) view returns (uint256)',
  'function transfer(address to, uint256 amount) returns (bool)',
])

// A read is FREE: no transaction, no signature. It runs eth_call on the node.
const bal = await publicClient.readContract({
  address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC
  abi: erc20,
  functionName: 'balanceOf',
  args: ['0x1111...your address'],
}) // -> 12_500000n  (USDC has 6 decimals, so this is 12.5 USDC)
viem 讀取餘額:ABI 告訴它如何編碼這次呼叫;readContract 發出 eth_call,再把 32 位元組的結果解碼回一個 bigint。

第二層——錢包:金鑰留在家,外出的是簽章

這裡有個讓整套機制安全的架構決定:dApp 從頭到尾看不到你的私鑰。 像 MetaMask 這樣的瀏覽器錢包(或透過 WalletConnect 連上的手機錢包)是一個獨立、沙箱化的 非託管保險庫。前端請它去某些事;錢包則向你顯示一段人類看得懂的提示;唯有你批准,它才會用金鑰簽章。金鑰永遠不會跨進那張網頁——這正是為什麼惡意網站可以「請求」,卻無法「偷走」,除非你親手按下 確認

錢包透過一個極小的標準介面把自己暴露出來,也就是 EIP-1193:單一一個 `provider.request({ method, params })` 函式。viem 的 `custom(window.ethereum)` 傳輸層包的就是這個被注入的物件。「連接」不過是請錢包揭露一個帳戶,底層其實是 `eth_requestAccounts` 這個 RPC 方法——正是它觸發了我們熟悉的「要連接錢包嗎?」對話框。

import { createWalletClient, custom } from 'viem'
import { mainnet } from 'viem/chains'

// window.ethereum is the EIP-1193 provider injected by MetaMask.
const walletClient = createWalletClient({
  chain: mainnet,
  transport: custom(window.ethereum),
})

// Ask the wallet to expose an account -> triggers eth_requestAccounts.
const [account] = await walletClient.requestAddresses()

// A WRITE costs gas and needs a signature. The WALLET signs, not the dApp.
const hash = await walletClient.writeContract({
  account,
  address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
  abi: erc20,
  functionName: 'transfer',
  args: ['0x2222...recipient', 1_000000n], // 1 USDC (6 decimals)
})
// `hash` is the transaction hash. The token has NOT moved yet — it is
// only in the mempool. We wait for a receipt in Layer 4.
透過被注入的錢包送出一次寫入。這裡的 writeContract 會呼叫 eth_sendTransaction;MetaMask 填上 nonce 與 gas、完成簽章,並透過它自己的 RPC 廣播,最後回傳交易雜湊。

在那一次 `writeContract` 呼叫之內,錢包悄悄完成了四件你在前面幾階學過的工作:查出你的 nonce(讓交易無法被重放或重排)、估算並填上 EIP-1559 的手續費欄位(`maxFeePerGas` 與 `maxPriorityFeePerGas`)、把交易序列化並做 RLP 編碼,最後用你的金鑰、以 secp256k1 曲線上的 ECDSA 對該交易的雜湊簽章。這個簽章就是你的授權;任何人都能驗證它能還原出你的位址,但沒有金鑰誰也偽造不了它。

第三層——RPC 端點:你通往網路的那道門

在某個節點接受它之前,一筆已簽章的交易不過是一串位元組。JSON-RPC 端點就是那個節點對外的 API:一台只會講一小份方法選單的純 HTTPS 伺服器。要廣播,錢包把已簽章、RLP 編碼後的那團資料送進 `eth_sendRawTransaction`;節點驗證簽章與 nonce 後,透過點對點傳播把它八卦進 交易池。關鍵在於,回應是交易雜湊,而不是收據——節點承諾的是會去「試」,而非確認已被收錄。

# What actually goes over HTTPS to the RPC endpoint when you broadcast:
POST https://eth-mainnet.g.alchemy.com/v2/<key>
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "eth_sendRawTransaction",
  "params": ["0x02f8b0018203...<RLP-encoded signed EIP-1559 tx, type 0x02>"]
}

# The node replies with the transaction HASH (not a receipt -- it isn't mined yet):
{ "jsonrpc": "2.0", "id": 1, "result": "0x9a8f1c...c21" }
writeContract 底下那通原始的 JSON-RPC 呼叫。開頭的 0x02 標示這是一筆 EIP-1559 型別交易。這個雜湊讓你能追蹤交易,但它不是執行完成的證明。

那個端點是哪來的?三種選擇,各有真實的取捨。(1)自己跑一個 全節點 / 執行層用戶端——無需信任程度最高,但你得維護一台機器。(2)用 託管的節點供應商,例如 Alchemy 或 Infura——一條 URL、零維運,但你現在信任的是一家能看到你 IP 與查詢內容、能對你限流、也可能當機的公司。(3)混合:日常用供應商圖方便,敏感路徑走自己的節點。多數正式環境的 dApp 選了第二種——而這正是一個「去中心化」應用最容易悄悄重新中心化的單一最大破口。

第四層——鏈上:執行、收錄與收據

現在輪到你花了好幾階學的那些層接手。從 交易池裡,一個區塊建構者挑中你的交易(這是手續費市場:你的 優先小費在被燒掉的 基礎費之上爭奪區塊空間)。一位提議者把區塊收錄進來;每個節點都在 EVM 上重新執行你的 `transfer`,它在那份 智慧合約的儲存中,把一個餘額槽減少、另一個增加,並逐一操作碼地收取 gas。合約對日誌發出一筆 `Transfer(from, to, value)` 事件。接著區塊被見證,經過兩個 epoch 後達到 最終性——到了那一刻,要回滾它將讓攻擊者賠上全體質押 ETH 的三分之一。

前端怎麼知道成功了?靠輪詢。函式庫反覆呼叫 `eth_getTransactionReceipt(hash)`;交易還在等待時節點回傳 `null`,一旦被打包進區塊,便回傳一張收據,內含狀態(`success` 或 `reverted`)、實際用掉的 gas、區塊編號,以及發出的日誌。viem 把這個迴圈包成了 `waitForTransactionReceipt`。

// Poll until the tx is in a block, then read its receipt.
const receipt = await publicClient.waitForTransactionReceipt({ hash })

receipt.status       // 'success'  (or 'reverted' -- a mined-but-failed tx
                     //              STILL costs gas; you paid for the work)
receipt.blockNumber  // 19_482_103n
receipt.gasUsed      // e.g. 51_000n
receipt.logs         // includes the ERC-20 Transfer(from, to, value) event

// A receipt only means "included". For high-value flows, also confirm depth
// or wait for finality before treating the money as truly settled.
const safe = await publicClient.waitForTransactionReceipt({
  hash, confirmations: 12,
})
讀取收據。注意:一筆被還原(revert)的交易照樣會被打包、照樣收 gas——「失敗」不等於「免費」,也不等於「沒發生過」。

第五層——把鏈讀回來:事件、索引器與 UI

轉帳成功了——但 UI 要怎麼顯示「你最近的 50 筆轉帳」?你沒辦法直接向鏈問這個問題。節點能便宜地給你當前狀態(此刻的 `balanceOf`),但鏈並沒有內建的「查詢全部歷史」索引。事件就是那座橋:每一筆 `Transfer` 日誌都被永久記錄,帶有可供過濾的索引化主題(topic)。`eth_getLogs` 讓你能掃描一段區塊範圍、找出符合的事件。

import { parseAbiItem } from 'viem'

// Direct log scan: fine for a narrow range, falls over across millions of blocks.
const logs = await publicClient.getLogs({
  address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
  event: parseAbiItem(
    'event Transfer(address indexed from, address indexed to, uint256 value)'
  ),
  args: { to: account },           // filter on the indexed `to` topic
  fromBlock: 19_000_000n,
  toBlock: 'latest',
})

// Problem: providers cap getLogs (e.g. 10k results / a few thousand blocks).
// Scanning all of history from the browser is slow, paginated, and fragile.
直接查詢事件。這在小範圍內可行,但無法擴展到「全部歷史」——這正是專責索引器存在的理由。

對真正的應用來說,這是索引器的工作。像 The Graph 這樣的服務會跑一批節點盯著鏈、解碼每一筆事件、寫進一個資料庫,讓你用 GraphQL 在毫秒內查詢——連接、排序、彙總,這些都是 `eth_getLogs` 做不到的。你定義一個子圖(subgraph)(哪些合約、哪些事件、如何對應),索引器便回補歷史並持續同步。於是完整的讀取路徑是:鏈 → 事件 → 索引器 → GraphQL → 你的 UI。而像 Etherscan 這樣的 區塊瀏覽器,本質上不過是一個套上網頁介面的巨型通用索引器。

完整的生命週期,以及它在哪裡倚靠信任

讓我們把一次點擊從上到下、再繞回來地走過每一層,好讓整個架構在你腦中成為一幅完整的圖。

  1. 前端。 你按下送出 1 USDC。Web3 函式庫(viem/ethers)讀取 ABI,把這次呼叫編碼成 calldata:選擇器 `0xa9059cbb`(transfer)+ 補滿的收款位址 + 金額。
  2. 錢包。 函式庫透過 EIP-1193 把請求交給你的 非託管錢包。它顯示一段人類看得懂的提示;你一批准,它便填上 nonceEIP-1559 手續費、對交易做 RLP 編碼,並用 ECDSA 對其雜湊簽章。你的金鑰從不離開保險庫。
  3. RPC 端點。 已簽章的資料團透過 `eth_sendRawTransaction` 被 POST 到一個 JSON-RPC 節點。節點檢查它、把它八卦進 交易池;你拿回一個交易雜湊——那是一句承諾,不是確認。
  4. 鏈。 一個建構者從手續費市場挑中它;一位提議者把區塊收錄;每個節點在 EVM 上重新執行它,更新儲存並發出一筆 `Transfer` 日誌。見證不斷累積;兩個 epoch 後它便 最終定案
  5. 繞回上層。 前端輪詢 `eth_getTransactionReceipt`,直到看見 `status: success`,便畫出樂觀的「已送出!」狀態。至於歷史紀錄,它向索引器(The Graph)以 GraphQL 查詢,而不是自己去掃日誌。

退一步,看清這裡誠實的接縫。共識層是真正去中心化、無需信任的——那是你花了十二階才掙到的部分。但圍繞它的存取層往往不是:RPC 端點通常是單一一家公司、索引器通常是單一一家公司、前端通常由中心化代管的單一網域提供。一個動不了 以太坊的政府,仍然可以對 Infura、Etherscan 與你的 DNS 施壓。把鏈去中心化已經解決了;把它之上的整個堆疊去中心化——本地節點、輕用戶端、用 IPFS 代管的前端、多個 RPC——才是仍在進行的工程前沿。

這就是整台機器。一筆你現在能完整追蹤的 交易:從一顆按鈕出發,經過一個證明「就是你」的簽章,穿過一道 RPC 門,進入一台執行著你寫得出來的 SolidityEVM,落到一本由你已理解的質押所守護的帳本上——再繞回畫面。先前每一階都是一個零件;這一階是組裝。你已經走完了一個 dApp 的完整堆疊。