使用 Web 应用程序中继 Gasless Meta-Transactions
Defender 提供了一种无缝且安全的方式,使用 Relayer 实现 gasless meta-transactions。这些 Relayer 代表用户处理发送交易,从而消除了用户管理私钥、交易签名、nonce 管理、gas 预估和交易包含的需求。
这个演示应用程序展示了如何不仅使用 ERC-2771 实现 meta-transactions,还探索了其他 gasless transaction 标准:
ERC-2771: 安全原生 Meta Transactions:这个演示应用程序 使用 ERC2771Forwarder 和 ERC2771Context 来实现 meta-transactions,从而将
msg.sender与 Relayer 的地址分离。用户需要做的就是使用他们想要从中发出交易的帐户签署消息。使用用户的私钥,为目标合约和所需交易的数据形成签名。此签名是链下发生的,不花费任何 gas。签名传递给 Relayer,以便它可以为用户执行交易(并支付 gas)。ERC-2612: Permit 函数:此标准引入了一种在 ERC-20 代币中启用 gasless 代币批准的方法。用户可以通过签署消息而不是直接支付传统 "approve" 函数的 gas 费用来授予 relayer 服务支出权限。这允许 relayer 代表用户处理代币批准。
ERC-3009: Transfer with Authorization:此标准通过链下授权促进 gasless 代币转账。用户签署消息,授权特定的代币转账,然后任何人(包括 relayer 服务)都可以将其提交到区块链。
使用带有 Relayer 的 Defender 可以轻松安全地实现 gasless meta-transaction 中继,这使你可以轻松发送交易,而无需管理私钥、交易签名、nonce 管理、gas 预估和交易包含。
准备工作
OpenZeppelin Defender 帐户。你可以在此处注册 Defender。
已安装 Git 和 Yarn
1. ERC-2771:安全原生 Meta Transactions
你可以在此处查看实时演示应用程序。如果用户有可用资金来支付交易费用,它会直接接受注册,否则数据将作为 meta-transaction 发送。
在示例代码中,SimpleRegistry 合约 的功能是获取一个字符串并存储它。该合约的 meta-transaction 实现 通过将签名者与交易发送者分离来实现相同的结果。
在比较代码时,请注意 meta-transaction 使用 _msgSender() 而不是 SimpleRegistry 使用 msg.sender。通过从 ERC2771Context 和 ERC2771Forwarder 扩展,该合约变得能够进行 meta-transaction。
所有 OpenZeppelin 合约都与 _msgSender() 的使用兼容。 |
两个合约之间的第二个根本变化是 meta-transaction 合约(Registry)需要指定受信任 forwarder 的地址,在本例中,它是 ERC2771Forwarder 合约的地址。
1.1 配置项目
首先,fork 该存储库并导航到本指南的目录。在那里,使用 yarn 安装依赖项:
$ git clone https://github.com/openzeppelin/workshops.git $ cd workshops/25-defender-metatx-api/ $ yarn在项目根目录中创建一个 .env 文件,并从 API 密钥页面 提供 API 密钥和密钥。私钥将用于本地测试,但 Relayer 用于实际的合约部署。
PRIVATE_KEY="0xabc" API_KEY="abc" API_SECRET="abc"1.2 创建 Relayer
运行 Relayer 创建脚本,它将使用 .env 文件中的 Defender API 参数:
$ yarn create-relayRelayer 使用 defender-sdk 包创建:
// ... const client = new Defender(creds); // 使用 Defender SDK 客户端创建 Relayer。 const requestParams = { name: 'MetaTxRelayer', network: 'sepolia', minBalance: BigInt(1e17).toString(), }; const relayer = await client.relay.create(requestParams); // ...创建后,该脚本将获取 Relayer ID 并创建一个 API 密钥和密钥集以通过它发送交易。Relayer ID 自动存储在 relayer.json 文件中,其 API 参数存储在 .env 文件中。
1.3 使用 Hardhat 编译合约
在 contracts 目录中,你可以找到 SimpleRegistry.sol 和 Registry.sol 合约。前者合约包含 meta-transaction 功能,你可以在此处看到:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "@openzeppelin/contracts/metatx/ERC2771Context.sol"; import "@openzeppelin/contracts/metatx/ERC2771Forwarder.sol"; contract Registry is ERC2771Context { event Registered(address indexed who, string name); mapping(address => string) public names; mapping(string => address) public owners; constructor(ERC2771Forwarder forwarder) // 初始化受信任 forwarder ERC2771Context(address(forwarder)) { } function register(string memory name) external { require(owners[name] == address(0), "Name taken"); address owner = _msgSender(); // 从 msg.sender 更改 owners[name] = owner; names[owner] = name; emit Registered(owner, name); } }运行 npx hardhat compile 以编译它以进行部署。
1.4 使用 Relayer 部署
你可以使用 defender-sdk 包中的 Relayer 客户端轻松部署已编译的智能合约,而无需处理私钥。
deploy.js 脚本从本地 .env 文件中提取 Relayer 的凭据,以及 Registry 和 ERC2771Forwarder 合约的 artifacts,并使用 ethers.js 进行部署。这些合约的相关地址将保存到本地文件 deploy.json 中。
// ... const creds = { relayerApiKey: process.env.RELAYER_API_KEY, relayerApiSecret: process.env.RELAYER_API_SECRET, }; const client = new Defender(creds); const provider = client.relaySigner.getProvider(); const signer = client.relaySigner.getSigner(provider, { speed: 'fast' }); const forwarderFactory = await ethers.getContractFactory('ERC2771Forwarder', signer) const forwarder = await forwarderFactory.deploy('ERC2771Forwarder') .then((f) => f.deployed()) const registryFactory = await ethers.getContractFactory('Registry', signer) const registry = await registryFactory.deploy(forwarder.address) .then((f) => f.deployed()) // ...使用 yarn deploy 运行此脚本。
合约部署后,可以安全地删除 Relayer 密钥和密钥;除非需要额外的本地测试,否则不需要它们。合约地址将保存在 deploy.json 文件中。
1.5 通过 API 创建 Action
演示应用程序使用 Action 来提供必要的逻辑,以告诉 Relayer 将交易发送到 Forwarder 合约,并提供签名者的地址。每次从应用程序调用其 webhook 时,都会触发 Action。
由于组件之间的紧密关系,Relayer 凭据可以通过简单地实例化一个新的 provider 和 signer 安全地提供给 Action。
Action 在这里的位置至关重要 — 只有 Action 的 webhook 暴露给前端。Action 的作用是根据分配给它的逻辑执行交易:如果用户有资金,他们支付交易费用。如果不是,则 Relayer 支付交易费用。
重要的是,Relayer 的 API 密钥和密钥与前端隔离。如果 Relayer 密钥暴露,任何人都可以潜在地使用 Relayer 发送他们想要的任何交易。
这是 Action 的代码,可以在 action/index.js 中找到:
const { Defender } = require('@openzeppelin/defender-sdk'); const { ethers } = require('hardhat') const { ForwarderAbi } = require('../../src/forwarder'); const ForwarderAddress = require('../../deploy.json').ERC2771Forwarder; async function relay(forwarder, request, signature, whitelist) { // 根据白名单决定是否要中继此请求 const accepts = !whitelist || whitelist.includes(request.to); if (!accepts) throw new Error(`Rejected request to ${request.to}`); // 在 forwarder 合约上验证请求 const valid = await forwarder.verify(request, signature); if (!valid) throw new Error(`Invalid request`); // 通过 relayer 将 meta-tx 发送到 forwarder 合约 const gasLimit = (parseInt(request.gas) + 50000).toString(); return await forwarder.execute(request, signature, { gasLimit }); } async function handler(event) { // 解析 webhook 有效负载 if (!event.request || !event.request.body) throw new Error(`Missing payload`); const { request, signature } = event.request.body; console.log(`Relaying`, request); // 初始化 Relayer provider 和 signer,以及 forwarder 合约 const creds = { ... event }; const client = new Defender(creds); const provider = client.relaySigner.getProvider(); const signer = client.relaySigner.getSigner(provider, { speed: 'fast' }); const forwarder = new ethers.Contract(ForwarderAddress, ForwarderAbi, signer); // 中继交易! const tx = await relay(forwarder, request, signature); console.log(`Sent meta-tx: ${tx.hash}`); return { txHash: tx.hash }; } module.exports = { handler, relay, }请注意,Action 代码必须包含一个 index.js 文件,该文件导出一个 handler 入口点。如果代码依赖于任何外部依赖项(例如导入的 ABI),则必须使用 webpack、rollup 等捆绑 Action。你可以通过 Defender 或使用 defender-sdk 包创建一个 Action。
运行 yarn create-action 以编译代码并通过 SDK 的 action.create() 方法使用捆绑的代码创建 Action:
// ... const { actionId } = await client.action.create({ name: "Relay MetaTx", encodedZippedCode: await client.action.getEncodedZippedCodeFromFolder('./build/action'), relayerId: relayerId, trigger: { type: 'webhook' }, paused: false }); // ...前往 Defender Actions 并复制 Actions 的 webhook,以便你可以测试功能并将应用程序连接到 Action 以中继 meta-transactions。

将 Action webhook 保存到你的 .env 文件中作为 WEBHOOK_URL,并在 /app .env 文件中作为 REACT_APP_WEBHOOK_URL。
使用 yarn sign 后跟 yarn invoke 测试 meta-transaction 的功能。
1.6 创建 Web 应用程序
关键构建块已经铺设好,所以接下来就是制作一个利用这些组件的 Web 应用程序。
你可以在 register.js 文件中看到这种关系的详细信息。用户的交易请求通过 Action 的 webhook 发送到 Relayer,这会根据应用程序提供的参数执行 Action 的逻辑。请注意,签名者的 nonce 是从交易中递增的。
import { ethers } from 'ethers'; import { createInstance } from './forwarder'; import { signMetaTxRequest } from './signer'; async function sendTx(registry, name) { console.log(`Sending register tx to set name=${name}`); return registry.register(name); } async function sendMetaTx(registry, provider, signer, name) { console.log(`Sending register meta-tx to set name=${name}`); const url = process.env.REACT_APP_WEBHOOK_URL; if (!url) throw new Error(`Missing relayer url`); const forwarder = createInstance(provider); const from = await signer.getAddress(); const data = registry.interface.encodeFunctionData('register', [name]); const to = registry.address; const request = await signMetaTxRequest(signer.provider, forwarder, { to, from, data }); return fetch(url, { method: 'POST', body: JSON.stringify(request), headers: { 'Content-Type': 'application/json' }, }); } export async function registerName(registry, provider, name) { if (!name) throw new Error(`Name cannot be empty`); if (!window.ethereum) throw new Error(`User wallet not found`); await window.ethereum.enable(); const userProvider = new ethers.BrowserProvider(window.ethereum); const userNetwork = await userProvider.getNetwork(); console.log(userNetwork) if (userNetwork.chainId !== 11155111) throw new Error(`Please switch to Sepolia for signing`); const signer = userProvider.getSigner(); const from = await signer.getAddress(); const balance = await provider.getBalance(from); const canSendTx = balance.gt(1e15); if (canSendTx) return sendTx(registry.connect(signer), name); else return sendMetaTx(registry, provider, signer, name); }2. ERC-2612:Permit 函数
EIP-2612 引入了 permit 函数,这是一种在 ERC-20 代币中启用 gasless 交易的工具。通过使用户能够通过签名消息而不是 approve 函数来修改其 allowance 的方法扩展 ERC-20 接口,此标准使用户能够在不直接支付 gas 费用的情况下批准代币。此标准使 relayer 服务能够代表用户执行交易,同时用户只需签署消息。
function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external此函数基于已签名的批准修改 owner 的代币的 spender 的 allowance。签名分为 v、r 和 s 组件以进行验证。
2.1 EIP-712 签名前端
它如何使用 EIP-712 进行结构化数据签名:EIP-2612 利用 EIP-712 来创建和签名结构化数据。这提供了正在签名的数据的人类可读表示,从而增强了安全性和用户体验。示例代码:
// ... const domain = { name: name, version: '1', chainId: chainId, verifyingContract: ERC20_ADDRESS, }; const types = { Permit: [\ { name: 'owner', type: 'address' },\ { name: 'spender', type: 'address' },\ { name: 'value', type: 'uint256' },\ { name: 'nonce', type: 'uint256' },\ { name: 'deadline', type: 'uint256' },\ ] }; const value = { owner: OWNER_ADDRESS, spender: SPENDER_ADDRESS, value: amount, nonce: nonce, deadline: deadline, }; const signature = await wallet.signTypedData(domain, types, value); const sig = ethers.Signature.from(signature); const recoveredAddress = ethers.verifyTypedData(domain, types, value, signature); const request = { owner: OWNER_ADDRESS, spender: SPENDER_ADDRESS, amount, deadline, v: sig.v, r: sig.r, s: sig.s }; return fetch(`${url}/relayerForwardMessage`, { method: 'POST', body: JSON.stringify(request), headers: { 'Content-Type': 'application/json' }, });2.2 Relayer 服务
创建一个后端服务以与 Defender Relayer 交互。该服务最初需要设置 Defender Relayer。配置完成后,它将处理来自前端的传入请求,并将已签名的 EIP-712 消息转发到合约。该服务将利用 Relayer 执行合约的 permit 函数,从而允许 Relayer 支付 gas 费用。该服务将促进最终用户的代币批准,从而可以使用 Relayer 进行后续操作,例如将代币转账到不同的钱包。
import { ethers, defender } from "hardhat"; // ... const creds = { relayerApiKey: process.env.RELAYER_API_KEY, relayerApiSecret: process.env.RELAYER_API_SECRET, }; const client = new Defender(creds); const provider = client.relaySigner.getProvider(); const signer = client.relaySigner.getSigner(provider, { speed: 'fast' }); const erc20 = await ethers.getContractAt("ERC20Token", CONTRACT_ADDRESS); // 你现在可以使用这些值来调用 permit 函数 // permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) const tx = await erc20.permit( request.owner, request.spender, request.amount, request.deadline, request.v, request.r, request.s ); await tx.wait(); console.log("Permit executed!"); // 示例后续操作 const transferTx = await erc20.transferFrom(request.owner, to, request.amount); await transferTx.wait(); // ...3. ERC-3009:Transfer with Authorization
ERC-3009 引入了一种通过链下授权进行 gasless 代币转账的标准。此标准允许用户签署消息以授权代币转账,然后可以通过 Defender Relayer 服务将其提交到链上。与 EIP-2612 的比较(签名差异): 虽然 EIP-2612 侧重于批准,但 ERC-3009 直接授权转移。主要区别在于:
目的:ERC-3009 授权特定的转移,而 EIP-2612 批准 allowance。
灵活性:ERC-3009 不需要 EIP-712 进行结构化数据签名,从而在消息格式方面提供了更大的灵活性。
时间窗口:ERC-3009 包括 validAfter 和 validBefore 参数,从而可以更精确地控制何时可以执行授权。
函数定义:
function transferWithAuthorization( address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce, uint8 v, bytes32 r, bytes32 s ) external3.1 EIP-712 签名前端
与 ERC-2612 类似,你可以使用 EIP-712 格式在前端以最终用户的身份签署消息。虽然 ERC-3009 为前端消息签名提供了更大的灵活性,但此示例遵循 EIP-712 标准。示例代码:
//... const validAfter = Math.floor(Date.now() / 1000); // 现在 const validBefore = validAfter + 3600; // validAfter 后 1 小时 const value = ethers.parseEther("10"); // 要转移的金额 const nonce = ethers.randomBytes(32); const domain = { name: name, version: '1', chainId: chainId, verifyingContract: ERC20_ADDRESS, }; const types = { TransferWithAuthorization: [\ { name: 'from', type: 'address' },\ { name: 'to', type: 'address' },\ { name: 'value', type: 'uint256' },\ { name: 'validAfter', type: 'uint256' },\ { name: 'validBefore', type: 'uint256' },\ { name: 'nonce', type: 'bytes32' },\ ] }; const valueToSign = { from: FROM_ADDRESS, to: TO_ADDRESS, value: value, validAfter: validAfter, validBefore: validBefore, nonce: nonce, }; const signature = await wallet.signTypedData(domain, types, valueToSign); const sig = ethers.Signature.from(signature); const request = { from: FROM_ADDRESS, to: TO_ADDRESS, value, validAfter, validBefore, nonce, v: sig.v, r: sig.r, s: sig.s }; return fetch(`${url}/relayerForwardMessage`, { method: 'POST', body: JSON.stringify(request), headers: { 'Content-Type': 'application/json' }, });3.2 Relayer 服务
创建一个后端服务以与 Defender Relayer 交互。该服务最初需要设置 Defender Relayer。配置完成后,它将处理来自前端的传入请求,并将已签名的消息转发到合约。该服务将利用 Relayer 执行合约的 transferWithAuthorization 函数,从而允许 Relayer 支付 gas 费用。该服务将促进最终用户的代币转账。
import { ethers, defender } from "hardhat"; // ... const creds = { relayerApiKey: process.env.RELAYER_API_KEY, relayerApiSecret: process.env.RELAYER_API_SECRET, }; const client = new Defender(creds); const provider = client.relaySigner.getProvider(); const signer = client.relaySigner.getSigner(provider, { speed: 'fast' }); const erc20 = await ethers.getContractAt("ERC20Token", CONTRACT_ADDRESS); const tx = await erc20.transferWithAuthorization( request.from, request.to, request.value, request.validAfter, request.validBefore, request.nonce, request.v, request.r, request.s ); await tx.wait(); console.log("TransferWithAuthorization executed!"); // ...试用应用程序
安装必要的依赖项并运行该应用程序。
$ cd app $ yarn $ yarn start打开应用程序:http://localhost:3000/
在 Metamask 中更改为 Sepolia 网络
输入要注册的名称并在 Metamask 中签署 meta-transaction
你的姓名将被注册,显示创建 meta-transaction 的地址和姓名。
使用前端亲自查看它的工作原理!比较使用有资金的帐户注册表签名时发生的情况,然后尝试使用 ETH 余额为零的帐户进行注册。
参考文献
演示 repo - Meta-Transaction Name Registry
文档 - Meta Transactions
← 添加完整的私有网络
工厂克隆的自动监控 →
- 原文链接: docs.openzeppelin.com/de...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~