赞助Gas费:如何为用户承担交易成本

Ethereum.org 发布于 2026-02-27 阅读 91

本文介绍如何通过服务器赞助的方式实现以太坊上的无 gas 交易,用户无需支付 gas 费。文章详细讲解了基于 EIP-712 签名的实现流程:前端使用 React/WAGMI 生成签名,后端使用 Vite/viem 将签名提交到智能合约,智能合约验证签名并更新状态。还讨论了拒绝服务攻击、签名伪造、重放攻击等漏洞及解决方案,并指出生产环境中需考虑的非ce、时间戳、错误处理等功能。示例代码已部署在 Sepolia 测试网。

引言

如果我们希望以太坊能够服务下一个十亿人,就需要消除摩擦,使其尽可能易于使用。其中一个摩擦来源是需要 ETH 来支付 Gas 费。

如果你有一个从用户那里赚钱的 dapp,那么让用户通过你的服务器提交交易并自己支付交易费用可能是合理的。因为用户仍然在他们的钱包中签署 EIP-712 授权消息,所以用户仍然保留了以太坊的完整性保证。可用性取决于中继交易的服务器,因此受到更多限制。但是,你可以设置让用户也可以直接访问智能合约(如果他们获得 ETH),并允许其他人如果他们想赞助交易则设置自己的服务器。

本教程中的技术仅在你控制智能合约时才有效。还有其他技术,包括 账户抽象,允许你赞助其他智能合约的交易,我希望在未来的教程中介绍。

注意:这不是生产级代码。它容易受到重大攻击,并且缺少主要功能。在本指南的漏洞部分了解更多信息。

先决条件

要理解本教程,你需要已经熟悉:

  • Solidity
  • JavaScript
  • React 和 WAGMI。如果你不熟悉这些用户界面工具,我们有一个教程

示例应用

这里的示例应用是 Hardhat 的 Greeter 合约的一个变体。你可以在 GitHub上看到它。该智能合约已部署在 Sepolia上,地址为 0xC87506C66c7896366b9E988FE0aA5B6dDE77CFfA

要查看实际效果,请按照以下步骤操作。

  1. 克隆仓库并安装必要的软件。
git clone https://github.com/qbzzt/260301-gasless.git
cd 260301-gasless/server
npm install
  1. 编辑 .env 文件,将 PRIVATE_KEY 设置为一个在 Sepolia 上拥有 ETH 的钱包。如果你需要 Sepolia ETH,使用水龙头。理想情况下,这个私钥应该与你浏览器钱包中的不同。

  2. 启动服务器。

npm run dev
  1. 在 URL http://localhost:5173 处浏览应用。

  2. 点击 Connect with Injected 连接到钱包。在钱包中批准,并在必要时批准切换到 Sepolia。

  3. 编写一个新的问候语,然后点击 Update greeting via sponsor

  4. 签署消息。

  5. 等待大约 12 秒(Sepolia 的出块时间)。等待时可以查看服务器控制台中的 URL 以查看交易。

  6. 查看问候语已更改,并且最后更新者地址值现在是你浏览器钱包的地址。

要理解这是如何工作的,我们需要查看消息在用户界面中是如何创建的,如何由服务器中继,以及智能合约如何处理它。

用户界面

用户界面基于 WAGMI;你可以在本教程中阅读相关内容。

以下是我们如何签署消息:

const signGreeting = useCallback(

React Hook useCallback 允许我们通过重用相同的函数来提高性能,当组件被重绘时。

    async (greeting) => {
        if (!account) throw new Error("Wallet not connected")

如果没有账户,则抛出错误。这应该永远不会发生,因为启动调用 signGreeting 过程的 UI 按钮在这种情况下是禁用的。然而,未来的程序员可能会删除该保护措施,所以在这里检查此条件也是个好主意。

        const domain = {
            name: "Greeter",
            version: "1",
            chainId,
            verifyingContract: contractAddr,
        }

域分隔符的参数。这个值是常量,因此在优化更好的实现中,我们可以计算一次而不是每次调用函数时重新计算。

  • name 是用户可读的名称,例如我们为其生成签名的 dapp 的名称。
  • version 是版本号。不同版本不兼容。
  • chainId 是我们使用的链,由 WAGMI提供。
  • verifyingContract 是验证此签名的合约地址。我们不希望同一个签名适用于多个合约,以防有多个 Greeter 合约且我们希望它们有不同的问候语。
        const types = {
            GreetingRequest: [\
                { name: "greeting", type: "string" },\
            ],
        }

我们签名的数据类型。这里只有一个参数 greeting,但现实系统通常有更多。

        const message = { greeting }

我们想要签名和发送的实际消息。greeting 既是字段名称,也是填充它的变量名称。

        const signature = await signTypedDataAsync({
            domain,
            types,
            primaryType: "GreetingRequest",
            message,
        })

实际获取签名。这个函数是异步的,因为用户需要很长时间(从计算机的角度来看)来签署数据。

        const r = `0x${signature.slice(2, 66)}`
        const s = `0x${signature.slice(66, 130)}`
        const v = parseInt(signature.slice(130, 132), 16)

        return {
            req: { greeting },
            v,
            r,
            s,
        }
    },

函数返回一个十六进制值。在这里我们将其分解为字段。

    [account, chainId, contractAddr, signTypedDataAsync],
)

如果这些变量中的任何一个发生变化,则创建函数的新实例。accountchainId 参数可以由用户在钱包中更改。contractAddr 是链 ID 的函数。signTypedDataAsync 不应更改,但我们从 一个 Hook 导入它,所以我们不能确定,最好在这里添加它。

现在新的问候语已签署,我们需要将其发送到服务器。

  const sponsoredGreeting = async () => {
    try {

此函数接受签名并将其发送到服务器。

      const signedMessage = await signGreeting(newGreeting)
      const response = await fetch("/server/sponsor", {

发送到来源服务器的路径 /server/sponsor

        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(signedMessage),
      })

使用 POST 发送 JSON 编码的信息。

      const data = await response.json()
      console.log("Server response:", data)
    } catch (err) {
      console.error("Error:", err)
    }
  }

输出响应。在生产系统上,我们还会向用户显示响应。

服务器

我喜欢使用 Vite 作为前端。它自动提供 React 库,并在前端代码更改时更新浏览器。但是,Vite 不包含后端工具。

解决方案在 index.js 中。

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

  // Let Vite handle everything else
  const vite = await createViteServer({
    server: { middlewareMode: true }
  })

  app.use(vite.middlewares)

首先,我们为自己处理的请求注册一个处理程序(POST/server/sponsor)。然后我们创建并使用一个 Vite 服务器来处理所有其他 URL。

  app.post("/server/sponsor", async (req, res) => {
    try {
      const signed = req.body

      const txHash = await sepoliaClient.writeContract({
        address: greeterAddr,
        abi: greeterABI,
        functionName: 'sponsoredSetGreeting',
        args: [signed.req, signed.v, signed.r, signed.s],
      })
    } ...
  })

这只是标准的 viem 区块链调用。

智能合约

最后,Greeter.sol 需要验证签名。

    constructor(string memory _greeting) {
        greeting = _greeting;

        DOMAIN_SEPARATOR = keccak256(
            abi.encode(
                keccak256(
                    "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
                ),
                keccak256(bytes("Greeter")),
                keccak256(bytes("1")),
                block.chainid,
                address(this)
            )
        );
    }

构造函数创建 域分隔符,类似于上面的用户界面代码。区块链执行要昂贵得多,所以我们只计算一次。

    struct GreetingRequest {
        string greeting;
    }

这是被签名的结构。这里我们只有一个字段。

    bytes32 private constant GREETING_TYPEHASH =
        keccak256("GreetingRequest(string greeting)");

这是 结构标识符。它在用户界面中每次计算。

    function sponsoredSetGreeting(
        GreetingRequest calldata req,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external {

此函数接收一个签名请求并更新问候语。

        // Compute EIP-712 digest
        bytes32 digest = keccak256(
            abi.encodePacked(
                "\x19\x01",
                DOMAIN_SEPARATOR,
                keccak256(
                    abi.encode(
                        GREETING_TYPEHASH,
                        keccak256(bytes(req.greeting))
                    )
                )
            )
        );

根据 EIP 712 创建摘要。

        // Recover signer
        address signer = ecrecover(digest, v, r, s);
        require(signer != address(0), "Invalid signature");

使用 ecrecover 获取签名者地址。注意,一个错误的签名仍然可以产生一个有效的地址,只是随机的一个。

        // Apply greeting as if signer called it
        greeting = req.greeting;
        emit SetGreeting(signer, req.greeting);
    }

更新问候语。

漏洞

不是生产级代码。它容易受到重大攻击,并且缺少主要功能。这里列出一些漏洞以及如何解决它们。

要查看其中一些攻击,请单击 Attacks 标题下的按钮并观察发生了什么。对于 Invalid signature 按钮,请检查服务器控制台以查看交易响应。

服务器上的拒绝服务攻击

最简单的攻击是对服务器的 拒绝服务 攻击。服务器从互联网的任何地方接收请求,并根据这些请求发送交易。没有任何东西可以阻止攻击者发出一堆签名,无论是有效的还是无效的。每次都会导致一笔交易。最终服务器将耗尽 ETH 来支付 Gas 费。

解决此问题的一种方法是限制每个区块一个交易。如果目的是向 外部拥有账户 显示问候语,那么在区块中间问候语是什么并不重要。

另一种解决方案是跟踪地址,只允许来自有效客户的签名。

错误的问候语签名

当你点击 Signature for wrong greeting 时,你提交了一个特定地址 (0xaA92c5d426430D4769c9E878C1333BDe3d689b3e) 和问候语 (Hello) 的有效签名。但它以不同的问候语提交。这混淆了 ecrecover,它更改了问候语但地址错误。

要解决这个问题,将地址添加到 签名结构 中。这样,ecrecover 的随机地址将不会与签名中的地址匹配,智能合约将拒绝该消息。

重放攻击

当你点击 Replay attack 时,你提交了相同的 "我是 0xaA92c5d426430D4769c9E878C1333BDe3d689b3e,我希望问候语是 Hello" 签名,但使用了正确的问候语。结果是智能合约认为该地址(不是你的)将问候语改回了 Hello。执行此操作所需的信息在 交易信息 中是公开可用的。

如果这是一个问题,一种解决方案是添加一个 随机数(nonce)。建立一个地址到数字之间的 映射,并向签名添加一个 nonce 字段。如果 nonce 字段与地址的映射匹配,则接受签名并为下次递增映射。如果不匹配,则拒绝交易。

另一种解决方案是向签名数据添加时间戳,并且只接受该时间戳后几秒钟内的签名有效。这更简单且更便宜,但我们面临时间窗口内的重放攻击风险,以及如果超出时间窗口,合法交易可能失败。

其他缺失功能

在生产环境中,我们会添加一些额外的功能。

来自其他服务器的访问

目前,我们允许任何地址提交 sponsorSetGreeting。为了去中心化的利益,这可能正是我们想要的。或者我们可能希望确保赞助交易通过我们的服务器,这种情况下我们会在智能合约中检查 msg.sender

无论哪种方式,这应该是一个有意识的设计决策,而不仅仅是未考虑问题的结果。

错误处理

用户提交了一个问候语。它可能在下个区块更新,也可能不更新。错误是不可见的。在生产系统上,用户应该能够区分以下情况:

  • 新的问候语尚未提交
  • 新的问候语已提交,正在处理中
  • 新的问候语已被拒绝

结论

至此,你应该能够为你的 dapp 用户创建无 Gas 的体验,代价是某些中心化。

然而,这仅适用于支持 ERC-712 的智能合约。例如,要转移 ERC-20 代币,需要由所有者签署交易,而不仅仅是消息。最简单的解决方案是让资产不由 EOA 地址拥有,而是由单独的合约拥有(一种简单的 账户抽象 形式)。你可以在 后续教程 中了解更多信息。

  • 原文链接: ethereum.org/developers/...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

相关文章

0 条评论