入門:從本地鏈轉(zhuǎn)賬到合約交互與事件監(jiān)聽)
1. 先搞清楚 Ethers.js 到底在幫你省什么事很多新手剛接觸 Web3 開發(fā)時(shí)第一反應(yīng)是跑去讀共識算法、看 Solidity 語法折騰一星期連一條真實(shí)交易都沒發(fā)過然后就被勸退了。我的建議正好相反想入門先直接把 Ethers.js 用起來在一臺本地開發(fā)鏈上做一筆轉(zhuǎn)賬、調(diào)一個(gè)合約方法把“鏈上交互”這個(gè)閉環(huán)跑通再去補(bǔ)底層原理你會發(fā)現(xiàn)一切都順了。你可能會問Ethers.js 是什么簡單說它可以讓你用 JavaScript 的語法去讀寫以太坊上的數(shù)據(jù)。以前要是想讀一個(gè)地址的余額你得自己拼 JSON-RPC 請求自己處理十六進(jìn)制結(jié)果自己解析錯(cuò)誤現(xiàn)在你只需要拿到一個(gè) provider然后調(diào)用provider.getBalance(address)。底層那些復(fù)雜的編碼、簽名、網(wǎng)絡(luò)通信都被封裝掉了。1.1 從中心化應(yīng)用的“用戶體系”說起傳統(tǒng)互聯(lián)網(wǎng)應(yīng)用里用戶登錄靠的是賬號密碼數(shù)據(jù)存在自己的數(shù)據(jù)庫里。Web3 世界里沒有“數(shù)據(jù)庫里的一行記錄”能用密碼改所有狀態(tài)都保存在區(qū)塊鏈上狀態(tài)變更必須通過交易來觸發(fā)。這意味著兩件事第一你的代碼要會構(gòu)造交易第二你的代碼要能簽名證明你有權(quán)操作某個(gè)賬戶。Ethers.js 把這兩件事拆成了兩個(gè)概念provider 負(fù)責(zé)連接網(wǎng)絡(luò)、查詢狀態(tài)signer 負(fù)責(zé)持有私鑰、簽名交易。這種分離初看有點(diǎn)啰嗦但它和你真實(shí)面對的編程模型完全一致。讀數(shù)據(jù)不需要花錢也不需要身份寫數(shù)據(jù)一定要有賬戶簽名并且要支付 gas。所以學(xué)習(xí) Ethers.js 不只是學(xué)一個(gè) API而是在學(xué)以太坊自己的“權(quán)限模型”。1.2 為什么新項(xiàng)目我更愿意用它市面上的 Web3 交互庫不少老一些的教程里更多是另一套庫的寫法我自己早期也用過但后來的項(xiàng)目基本都換到了 Ethers.js。原因有三個(gè)API 設(shè)計(jì)更符合直覺你不需要記大量與鏈上協(xié)議強(qiáng)綁定的規(guī)則寫出來的代碼更接近“面向?qū)ο蟆钡挠梅ū热鏲ontract.balanceOf(addr)就是一個(gè)普通方法調(diào)用。類型信息更完整如果你用 TypeScript它能給你非常清楚的類型提示。合約的方法名、參數(shù)類型都能在你寫代碼時(shí)被編輯器檢查出來這對項(xiàng)目維護(hù)太重要了。內(nèi)部數(shù)據(jù)表示更現(xiàn)代新版已經(jīng)遷移到原生BigInt不再依賴自帶的大數(shù)類跟 Node.js 和瀏覽器的兼容性都好很多。當(dāng)然不是說別的庫一無是處只是從新項(xiàng)目選型角度看Ethers.js 的生態(tài)和更新節(jié)奏更讓我放心。1.3 Provider、Signer、Contract三個(gè)核心抽象整個(gè) Ethers.js 的日常使用可以濃縮成三個(gè)對象Provider鏈的只讀窗口。傳入一個(gè) RPC 地址你就能查區(qū)塊高度、賬戶余額、合約狀態(tài)。Signer有私鑰的賬戶。它擴(kuò)展了 provider 的能力可以簽名并廣播交易。Contract合約地址與 ABI 的組合。有了它你調(diào)用合約方法就像調(diào)用普通函數(shù)。我見過不少人把這三個(gè)概念混在一起用最后寫出的代碼非常擰巴。比如只傳 provider 給合約卻在里面調(diào)用轉(zhuǎn)賬方法結(jié)果報(bào)錯(cuò)“missing signer”。你先在腦子里理清楚這三個(gè)抽象后面所有例子都會好懂很多。2. 動(dòng)手前的準(zhǔn)備把本地項(xiàng)目連上一條鏈不要一開始就連接主網(wǎng)去操作真實(shí)資產(chǎn)誰的錢都不是大風(fēng)刮來的。安全、便宜、還能反復(fù)折騰的方式是先搭一個(gè)本地開發(fā)鏈。你在本機(jī)跑一個(gè)開發(fā)鏈后可以為所欲為地制造區(qū)塊還不用擔(dān)心把測試幣耗盡。2.1 搭建最小項(xiàng)目骨架打開終端建一個(gè)目錄初始化 npm 項(xiàng)目mkdir ethers-playground cd ethers-playground npm init -y npm install ethers裝完之后確認(rèn)一下版本。如果你看到package.json里的版本是 6.x就按本文的語法來如果看到 5.x很多 API 是不一樣的。接著創(chuàng)建一個(gè)index.js先用最少的代碼連接網(wǎng)絡(luò)const { JsonRpcProvider } require(ethers); const provider new JsonRpcProvider(http://127.0.0.1:8545); (async () { const network await provider.getNetwork(); const blockNumber await provider.getBlockNumber(); console.log(chainId:, network.chainId); console.log(當(dāng)前區(qū)塊高度:, blockNumber); })();如果你本地已經(jīng)跑起了開發(fā)鏈運(yùn)行node index.js應(yīng)該能看到 chainId 和區(qū)塊高度。2.2 RPC 節(jié)點(diǎn)與網(wǎng)絡(luò)配置里最容易踩的坑很多新人第一次連接網(wǎng)絡(luò)就卡住不是因?yàn)榇a寫錯(cuò)而是 RPC 地址給錯(cuò)了。請記住一個(gè)原則RPC URL 決定了你連接的是哪條鏈。如果你連接的是公共測試網(wǎng)官方會提供一個(gè)公開 RPC 地址如果你連接的是本地開發(fā)鏈通常是http://127.0.0.1:8545。后者是明文 HTTP只能在本地調(diào)試時(shí)用千萬不能拿它去傳主網(wǎng)私鑰。生產(chǎn)環(huán)境一定要用加密通道并且建議把 RPC 地址和 API Key 放進(jìn)環(huán)境變量里別硬編碼進(jìn)代碼提交到倉庫。一個(gè)更穩(wěn)妥的做法是在代碼里校驗(yàn)當(dāng)前網(wǎng)絡(luò)是不是預(yù)期的鏈const expectedChainId 1337n; const network await provider.getNetwork(); if (network.chainId ! expectedChainId) { throw new Error(網(wǎng)絡(luò)不匹配期望 chainId ${expectedChainId}實(shí)際 ${network.chainId}); }這個(gè)檢查能幫你擋住“測試網(wǎng)地址填錯(cuò)、連上主網(wǎng)”這種致命問題。2.3 測試幣從哪里來本地開發(fā)鏈有一個(gè)內(nèi)置賬戶通常全自動(dòng)生成里面有一堆 ETH你可以直接拿它的私鑰做實(shí)驗(yàn)。如果想模擬更接近真實(shí)的環(huán)境也可以用公共測試網(wǎng)去水龍頭網(wǎng)站上領(lǐng)一些測試幣。說句實(shí)話測試幣雖然不值錢但領(lǐng)起來也煩。我覺得最好的上手方式還是本地開發(fā)鏈因?yàn)樗馁~戶余額充足也沒有網(wǎng)絡(luò)延遲所有交易秒級確認(rèn)。等你把邏輯都調(diào)通了再去公共測試網(wǎng)做一次完整的部署和交互體驗(yàn)會好很多。下面是一個(gè)簡單的對比網(wǎng)絡(luò)類型用途獲取測試幣難度適合階段本地開發(fā)鏈日常調(diào)試無需領(lǐng)取初學(xué)、測試邏輯公共測試網(wǎng)模擬真實(shí)環(huán)境需要去水龍頭上線前驗(yàn)證主網(wǎng)真實(shí)資產(chǎn)無測試幣概念生產(chǎn)環(huán)境3. 第一筆轉(zhuǎn)賬代碼量不大細(xì)節(jié)卻不少網(wǎng)絡(luò)通了下一步就是發(fā)一筆真正上鏈的交易。第一次看到自己能轉(zhuǎn)賬是很上頭的。但激動(dòng)歸激動(dòng)還是要穩(wěn)。3.1 錢包是怎么來的在 Ethers.js 里你想發(fā)交易首先得有一個(gè) signer。它的來源主要有三種const { Wallet } require(ethers); // 方式一從私鑰導(dǎo)入 const wallet1 new Wallet(process.env.PRIVATE_KEY, provider); // 方式二隨機(jī)生成一個(gè)新錢包 const wallet2 Wallet.createRandom(); // 方式三從助記詞恢復(fù) const wallet3 Wallet.fromPhrase(process.env.MNEMONIC, provider);方式一最常用。開發(fā)時(shí)你把私鑰放在.env文件里通過環(huán)境變量讀取。方式二適合創(chuàng)建新測試賬戶它會生成一組助記詞記錄好了下次還能恢復(fù)。方式三是給用戶做導(dǎo)入用的比如你寫了一個(gè)錢包工具需要支持用戶輸入助記詞登錄。注意私鑰就是賬戶的唯一憑證誰拿到誰就有權(quán)動(dòng)用里面的所有資產(chǎn)。測試網(wǎng)上還好主網(wǎng)上一旦泄露神仙都救不回來。3.2 完整轉(zhuǎn)賬代碼與參數(shù)逐行拆解假設(shè)你已經(jīng)從私鑰創(chuàng)建好了wallet現(xiàn)在想給另一個(gè)地址轉(zhuǎn) 0.01 ETHconst tx await wallet.sendTransaction({ to: 0xRecipientAddress, value: parseEther(0.01), gasLimit: 21000n, maxFeePerGas: parseUnits(20, gwei), maxPriorityFeePerGas: parseUnits(1, gwei), });讓我逐行解釋to接收方地址。如果寫錯(cuò)了交易會發(fā)送到一個(gè)別人可能永遠(yuǎn)無法找回私鑰的地址資產(chǎn)直接消失。value轉(zhuǎn)賬金額。這里用parseEther(0.01)把 ETH 轉(zhuǎn)成最小單位 Wei。千萬不要直接寫0.01鏈上都是整數(shù)單位前端展示才用浮點(diǎn)數(shù)。gasLimit這筆交易最多消耗多少 gas。普通 ETH 轉(zhuǎn)賬固定是 21000不需要多給。maxFeePerGas你愿意支付的單份 gas 價(jià)格上限。maxPriorityFeePerGas給礦工/驗(yàn)證者的小費(fèi)用來讓交易更快被打包。相比舊版教程里只有一個(gè)gasPrice現(xiàn)在以太坊主要使用 EIP-1559 模式理解這兩個(gè)參數(shù)能幫你排查交易一直“待確認(rèn)”的問題。3.3 交易發(fā)出去了不代表已經(jīng)成功很多人以為await wallet.sendTransaction(...)執(zhí)行完交易就成功了。實(shí)際情況是sendTransaction只是把交易廣播了出去返回一個(gè) Promise里面是交易響應(yīng)對象tx。交易能不能被確認(rèn)取決于網(wǎng)絡(luò)是否接受了它以及 gas 是否足夠。你必須再等一個(gè)確認(rèn)const receipt await tx.wait(); if (receipt.status 1) { console.log(交易成功tx hash:, tx.hash); } else { console.log(交易失敗回執(zhí)狀態(tài), receipt.status); }receipt.status為 1 表示成功為 0 表示執(zhí)行失敗。失敗同樣會消耗 gas因此調(diào)試合約調(diào)用時(shí)如果失敗總是反復(fù)發(fā)生你得先看回執(zhí)里的日志不能盲目重試。4. 打合約從 ABI 到事件監(jiān)聽一次說清轉(zhuǎn)賬只是單方動(dòng)作真正讓 Web3 世界豐富多彩的是智能合約。Ethers.js 讓你調(diào)用合約方法就像調(diào)用本地函數(shù)一樣但它并不是魔法它背后依賴一個(gè)關(guān)鍵的東西ABI。4.1 ABI 決定你能調(diào)什么ABI 是一個(gè) JSON 數(shù)組描述了合約有哪些函數(shù)、參數(shù)類型、返回值類型還會告訴你某個(gè)函數(shù)是可讀還是可寫。沒有 ABI你就不知道該怎么編碼調(diào)用數(shù)據(jù)只能拿著原始十六進(jìn)制數(shù)據(jù)發(fā)呆。獲取 ABI 通常有兩個(gè)渠道一是你自己編譯智能合約時(shí)生成的構(gòu)建產(chǎn)物二是從區(qū)塊瀏覽器上復(fù)制別人合約的 ABI??傊灰霞s地址和 ABI 都是真的你就能和這個(gè)合約交互。創(chuàng)建合約實(shí)例時(shí)要特別注意第三個(gè)參數(shù)const { Contract } require(ethers); const contract new Contract(contractAddress, abi, signer);第三個(gè)參數(shù)如果傳的是 provider那么這個(gè)合約只能做只讀操作如果傳的是 signer那它既能讀也能寫。反過來你必須清楚當(dāng)前調(diào)用到底需不需要簽名。4.2 只讀方法零 gas 也能拿到鏈上數(shù)據(jù)合約里的view或pure方法屬于只讀調(diào)用不會改變鏈上狀態(tài)所以不需要簽名也不需要消耗用戶的 gas。用 Ethers.js 調(diào)用它們非常簡單const name await contract.name(); const totalSupply await contract.totalSupply(); const balance await contract.balanceOf(0xSomeAddress);這里有一個(gè)很常見的誤解不是所有網(wǎng)絡(luò)讀取都是完全“免費(fèi)”的。節(jié)點(diǎn)執(zhí)行這些調(diào)用仍然會消耗計(jì)算資源只不過費(fèi)用不由發(fā)起者承擔(dān)而是由節(jié)點(diǎn)自己消化。對用戶來說體驗(yàn)確實(shí)像免費(fèi)查詢。4.3 寫方法簽名、廣播、等待確認(rèn)缺一不可如果方法是transfer、mint、swap這類會修改狀態(tài)的那就得用 signer 調(diào)用并等待交易確認(rèn)const tx await contract.transfer(0xRecipientAddress, parseEther(1.5)); const receipt await tx.wait(); if (receipt.status 1) { console.log(合約調(diào)用成功hash:, tx.hash); }有些合約的寫方法還要求你附帶 ETH比如購買 NFT 或鑄造代幣。Ethers.js 允許你傳入一個(gè)額外的覆蓋參數(shù)const tx await contract.mint({ value: parseEther(0.1), });這里的{ value: ... }就是給合約轉(zhuǎn)賬 0.1 ETH。你還可以同時(shí)覆蓋 gas 參數(shù)比如gasLimit、maxFeePerGas。4.4 事件監(jiān)聽從“查狀態(tài)”到“等通知”鏈上數(shù)據(jù)的另一種常見用法是監(jiān)聽事件。比如你關(guān)心某個(gè)代幣的轉(zhuǎn)賬可以通過合約事件接口訂閱contract.on(Transfer, (from, to, value, event) { console.log(${from} 轉(zhuǎn)賬給 ${to}: ${value}); });但要記住一個(gè)關(guān)鍵前提HTTP 連接無法主動(dòng)接收節(jié)點(diǎn)推送。如果你用的是JsonRpcProvider(http://...)直接調(diào)用on是無效的因?yàn)?HTTP 是單向請求。要么換成支持訂閱的WebSocketProvider要么定期用queryFilter去掃歷史事件。const events await contract.queryFilter(Transfer, startBlock, endBlock);對入門項(xiàng)目來說輪詢雖然笨但勝在穩(wěn)定也不需要額外維護(hù)長連接。5. 真正讓我印象深刻的是這些坑Ethers.js 整體不難但實(shí)際寫起來有些坑幾乎每個(gè)人都踩過。我把它們放在一起希望能幫你節(jié)省幾天時(shí)間。5.1 BigInt 精度不是玄學(xué)是你沒看清類型鏈上金額通常用最小單位表示比如 0.01 ETH 在鏈上是10000000000000000Wei。這個(gè)數(shù)遠(yuǎn)超 JavaScript 普通number的安全整數(shù)范圍所以在 Ethers.js 新版里數(shù)值類型統(tǒng)一用的是原生BigInt。我見過很多報(bào)錯(cuò)都長這樣const amount parseEther(0.01); console.log(amount 1); // 報(bào)錯(cuò)BigInt 和 number 不能直接相加解決辦法很簡單所有數(shù)值運(yùn)算都用BigInt用人眼看著惡心但精度絕對靠譜。展示給用戶前再調(diào)用formatEther(amount)轉(zhuǎn)回字符串。5.2 Nonce 卡住一筆 pending 交易堵住整個(gè)賬戶每筆交易都會帶一個(gè)nonce代表這個(gè)賬戶發(fā)出的第幾筆交易。節(jié)點(diǎn)要求交易必須按照 nonce 順序執(zhí)行如果一個(gè)賬戶有一筆 nonce 為 5 的交易始終沒被確認(rèn)那你后面所有 nonce 更大的交易都會排隊(duì)哪怕你重新提交也未必有效。排查時(shí)可以先打印一下這個(gè)賬戶的 nonceconst nonce await wallet.getNonce(); console.log(下一筆可用 nonce:, nonce);如果發(fā)現(xiàn)有交易卡了很久有兩種處理思路一是用更高 gas 重新廣播同一筆交易覆蓋舊的二是以nonce作為參數(shù)發(fā)送一筆 0 ETH 轉(zhuǎn)賬給自己手動(dòng)把它“頂?shù)簟?。這里面的操作細(xì)節(jié)很多但至少你得先知道問題出在 nonce 上面而不是無腦重試。5.3 Gas 估算與限額寧可多留余地也別卡死在半路Ethers.js 里有個(gè)estimateGas能在發(fā)送前估算交易需要的 gas。但估算結(jié)果不一定完全準(zhǔn)確因?yàn)樗诋?dāng)前鏈上狀態(tài)做模擬合約執(zhí)行路徑一變實(shí)際消耗可能比估算值高。更穩(wěn)妥的做法是給gasLimit留一個(gè)緩沖比如估算出來是 50000你就設(shè) 60000甚至寫死。對普通 ETH 轉(zhuǎn)賬 21000 是固定的不需要緩沖對合約調(diào)用建議多留 10% 到 20%。Gas 價(jià)格同理。如果你把maxFeePerGas設(shè)得太低交易就會一直滯留。測試環(huán)境無所謂主網(wǎng)上要留意當(dāng)前網(wǎng)絡(luò)的平均費(fèi)率別憑感覺填。5.4 網(wǎng)絡(luò)環(huán)境切換同一個(gè)私鑰在不同鏈上的教訓(xùn)本地開發(fā)鏈、公共測試網(wǎng)、主網(wǎng)它們的 chainId 完全不同。同一個(gè)私鑰在主網(wǎng)和測試網(wǎng)都能推導(dǎo)出相同的地址但你如果拿著測試網(wǎng)的 RPC 去發(fā)主網(wǎng)交易極大概率會直接失敗。以前我在一個(gè)腳本里同時(shí)配了多個(gè)網(wǎng)絡(luò)結(jié)果忘了改環(huán)境變量差點(diǎn)把主網(wǎng)交易發(fā)到測試網(wǎng)。從那以后我習(xí)慣在代碼啟動(dòng)時(shí)強(qiáng)制檢查 chainId把錯(cuò)誤暴露在第一時(shí)間而不是等交易失敗了再回頭看日志。6. 進(jìn)階方向從腳本到 DApp當(dāng)你已經(jīng)把轉(zhuǎn)賬和合約調(diào)用跑通接下來無非是把這套能力變成產(chǎn)品。這里有幾個(gè)方向我建議按順序去擴(kuò)展。6.1 私鑰安全所有 Web3 開發(fā)的底線在腳本里直接碰私鑰關(guān)起門自己玩可以但一旦要給別人用就不能再用服務(wù)器私鑰簽名了。安全底線是私鑰不寫入代碼倉庫用環(huán)境變量或密鑰管理服務(wù)。助記詞不截圖、不粘貼到聊天工具、不存云筆記。主網(wǎng)真實(shí)資產(chǎn)盡量走硬件錢包或者通過瀏覽器錢包讓用戶自行授權(quán)簽名。對任何看似“免費(fèi)領(lǐng)取”的簽名請求保持警惕授權(quán)簽名可能讓你損失資產(chǎn)。Ethers.js 本身只是一個(gè)庫它不會替你做安全決策但你應(yīng)該從一開始就養(yǎng)成習(xí)慣。6.2 接瀏覽器錢包EIP-1193 是標(biāo)準(zhǔn)現(xiàn)在大多數(shù) DApp 并不是在服務(wù)器端用私鑰簽名而是在瀏覽器里連接用戶自己的錢包。Ethers.js 對這種場景有專門的支持const { BrowserProvider } require(ethers); // 瀏覽器環(huán)境里錢包會往 window.ethereum 注入一個(gè) provider const provider new BrowserProvider(window.ethereum); const signer await provider.getSigner();這里不需要你管理私鑰用戶自己點(diǎn)擊確認(rèn)授權(quán)你的代碼拿到的是一個(gè)受限的 signer。這種方式雖然犧牲了自動(dòng)化能力但安全性和用戶體驗(yàn)都遠(yuǎn)好于“服務(wù)器保存用戶私鑰”。6.3 下一步還能玩什么基礎(chǔ)鏈路已經(jīng)通了你完全可以往這些方向繼續(xù)探索批量轉(zhuǎn)賬工具讀取 CSV循環(huán)調(diào)用代幣合約的transfer。鏈上監(jiān)控機(jī)器人監(jiān)聽某個(gè)合約的特定事件觸發(fā)告警。簡單的資產(chǎn)管理面板用 provider 查詢多個(gè)地址的幣種余額。合約部署腳本用ContractFactory把 Solidity 編譯產(chǎn)物部署到測試網(wǎng)。給你一個(gè)最實(shí)在的下一步建議找一個(gè)你感興趣的 ERC-20 代幣合約 ABI把它接到你的本地開發(fā)鏈測試合約上寫一個(gè)自動(dòng)轉(zhuǎn)賬加監(jiān)聽事件的腳本。兩三個(gè)小時(shí)跑下來你對 Web3 開發(fā)的核心鏈路就不會再恐懼了。最后說一點(diǎn)個(gè)人體會。我每次帶新人入門都不會讓他們先背一堆概念而是讓代碼先跑起來。Ethers.js 恰恰是那種“五分鐘能見到效果”的庫它的價(jià)值不是幫你寫業(yè)務(wù)邏輯而是幫你把鏈上交互的復(fù)雜度收斂在一個(gè)很舒服的抽象層里。先動(dòng)手等你在交易和合約上栽過幾個(gè)小跟頭再回去看共識算法、看狀態(tài)樹你會發(fā)現(xiàn)一切都變得特別好懂。別怕踩坑坑本身就是這條路上最好的老師。