跳至主要內容

讓你的免燃料使用者持有代幣並呼叫合約

免燃料
ERC-20
帳戶抽象化
中階
奧里·波梅蘭茨
2026年4月1日
25 分鐘閱讀

簡介

上一篇文章討論了如何使用 EIP-712 簽章對你自己的應用程式進行免燃料存取,但這僅限於你自己的智能合約。透過帳戶抽象化,我們可以建立智能合約錢包,接受兩種類型的交易並將其轉發到請求的目的地:

  • 由特定外部擁有帳戶 (EOA) 發送的交易(這要求該 EOA 擁有 ETH)
  • 從任何地方發送,但由同一個 EOA 簽署的交易。

透過這種方式,我們可以為帳戶提供一種免燃料的方式來持有資產(代幣等),並執行擁有燃料的 EOA 所能執行的所有功能。

為什麼我們不能直接轉發請求?

在 ERC-20 及相關標準中,帳戶擁有者是 msg.sender (在新分頁開啟),即呼叫代幣合約的地址,這不一定是交易的發起者 tx.origin (在新分頁開啟)。這是基於安全考量 (在新分頁開啟)的要求。這意味著如果我們轉發代幣轉帳請求,它們將嘗試從轉發者的地址轉帳代幣,而不是從使用者控制的地址轉帳。

有一個解決方案可以讓你透過 EIP-7702 (在新分頁開啟) 使用 EOA 地址,但這需要簽署一個具有潛在危險的委託,因此你只能將其用於委託給錢包提供商核准的智能合約。在本教學中,我傾向於使用更簡單的方法,即建立一個智能合約作為使用者的代理。

實際操作

  1. 確保你已安裝 Node (在新分頁開啟)Foundry (在新分頁開啟)

  2. 複製應用程式並安裝必要的軟體。

    git clone https://github.com/qbzzt/260315-gasless-tokens.git
    cd 260315-gasless-tokens
    forge build
    cd server
    npm install
    
  3. 編輯 .env,將 SEPOLIA_PRIVATE_KEY 設定為在 Sepolia 上擁有 ETH 的錢包。如果你需要 Sepolia ETH,請使用水龍頭來獲取。理想情況下,這個私鑰應該與你瀏覽器錢包中的私鑰不同。

  4. 啟動伺服器。

    npm run dev
    
  5. 瀏覽位於 URL http://localhost:5173 (在新分頁開啟) 的應用程式。

  6. 點擊 Connect with Injected 以連接到錢包。在錢包中授權,並在必要時授權切換到 Sepolia 網路。

  7. 向下捲動並點擊 Deploy UserProxy (slow process)

  8. 當使用者代理部署完成時,你可以在 UserProxy access 旁邊看到一個地址。如果你等待了 24 秒(2 個區塊)但仍未出現,可能是偵測變更時發生了問題。

    如果發生這種情況,請前往 Sepolia 區塊鏈瀏覽器 (在新分頁開啟),並輸入你在伺服器輸出 npm run dev 中看到的部署交易雜湊值。點擊已建立的合約以檢視其地址,然後將其複製。將地址貼上到 Or enter existing proxy address 欄位中,然後點擊 Set proxy address

  9. 點擊 Request more tokens for proxy 以提交對 ERC-20 合約 faucet (在新分頁開啟) 函式的呼叫來獲取代幣。在錢包中確認簽章。當然,代幣會到達代理的地址,而不是使用者的地址。

  10. 向下捲動並點擊 Last transaction: 下方的連結。這將開啟瀏覽器以向你顯示 faucet 交易。

  11. amount to transfer 中,輸入一個介於一到一千之間的數字。點擊 Transfer 將代幣轉帳到你自己的地址。在點擊確認請求之前,請注意正在簽署的資料是不透明的。使用者很難理解他們正在簽署什麼。請記住,我們將在下方討論這個問題。

  12. 交易確認後,等待查看 your balanceproxy balance 的變化。請注意,這也需要一些時間,因為 Sepolia 的區塊時間為 12 秒。

運作原理

為了實現免燃料體驗,我們需要一個供使用者操作的使用者介面、一個將訊息從使用者介面路由到鏈上的伺服器,以及一個接收並驗證這些訊息的智能合約。

錢包智能合約

這是智能合約 (在新分頁開啟)。它的目的是執行真正擁有者請求的任何操作,無論使用何種管道進行請求,並忽略其他所有內容。為此,其函式會接收一個要呼叫的目標地址以及用於呼叫該地址的資料。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

contract UserProxy {
    address immutable OWNER;
    uint public nonce = 0;

擁有者的身分和一個隨機數 (在新分頁開啟),用於防止訊息被重複發送。因為隨機數是一個 public 變數,Solidity 編譯器也會建立一個 view 函式 nonce() (在新分頁開啟),允許鏈下程式碼讀取其值。

    bytes32 private constant SIGNED_ACCESS_TYPEHASH =
        keccak256("SignedAccess(address target,bytes data,uint256 nonce)");

    bytes32 private constant SIGNED_ACCESS_PAYABLE_TYPEHASH =
        keccak256("SignedAccessPayable(address target,bytes data,uint256 nonce,uint256 value)");

    bytes32 immutable DOMAIN_SEPARATOR;

驗證 EIP-712 簽章 (在新分頁開啟)所需的資訊。

    constructor(address owner_) {
        OWNER = owner_;

一個 UserProxy 綁定到單一擁有者地址。這是必要的,因為它可以擁有資產(ERC-20 代幣、NFT 等)。我們不希望將屬於不同擁有者的資產混合在一起。

網域分隔符 (在新分頁開啟)。它無法在編譯時計算,因為它取決於鏈 ID 和合約地址。這使得 UserProxy 不可能被為另一個代理準備的訊息所欺騙。

    event CallResult(address target, bytes returnData);

記錄呼叫的結果。

    function directAccess(address target, bytes calldata data)
            external returns (bytes memory) {

這個函式可以直接由擁有者呼叫。如果沒有可用的轉發器,擁有者仍然可以直接在區塊鏈上存取資產(如果使用者擁有 ETH)。

        require(msg.sender == OWNER, "Only owner can call");
        (bool success, bytes memory returnData) = target.call(data);
        require(success, "Call failed");

        emit CallResult(target, returnData);

        return returnData;
    }

如果我們被擁有者_直接_呼叫,則使用提供的呼叫資料呼叫目標。

    function signedAccess(
        address target,
        bytes calldata data,
        uint8 v,
        bytes32 r,
        bytes32 s)

這是 UserProxy 的主要函式。它接收 targetdata,以及一個簽章。

摘要也包含隨機數,但我們不需要從交易中接收它;我們已經知道正確的值。具有錯誤隨機數的簽章將被拒絕。


    // 還原簽署者
    address signer = ecrecover(digest, v, r, s);
    require(signer == OWNER, "Signature invalid or not by owner");

如果簽章無效,ecrecover 通常會回傳一個不同的地址,並且不會被接受。

    (bool success, bytes memory returnData) = target.call(data);
    require(success, "Call failed");

呼叫使用者告訴我們要呼叫的合約,如果不成功則回滾。

    emit CallResult(target, returnData);

    nonce++; // 遞增隨機數以防止重放

    return returnData;
}

如果成功,則發出日誌事件並遞增隨機數。

這些是幾乎相同的變體,讓你也將 ETH 從合約中轉帳出去。

轉發器

轉發器是一個伺服器元件。它是用 JavaScript 編寫的;你可以在這裡 (在新分頁開啟)查看原始碼。

import express from "express";
import { createServer as createViteServer } from "vite";
import { createWalletClient, createPublicClient, http } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { sepolia } from 'viem/chains'
import dotenv from 'dotenv'

我們需要的函式庫。這是一個 Express (在新分頁開啟) 伺服器,它使用 Vite (在新分頁開啟) 來提供使用者介面程式碼。我們使用 Viem (在新分頁開啟) 與區塊鏈通訊,並使用 dotenv (在新分頁開啟) 讀取發送交易之地址的私鑰。

import { createRequire } from 'module'
const require = createRequire(import.meta.url)
const UserProxy = require('../contracts/out/UserProxy.sol/UserProxy.json')

這是讀取已編譯之 UserProxy 的簡單方法。我們需要 ABI 才能呼叫 UserProxy,並需要編譯後的程式碼才能為使用者部署它。

dotenv.config()
const sepoliaAccount = privateKeyToAccount(process.env.SEPOLIA_PRIVATE_KEY)
console.log("Using account:", sepoliaAccount.address)

讀取 .env 檔案,提取地址,並將其列印到主控台。

與區塊鏈通訊的 Viem 客戶端。

const start = async () => {
  const app = express()

執行 Express 伺服器。

  app.use(express.json())

告訴 Express 讀取請求主體,如果它是 JSON 則對其進行解析。

  app.post("/server/deploy", async (req, res) => {

這是處理部署代理請求的程式碼。請注意,我們在這裡容易受到阻斷服務 (在新分頁開啟)攻擊,因為攻擊者可以向我們發送大量部署代理的請求,直到我們的 ETH 耗盡。在生產系統上,我們可能會要求部署代理的請求必須經過簽署,並且簽署者必須是現有客戶。

    try {
      const ownerAddress = req.body.ownerAddress

從請求中獲取擁有者的地址。

部署合約 (在新分頁開啟)等待其部署完成 (在新分頁開啟)

      res.json({ contractAddress: receipt.contractAddress })

如果一切正常,則將代理地址回傳給使用者介面。

    } catch (err) {
      console.error(err)
      res.status(500).json({ error: err.message })
    }
  })

如果出現問題,則回報它。

  app.post("/server/message", async (req, res) => {

這是處理 UserProxy 合約之使用者訊息的程式碼。這是另一個容易受到阻斷服務攻擊的地方。

獲取請求資料並使用它來呼叫代理上的 signedAccess

      console.log("Message transaction hash:", txHash)

      res.json({ txHash })

回報交易雜湊值。這讓 UI 可以顯示一個 URL,供使用者檢查交易。

    } catch (err) {
      console.error(err)
      res.status(500).json({ error: err.message })
    }
  })

同樣地,如果出現問題,則回報它。

對於其他所有內容,請使用 Vite,它會為我們處理提供使用者介面的工作。

使用者介面

這是使用者介面程式碼 (在新分頁開啟)。除了 Token.jsx (在新分頁開啟) 之外,大部分程式碼與這篇文章中記錄的程式碼幾乎相同。

Token.jsx (在新分頁開啟) 的部分內容類似於這篇文章中的 Greeter.jsx (在新分頁開啟)。以下是新的部分。

import {
   encodeFunctionData
       } from 'viem'

這個函式 (在新分頁開啟)會建立 EVM 函式呼叫的呼叫資料。這是必要的,以便使用者可以簽署呼叫資料。

import UserProxy from '../../contracts/out/UserProxy.sol/UserProxy.json'

UserProxy,如上所述。

import Erc20 from '../../contracts/out/Faucet.sol/FaucetToken.json'

這個合約 (在新分頁開啟)主要是一個普通的 ERC-20 合約,但增加了一個重要的函式 faucet()。這個函式會將代幣授予任何出於測試目的而請求它們的人。

const erc20Addrs = {
  // Sepolia
    11155111: '0x4cBedDEDA88fDd9e116618a5cD71BB0E440C2A78'
}

FaucetToken 的地址。

const Address = ({ address }) => {
   if (!address) return null
   return (
      <a href={`https://eth-sepolia.blockscout.com/address/${address}?tab=read_write_contract`} target="_blank">{address}</a>
   )
}

這個元件會輸出一個地址,並附帶區塊鏈瀏覽器上該合約的連結。

const Token = () => {
    ...

這是完成大部分工作的主要元件。

  const [ balanceAmount, setBalanceAmount ] = useState("Loading...")

使用者地址的代幣餘額。

  const [ proxyAddr, setProxyAddr ] = useState(null)

使用者擁有的代理地址。

  const [ proxyBalanceAmount, setProxyBalanceAmount ] = useState("Loading...")

代理的代幣餘額。

  const [ newProxyAddr, setNewProxyAddr ] = useState("")

當使用者手動設定代理地址時,會使用此欄位。能夠手動設定代理地址讓使用者可以使用現有的代理,而不是每次都部署一個新的代理(並失去舊代理擁有的所有代幣)。

  const [ txHash, setTxHash ] = useState(null)

最後一筆交易的雜湊值,用於顯示瀏覽器的連結,以便使用者可以檢查該交易。

  const [ transferToken, setTransferToken ] = useState("")
  const [ transferAmount, setTransferAmount ] = useState("")
  const [ transferTo, setTransferTo ] = useState("")

這些欄位都用於向 ERC-20 合約發送代幣轉帳命令。這可能是 FaucetToken,但並非必須如此。transfer 函式是 ERC-20 標準的一部分。

  const balance = useReadContract({
    ...
  })


  const proxyBalance = useReadContract({
    ...
  })

讀取我們感興趣的兩個代幣餘額:使用者擁有多少,以及代理擁有多少。

  const nonce = useReadContract({
      address: proxyAddr,
      abi: UserProxy.abi,
      functionName: 'nonce',
      args: [],
  })

為了防止重放攻擊(例如,賣家重放一筆給他們錢的交易),我們使用一個隨機數 (在新分頁開啟)。我們需要知道目前的值,以便將其加入我們簽署的資料中。

當從區塊鏈讀取的資訊發生變化時,使用 useEffect (在新分頁開啟) 來更新顯示給使用者的餘額。

  useEffect(() => {
    setTransferToken(faucetAddr)
  }, [faucetAddr])

  useEffect(() => {
    setTransferTo(account.address)
  }, [account.address])

預設是將 FaucetToken 代幣轉帳到使用者自己的帳戶。在這裡,當我們從 Viem 接收到這些值時,我們會設定它們。

  const proxyAddressChange = (evt) => setNewProxyAddr(evt.target.value)
  const transferTokenChange = (evt) => setTransferToken(evt.target.value)
  const transferToChange = (evt) => setTransferTo(evt.target.value)
  const transferAmountChange = (evt) => setTransferAmount(evt.target.value)

當文字欄位變更時的事件處理常式。

要求伺服器為此使用者部署一個代理。

  const signMessage = async(proxyAddr, target, calldata) => {

在將訊息發送到伺服器以發送到鏈上的 UserProxy 之前,先簽署該訊息。這在這裡有解釋。我們需要簽署一個包含目標地址(我們正在呼叫的代幣地址)和要發送的呼叫資料的訊息。

    const domain = {
      .
      .
      .
    return {v, r, s}
  }

  const messageUserProxy = async (proxy, target, data, v, r, s) => {

將已簽署的訊息發送到 UserProxy,它將驗證簽章,然後將其發送到 target

向伺服器發送請求,當你收到回應時,獲取交易雜湊值。

  const faucetSimulation = useSimulateContract({
    address: faucetAddr,
    abi: Erc20.abi,
    functionName: 'faucet',
    account: account.address
  })

模擬呼叫 faucet 函式。只有在成功時,我們才會啟用該水龍頭按鈕。

要透過伺服器和 UserProxy 呼叫函式,我們遵循三個步驟:

  1. 使用 encodeFunctionData (在新分頁開啟) 建立要簽署和發送的呼叫資料。

  2. 簽署訊息(目標地址、呼叫資料和隨機數)。

  3. 將訊息發送到伺服器。

元件的這部分讓你直接從瀏覽器使用 FaucetToken。其主要目的是為了方便除錯。

         <h4>UserProxy access <Address address={proxyAddr} /></h4>
         <button onClick={deployUserProxy}>
         Deploy UserProxy (slow process)
         </button>

讓使用者部署一個新的 UserProxy

只有當使用者輸入合法的地址時,才讓他們點擊 Set proxy address。請注意,這並不能確保該地址確實是一個 UserProxy 合約。可以加入這樣的檢查,但這會慢得多(使用者體驗較差),而且不會提高安全性(攻擊者總是可以使用他們自己的程式碼來建立使用者介面)。

         <br /><br />
         { proxyAddr && (

只有在有合法的代理地址時,才顯示其餘部分。

            <>
               Proxy balance: {proxyBalanceAmount}
               <br />
               Proxy nonce: {nonce?.data?.toString() ?? "Loading..."}

使用者不需要知道隨機數;這只是為了除錯目的。

               <br />
               <button disabled={!proxyAddr || proxyAddr === "Loading..." || nonce?.status !== 'success'}
                  onClick={proxyFaucet}
               >
                  Request more tokens for proxy
               </button>

我們無法模擬透過代理呼叫 faucet()。然而,我們至少可以確保我們有一個代理,並且該代理向我們回報了一個隨機數。

讓使用者發出 ERC-20 轉帳交易。

如果有最後一筆交易雜湊值,則顯示一個連結,以便使用者可以在區塊鏈瀏覽器中檢視它。

 
</div>
    </>
  )
}

export {Token}

這只是 React 樣板程式碼。

漏洞

我們的伺服器容易受到阻斷服務攻擊。這種攻擊在本系列的前一篇文章中有解釋。

此外,我們正在鼓勵不良的使用者行為。這是我們要求使用者簽署的內容:

Screen capture with opaque calldata

我們知道這是一筆合法的 ERC-20 轉帳,包含使用者想要轉帳的代幣、金額和目標地址。但大多數使用者不知道如何解讀呼叫資料,也不知道他們正在簽署什麼。這是一個糟糕的設計,原因有兩個:

  • 有些使用者不會使用我們的服務,因為他們不信任我們要求他們簽署的資料。
  • 其他使用者_會_信任我們,並學到他們應該直接簽署呼叫資料,而不需要理解它是什麼。這意味著如果攻擊者亞當成功將他們重新導向到他的網站,他可以讓他們簽署一筆交易,將使用者擁有的所有 USDC(或 DAI,或任何其他 ERC-20)授予他。

解決方案是在 UserProxy 中為常用的函式(例如轉帳)提供獨立的函式。這樣使用者就可以簽署他們理解的內容。

Screen capture with transfer details

注意:雖然使用者可以使用任何他們想要的錢包,但強烈建議使用 EIP-712 的應用程式鼓勵他們使用能顯示完整簽章資料 (在新分頁開啟)的錢包。有些錢包會截斷地址,這是不安全的。攻擊者可以建立一個開頭和結尾字元相同,但中間不同的地址。

Screen capture with truncated addresses

結論

除了上述漏洞之外,本教學中的解決方案還有幾個缺點,以太坊可以幫助我們解決這些問題。

  • 抗審查性。目前,使用者可以使用你的伺服器、其他人建立的競爭伺服器,或者直接連接到以太坊(這會產生燃料費用)。使用 ERC-4337 (在新分頁開啟) 讓使用者可以將他們的交易提供給大型伺服器池,從而降低他們的交易被審查的可能性。
  • EOA 擁有的資產。如上所述,EIP-7702 (在新分頁開啟) 可用於管理 EOA 地址已擁有的資產。這有其困難之處,但有時是必要的。

我希望在不久的將來發布有關加入這些功能的教學。

在這裡查看我的更多作品 (在新分頁開啟)