贊助燃料費用:如何為您的使用者支付交易成本
簡介
如果我們希望以太坊能服務十億以上的人口 (opens in a new tab),我們需要消除阻力並使其盡可能容易使用。這種阻力的來源之一是需要 ETH 來支付燃料費用。
如果您有一個從使用者身上獲利的去中心化應用程式 (dapp),讓使用者透過您的伺服器提交交易並由您自己支付交易費用可能是合理的。因為使用者仍然在他們的錢包中簽署 EIP-712 授權訊息 (opens in a new tab),所以他們保留了以太坊的完整性保證。可用性取決於中繼交易的伺服器,因此較為受限。然而,您可以進行設定,讓使用者也能直接存取智能合約(如果他們獲得了 ETH),並讓其他人如果想贊助交易,可以架設自己的伺服器。
本教學中的技術僅在您控制智能合約時才有效。還有其他技術,包括帳戶抽象化 (opens in a new tab),可以讓您贊助其他智能合約的交易,我希望在未來的教學中涵蓋這些內容。
注意:這_不是_生產等級的程式碼。它容易受到重大攻擊且缺乏主要功能。請在本指南的漏洞部分了解更多資訊。
先決條件
要理解本教學,您需要已經熟悉:
- Solidity
- JavaScript
- React 和 WAGMI。如果您不熟悉這些使用者介面工具,我們有相關的教學。
範例應用程式
這裡的範例應用程式是 Hardhat 的 Greeter 合約的變體。您可以在 GitHub 上 (opens in a new tab)查看它。該智能合約已經部署在 Sepolia (opens in a new tab) 上,地址為 0xC87506C66c7896366b9E988FE0aA5B6dDE77CFfA (opens in a new tab)。
要查看其實際運作,請遵循以下步驟。
-
複製儲存庫並安裝必要的軟體。
1git clone https://github.com/qbzzt/260301-gasless.git2cd 260301-gasless/server3npm install -
編輯
.env,將PRIVATE_KEY設定為在 Sepolia 上擁有 ETH 的錢包。如果您需要 Sepolia ETH,請使用水龍頭。理想情況下,這個私鑰應該與您瀏覽器錢包中的私鑰不同。 -
啟動伺服器。
1npm run dev -
瀏覽位於 URL
http://localhost:5173(opens in a new tab) 的應用程式。 -
點擊 Connect with Injected 以連接到錢包。在錢包中授權,並在必要時授權切換到 Sepolia。
-
寫下新的問候語,然後點擊 Update greeting via sponsor。
-
簽署訊息。
-
等待約 12 秒(Sepolia 上的區塊時間)。在等待時,您可以查看伺服器主控台中的 URL 以查看交易。
-
看到問候語已更改,且最後更新者的地址值現在是您瀏覽器錢包的地址。
要了解其運作原理,我們需要看看訊息是如何在使用者介面中建立的、伺服器如何中繼它,以及智能合約如何處理它。
使用者介面
使用者介面基於 WAGMI (opens in a new tab);您可以在本教學中閱讀相關資訊。
以下是我們簽署訊息的方式:
1const signGreeting = useCallback(React hook useCallback (opens in a new tab) 讓我們能在重新繪製元件時重複使用相同的函式,從而提高效能。
1 async (greeting) => {2 if (!account) throw new Error("Wallet not connected")如果沒有帳戶,則引發錯誤。這應該永遠不會發生,因為在這種情況下,啟動呼叫 signGreeting 程序的 UI 按鈕會被停用。然而,未來的程式設計師可能會移除該保護措施,因此在這裡檢查此條件也是個好主意。
1 const domain = {2 name: "Greeter",3 version: "1",4 chainId,5 verifyingContract: contractAddr,6 }網域分隔符號 (domain separator) (opens in a new tab) 的參數。這個值是常數,所以在最佳化程度更高的實作中,我們可能會只計算一次,而不是每次呼叫函式時都重新計算。
name是使用者可讀的名稱,例如我們為其產生簽章的 dapp 名稱。version是版本。不同的版本互不相容。chainId是我們正在使用的鏈,由 WAGMI 提供 (opens in a new tab)。verifyingContract是將驗證此簽章的合約地址。我們不希望同一個簽章套用到多個合約,以防有多個Greeter合約而我們希望它們有不同的問候語。
1
2 const types = {3 GreetingRequest: [4 { name: "greeting", type: "string" },5 ],6 }我們簽署的資料類型。在這裡,我們只有一個參數 greeting,但現實生活中的系統通常會有更多參數。
1 const message = { greeting }我們實際想要簽署並發送的訊息。greeting 既是欄位名稱,也是填入該欄位的變數名稱。
1 const signature = await signTypedDataAsync({2 domain,3 types,4 primaryType: "GreetingRequest",5 message,6 })實際取得簽章。這個函式是非同步的,因為使用者需要很長的時間(從電腦的角度來看)來簽署資料。
1 const r = `0x${signature.slice(2, 66)}`2 const s = `0x${signature.slice(66, 130)}`3 const v = parseInt(signature.slice(130, 132), 16)4
5 return {6 req: { greeting },7 v,8 r,9 s,10 }11 },該函式回傳單一的十六進位值。在這裡我們將其劃分為多個欄位。
1 [account, chainId, contractAddr, signTypedDataAsync],2)如果這些變數中的任何一個發生變化,則建立該函式的新實例。使用者可以在錢包中更改 account 和 chainId 參數。contractAddr 是鏈 ID 的函式。signTypedDataAsync 應該不會改變,但我們是從一個 hook (opens in a new tab) 匯入它,所以我們無法確定,最好將其新增到這裡。
現在新的問候語已經簽署,我們需要將其發送到伺服器。
1 const sponsoredGreeting = async () => {2 try {這個函式接收一個簽章並將其發送到伺服器。
1 const signedMessage = await signGreeting(newGreeting)2 const response = await fetch("/server/sponsor", {發送到我們來源伺服器中的 /server/sponsor 路徑。
1 method: "POST",2 headers: { "Content-Type": "application/json" },3 body: JSON.stringify(signedMessage),4 })使用 POST 以 JSON 編碼發送資訊。
1 const data = await response.json()2 console.log("Server response:", data)3 } catch (err) {4 console.error("Error:", err)5 }6 }輸出回應。在生產系統上,我們也會向使用者顯示回應。
伺服器
我喜歡使用 Vite (opens in a new tab) 作為我的前端。它會自動提供 React 函式庫,並在前端程式碼更改時更新瀏覽器。然而,Vite 不包含後端工具。
解決方案在 index.js (opens in a new tab) 中。
1 app.post("/server/sponsor", async (req, res) => {2 ...3 })4
5 // 讓 Vite 處理其餘一切6 const vite = await createViteServer({7 server: { middlewareMode: true }8 })9
10 app.use(vite.middlewares)首先,我們為自己處理的請求(POST 到 /server/sponsor)註冊一個處理常式。然後我們建立並使用 Vite 伺服器來處理所有其他 URL。
1 app.post("/server/sponsor", async (req, res) => {2 try {3 const signed = req.body4
5 const txHash = await sepoliaClient.writeContract({6 address: greeterAddr,7 abi: greeterABI,8 functionName: 'sponsoredSetGreeting',9 args: [signed.req, signed.v, signed.r, signed.s],10 })11 } ...12 })這只是一個標準的 viem (opens in a new tab) 區塊鏈呼叫。
智能合約
最後,Greeter.sol (opens in a new tab) 需要驗證簽章。
1 constructor(string memory _greeting) {2 greeting = _greeting;3
4 DOMAIN_SEPARATOR = keccak256(5 abi.encode(6 keccak256(7 "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"8 ),9 keccak256(bytes("Greeter")),10 keccak256(bytes("1")),11 block.chainid,12 address(this)13 )14 );15 }建構函式建立網域分隔符號 (opens in a new tab),類似於上面的使用者介面程式碼。區塊鏈執行的成本要高得多,所以我們只計算一次。
1 struct GreetingRequest {2 string greeting;3 }這是被簽署的結構。在這裡我們只有一個欄位。
1 bytes32 private constant GREETING_TYPEHASH =2 keccak256("GreetingRequest(string greeting)");這是結構識別碼 (opens in a new tab)。它每次都會在使用者介面中計算。
1 function sponsoredSetGreeting(2 GreetingRequest calldata req,3 uint8 v,4 bytes32 r,5 bytes32 s6 ) external {這個函式接收一個已簽署的請求並更新問候語。
1 // 計算 EIP-712 摘要2 bytes32 digest = keccak256(3 abi.encodePacked(4 "\x19\x01",5 DOMAIN_SEPARATOR,6 keccak256(7 abi.encode(8 GREETING_TYPEHASH,9 keccak256(bytes(req.greeting))10 )11 )12 )13 );依照 EIP 712 (opens in a new tab) 建立摘要 (digest)。
1 // 還原簽署者2 address signer = ecrecover(digest, v, r, s);3 require(signer != address(0), "Invalid signature");使用 ecrecover (opens in a new tab) 來取得簽署者地址。請注意,錯誤的簽章仍然可能產生一個有效的地址,只不過是一個隨機的地址。
1 // 套用問候語,就像是由簽署者呼叫的一樣2 greeting = req.greeting;3 emit SetGreeting(signer, req.greeting);4 }更新問候語。
漏洞
這_不是_生產等級的程式碼。它容易受到重大攻擊且缺乏主要功能。以下列出一些漏洞,以及如何解決它們。
要查看其中一些攻擊,請點擊_攻擊 (Attacks)_ 標題下的按鈕,看看會發生什麼事。對於 Invalid signature (無效簽章) 按鈕,請檢查伺服器主控台以查看交易回應。
伺服器上的阻斷服務攻擊
最簡單的攻擊是對伺服器進行阻斷服務 (denial-of-service) (opens in a new tab) 攻擊。伺服器接收來自網際網路上任何地方的請求,並根據這些請求發送交易。完全沒有任何機制可以阻止攻擊者發出一堆簽章(無論有效或無效)。每一個都會引發一筆交易。最終伺服器將耗盡 ETH 來支付燃料費用。
解決這個問題的一種方法是將速率限制為每個區塊一筆交易。如果目的是向外部擁有帳戶顯示問候語,那麼在區塊中間的問候語是什麼其實並不重要。
另一種解決方案是追蹤地址,並僅允許來自有效客戶的簽章。
錯誤的問候語簽章
當您點擊 Signature for wrong greeting 時,您為特定的地址 (0xaA92c5d426430D4769c9E878C1333BDe3d689b3e) 和問候語 (Hello) 提交了一個有效的簽章。但它卻以不同的問候語提交。這會混淆 ecrecover,導致它更改了問候語,但卻使用了錯誤的地址。
要解決這個問題,請將地址新增到已簽署的結構 (opens in a new tab)中。這樣一來,ecrecover 產生的隨機地址就不會與簽章中的地址相符,智能合約就會拒絕該訊息。
重放攻擊
當您點擊 Replay attack 時,您提交了相同的「我是 0xaA92c5d426430D4769c9E878C1333BDe3d689b3e,我希望問候語是 Hello」簽章,但帶有正確的問候語。結果,智能合約會認為該地址(這不是您的地址)將問候語改回了 Hello。執行此操作的資訊在交易資訊 (opens in a new tab)中是公開可用的。
如果這是一個問題,一種解決方案是新增一個隨機數 (nonce) (opens in a new tab)。在地址和數字之間建立一個對映 (mapping) (opens in a new tab),並在簽章中新增一個隨機數欄位。如果隨機數欄位與該地址的對映相符,則接受該簽章並將對映遞增以供下次使用。如果不相符,則拒絕該交易。
另一種解決方案是在已簽署的資料中新增時間戳記,並僅在該時間戳記之後的幾秒鐘內接受簽章為有效。這更簡單也更便宜,但我們面臨在時間視窗內發生重放攻擊的風險,以及如果超過時間視窗,合法交易將會失敗的風險。
其他缺失的功能
在生產環境中,我們還會新增其他功能。
來自其他伺服器的存取
目前,我們允許任何地址提交 sponsorSetGreeting。為了去中心化的利益,這可能正是我們想要的。或者,我們可能希望確保贊助的交易透過_我們的_伺服器進行,在這種情況下,我們會在智能合約中檢查 msg.sender。
無論哪種方式,這都應該是一個有意識的設計決策,而不僅僅是沒有考慮到這個問題的結果。
錯誤處理
使用者提交了問候語。也許它會在下一個區塊更新。也許不會。錯誤是不可見的。在生產系統上,使用者應該能夠區分這些情況:
- 新的問候語尚未提交
- 新的問候語已提交,正在處理中
- 新的問候語已被拒絕
結論
到目前為止,您應該能夠為您的 dapp 使用者建立無燃料的體驗,代價是某種程度的中心化。
然而,這僅適用於支援 ERC-712 的智能合約。例如,要轉帳 ERC-20 代幣,必須由擁有者簽署交易,而不僅僅是訊息。解決方案是帳戶抽象化 (ERC-4337) (opens in a new tab)。我希望未來能寫一篇關於它的教學。
點此查看我更多的作品 (opens in a new tab)。
頁面最後更新: 2026年3月3日