跳转到主要内容

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

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

简介

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

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

通过这种方式,我们可以为账户提供一种免 Gas 的方式来持有资产(代币等),并执行拥有 Gas 的 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 中,输入一个介于 1 到 1000 之间的数字。点击 Transfer 将代币转账到你自己的地址。在点击确认请求之前,请注意正在签名的数据是不透明的。用户很难理解他们正在签名什么。请记住,我们将在下文中讨论这个问题。

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

工作原理

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

钱包智能合约

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

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

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

所有者的身份和一个用于防止消息被重复使用的随机数 (在新标签页中打开)。因为随机数是一个 public 变量,Solidity 编译器还会创建一个视图函数 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 转账。但大多数用户不知道如何解释调用数据,也不知道他们正在签名什么。这是糟糕的设计,原因有两个:

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

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

Screen capture with transfer details

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

Screen capture with truncated addresses

结论

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

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

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

在此处查看我的更多作品 (在新标签页中打开)