Gasless meta-transaction 为用户在区块链上提供了更无缝的体验,有可能消除每次交互都花费 Gas 费用的需求。这种方法允许用户免费签署交易,并由第三方安全地执行该交易,该第三方支付 Gas 来完成交易。

Defender 提供了一种无缝体验和安全的方式,使用 Relayer 实现 gasless meta-transaction。这些 Relayer 代表用户处理发送交易,从而消除了用户管理私钥、交易签名、nonce 管理、Gas 估算和交易包含的需求。

此演示应用程序展示了如何不仅使用 ERC-2771 实现 meta-transaction,还探索了其他 gasless 交易标准:

  • ERC-2771:安全的原生 Meta Transaction:此 演示应用程序,window=_blank 使用 ERC2771ForwarderERC2771Context 实现 meta-transaction,以将 msg.sender 与 Relayer 的地址分开。用户只需使用他们想要从中发出交易的帐户签署消息。使用用户的私钥为目标合约和所需交易的数据形成签名。此签名在链下进行,不花费任何 Gas。签名被传递给 Relayer,以便它可以为用户执行交易(并支付 Gas)。

  • ERC-2612:Permit 函数:此标准引入了一种用于在 ERC-20 token 中启用 gasless token 批准的方法。用户可以通过签署消息而不是直接为传统的 "approve" 函数支付 Gas 费用来向 relayer 服务授予支出权限。这允许 relayer 代表用户处理 token 批准。

  • ERC-3009:带授权的转移:此标准通过链下授权促进 gasless token 转移。用户签署消息以授权特定的 token 转移,然后任何人(包括 relayer 服务)都可以将这些消息提交到区块链。

可以使用 Defender 和 Relayer 轻松安全地实现 gasless meta-transaction 中继,这使您可以轻松发送交易,而无需管理私钥、交易签名、nonce 管理、Gas 估算和交易包含。

先决条件

  • OpenZeppelin Defender 帐户。

  • 安装 GitYarn

1. ERC-2771:安全的原生 Meta Transaction

您可以在此处查看实时 演示应用程序。如果用户有可用资金来支付交易,它会直接接受注册,否则数据将作为 meta-transaction 发送。

在示例代码中,https://github.com/OpenZeppelin/workshops/blob/master/25-defender-metatx-api/contracts/SimpleRegistry.sol[SimpleRegistry 合约, window=_blank] 的功能是获取一个字符串并存储它。合约的 meta-transaction 实现 通过将签名者与交易的发送者分离来实现相同的结果。

在比较代码时,请注意 meta-transaction 使用 _msgSender() 而不是 SimpleRegistry 使用的 msg.sender。通过从 ERC2771ContextERC2771Forwarder 扩展,合约变得具有 meta-transaction 功能。

所有 OpenZeppelin 合约都与使用 _msgSender() 兼容。

两个合约之间的第二个根本变化是 meta-transaction 合约(https://github.com/OpenZeppelin/workshops/blob/master/25-defender-metatx-api/contracts/Registry.sol[Registry, window=_blank])需要指定受信任转发器的地址,在本例中,它是 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-relay

Relayer 是使用 defender-sdk 包创建的:

// ...
const client = new Defender(creds);

// Create Relayer using Defender SDK client.
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.solRegistry.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) // Initialize trusted forwarder
    ERC2771Context(address(forwarder)) {
  }

  function register(string memory name) external {
    require(owners[name] == address(0), "Name taken");
    address owner = _msgSender(); // Changed from 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 的凭据以及 RegistryERC2771Forwarder 合约的工件,并使用 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) {
  // Decide if we want to relay this request based on a whitelist
  const accepts = !whitelist || whitelist.includes(request.to);
  if (!accepts) throw new Error(`Rejected request to ${request.to}`);

  // Validate request on the forwarder contract
  const valid = await forwarder.verify(request, signature);
  if (!valid) throw new Error(`Invalid request`);

  // Send meta-tx through relayer to the forwarder contract
  const gasLimit = (parseInt(request.gas) + 50000).toString();
  return await forwarder.execute(request, signature, { gasLimit });
}

async function handler(event) {
  // Parse webhook payload
  if (!event.request || !event.request.body) throw new Error(`Missing payload`);
  const { request, signature } = event.request.body;
  console.log(`Relaying`, request);

  // Initialize Relayer provider and signer, and forwarder contract
  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);

  // Relay transaction!
  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-transaction。

复制 Webhook

将 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,这会根据应用程序提供的参数执行 Actions 的逻辑。请注意,签名者的 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 token 中启用 gasless 交易的工具。通过扩展 ERC-20 接口,使用户可以通过签名消息而不是 approve 函数来修改其津贴,此标准使用户无需直接支付 Gas 费用即可批准 token。此标准使 relayer 服务能够通过支付 Gas 费用来代表用户执行交易,而用户只需签署消息。

function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external

此函数根据已签名的批准修改所有者的 token 的 spender 的 allowance。签名分为 vrs 组件以进行验证。

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 费用。该服务将促进最终用户的 token 批准,从而使 Relayer 能够进行后续操作,例如将 token 转移到不同的钱包。

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);

// You can now use these values to call the permit function
// 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!");

// Example subsequent operation
const transferTx = await erc20.transferFrom(request.owner, to, request.amount);
await transferTx.wait();
// ...

3. ERC-3009:使用授权转移

ERC-3009 引入了一种通过链下授权进行 gasless token 转移的标准。此标准允许用户签署授权 token 转移的消息,然后任何人都可以通过 Defender Relayer 服务将其提交到链上。与 EIP-2612 的比较(签名差异): 虽然 EIP-2612 侧重于批准,但 ERC-3009 直接授权转移。主要区别在于:

  • 目的:ERC-3009 授权特定转移,而 EIP-2612 批准津贴。

  • 灵活性: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
) external

3.1 EIP-712 签名前端

与 ERC-2612 类似,您可以使用 EIP-712 格式在前端以最终用户的身份签署消息。虽然 ERC-3009 为前端消息签名提供了更大的灵活性,但此示例坚持 EIP-712 标准。示例代码:

  //...

  const validAfter = Math.floor(Date.now() / 1000); // Now
  const validBefore = validAfter + 3600; // 1 hour from validAfter
  const value = ethers.parseEther("10"); // Amount to transfer
  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 费用。该服务将促进最终用户的 token 转移。

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
  1. 打开应用程序:http://localhost:3000/[http://localhost:3000/, window=_blank]

  2. 在 Metamask 中更改为 Sepolia 网络

  3. 输入一个名称以注册并在 Metamask 中签署 meta-transaction

  4. 您的姓名将被注册,显示创建 meta-transaction 的地址和姓名。

使用前端来亲身体验!比较拥有资金的帐户注册表时会发生什么,然后尝试使用 ETH 余额为零的帐户进行注册。