跳转到主要内容

让你的免 Gas 用户持有代币并调用合约

免 Gas
ERC-20
账户抽象
中级
奥里·波梅兰茨
2026年4月1日
24 分钟阅读

简介

上一篇文章讨论了使用 EIP-712 签名对你自己的应用程序进行免 Gas 访问,但这仅限于你自己的智能合约。使用账户抽象,我们可以创建智能合约钱包,接受两种类型的交易并将其转发到请求的目的地:

  • 由特定外部拥有账户 (EOA) 发送的交易(这要求该 EOA 拥有 ETH)
  • 从任何地方发送,但由同一个 EOA 签名的交易。

通过这种方式,我们可以为账户提供一种免 Gas 的方式来持有资产(代币等),并执行拥有 Gas 的 EOA 所能执行的所有功能。

为什么我们不能直接转发请求?

在 ERC-20 及相关标准中,账户所有者是 msg.sender (opens in a new tab),即调用代币合约的地址,这不一定是交易的发起者 tx.origin (opens in a new tab)。这是出于安全原因 (opens in a new tab)的要求。这意味着如果我们转发代币转账请求,它们将尝试从转发者的地址而不是用户控制的地址转账代币。

有一种解决方案允许你通过 EIP-7702 (opens in a new tab) 使用 EOA 地址,但这需要签名一个具有潜在危险的委托,因此你只能将其用于委托给钱包提供商授权的智能合约。对于本教程,我更倾向于使用一种简单得多的方法,即创建一个智能合约作为用户的代理。

实际操作演示

  1. 确保你已安装 Node (opens in a new tab)Foundry (opens in a new tab)

  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 (opens in a new tab) 的应用程序。

  6. 点击 Connect with Injected 连接到钱包。在钱包中授权,并在必要时授权切换到 Sepolia 网络。

  7. 向下滚动并点击 Deploy UserProxy (slow process)

  8. 当用户代理部署完成时,你可以在 UserProxy access 旁边看到一个地址。如果你等待了 24 秒(2 个区块)但仍未出现,则可能是检测更改时出现了问题。

    如果是这种情况,请前往 Sepolia 区块浏览器 (opens in a new tab),并输入你在服务器输出 npm run dev 处看到的部署交易哈希。点击创建的合约以查看其地址,然后复制它。将该地址粘贴到 Or enter existing proxy address 字段中,然后点击 Set proxy address

  9. 点击 Request more tokens for proxy,向 ERC-20 合约的 faucet (opens in a new tab) 函数提交调用以获取代币。在钱包中确认签名。当然,代币会到达代理的地址,而不是用户的地址。

  10. 向下滚动并点击 Last transaction: 下方的链接。这将在浏览器中打开以向你显示 faucet 交易。

  11. amount to transfer 中,输入一个介于 1 到 1000 之间的数字。点击 Transfer 将代币转账到你自己的地址。在点击确认请求之前,请注意正在签名的数据是不透明的。用户很难理解他们正在签名什么。请记住,我们将在下文中讨论这个问题。

  12. 交易确认后,等待查看 your balanceproxy balance 的变化。请注意,这也需要一些时间,因为 Sepolia 的出块时间为 12 秒。

工作原理

为了实现免 Gas 体验,我们需要一个供用户使用的用户界面、一个将消息从用户界面路由到链上的服务器,以及一个接收并验证这些消息的智能合约。

钱包智能合约

这是智能合约 (opens in a new tab)。它的目的是执行真正所有者请求的任何操作,无论使用何种渠道进行请求,并忽略其他所有内容。为此,其函数接收要调用的目标地址以及用于调用它的数据。

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

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

所有者的身份和一个用于防止消息被重复使用的随机数 (opens in a new tab)。因为随机数是一个 public 变量,Solidity 编译器还会创建一个视图函数 nonce() (opens in a new tab),允许链下代码读取其值。

    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 签名 (opens in a new tab)所需的信息。

    constructor(address owner_) {
        OWNER = owner_;

一个 UserProxy 绑定到一个单一的所有者地址。这是必要的,因为它可以拥有资产(ERC-20 代币、NFT 等)。我们不想将属于不同所有者的资产混合在一起。

域分隔符 (opens in a new tab)。它不能在编译时计算,因为它取决于链 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 编写的;你可以在此处 (opens in a new tab)查看源代码。

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 (opens in a new tab) 服务器,它使用 Vite (opens in a new tab) 来提供用户界面代码。我们使用 Viem (opens in a new tab) 与区块链通信,并使用 dotenv (opens in a new tab) 读取发送交易的地址的私钥。

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) => {

这是处理部署代理请求的代码。请注意,我们在这里容易受到拒绝服务 (opens in a new tab)攻击,因为攻击者可以向我们发送大量部署代理的请求,直到我们的 ETH 耗尽。在生产系统上,我们可能会要求部署代理的请求必须经过签名,并且签名者必须是现有客户。

    try {
      const ownerAddress = req.body.ownerAddress

从请求中获取所有者的地址。

部署合约 (opens in a new tab)等待其部署完成 (opens in a new tab)

      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,它会为我们处理用户界面的服务。

用户界面

这是用户界面代码 (opens in a new tab)。除了 Token.jsx (opens in a new tab) 之外,大部分代码与本文中记录的代码几乎相同。

Token.jsx (opens in a new tab) 的部分内容类似于本文中的 Greeter.jsx (opens in a new tab)。以下是新增的部分。

import {
   encodeFunctionData
       } from 'viem'

此函数 (opens in a new tab)为 EVM 函数调用创建调用数据。这是必要的,以便用户可以对调用数据进行签名。

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

UserProxy,如上所述。

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

此合约 (opens in a new tab)主要是一个普通的 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: [],
  })

为了防止重放攻击(例如,卖家重放一笔给他们钱的交易),我们使用一个随机数 (opens in a new tab)。我们需要知道当前值,以便将其添加到我们签名的数据中。

当从区块链读取的信息发生变化时,使用 useEffect (opens in a new tab) 更新显示给用户的余额。

  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 (opens in a new tab) 创建要签名和发送的调用数据。

  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 转账。但大多数用户不知道如何解释调用数据,也不知道他们正在签名什么。这是糟糕的设计,原因有两个:

  • 一些用户不会使用我们,因为他们不信任我们让他们签名的数据。
  • 其他用户_会_信任我们,并认为他们应该直接对调用数据进行签名,而无需了解它是什么。这意味着如果攻击者 Adam 设法将他们重定向到他的网站,他就可以让他们签名一笔交易,将用户拥有的所有 USDC(或 DAI,或任何其他 ERC-20)授权给他。

解决方案是在 UserProxy 中为常用函数(例如转账)提供单独的函数。这样用户就可以对他们理解的内容进行签名。

Screen capture with transfer details

注意:虽然用户可以使用他们想要的任何钱包,但强烈建议使用 EIP-712 的应用程序鼓励他们使用显示完整签名数据 (opens in a new tab)的钱包。一些钱包会截断地址,这是不安全的。攻击者可以创建一个开头和结尾字符相同,但中间不同的地址。

Screen capture with truncated addresses

结论

除了上述漏洞之外,本教程中的解决方案还有几个以太坊可以帮助我们解决的缺点。

  • 抗审查性。目前,用户可以使用你的服务器、其他人设置的竞争服务器,或者直接连接到以太坊(这会产生 Gas 成本)。使用 ERC-4337 (opens in a new tab) 允许用户将他们的交易提供给大型服务器池,从而降低其交易被审查的可能性。
  • EOA 拥有的资产。如上所述,EIP-7702 (opens in a new tab) 可用于管理 EOA 地址已拥有的资产。这有其困难,但有时是必要的。

我希望在不久的将来发布有关添加这些功能的教程。

在此处查看我的更多作品 (opens in a new tab)