Gasless meta-transaction 为用户在区块链上提供了更无缝的体验,有可能消除每次交互都花费 Gas 费用的需求。这种方法允许用户免费签署交易,并由第三方安全地执行该交易,该第三方支付 Gas 来完成交易。
Defender 提供了一种无缝体验和安全的方式,使用 Relayer 实现 gasless meta-transaction。这些 Relayer 代表用户处理发送交易,从而消除了用户管理私钥、交易签名、nonce 管理、Gas 估算和交易包含的需求。
此演示应用程序展示了如何不仅使用 ERC-2771 实现 meta-transaction,还探索了其他 gasless 交易标准:
-
ERC-2771:安全的原生 Meta Transaction:此 演示应用程序,window=_blank 使用 ERC2771Forwarder 和 ERC2771Context 实现 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 估算和交易包含。
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
。通过从 ERC2771Context
和 ERC2771Forwarder
扩展,合约变得具有 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.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) // 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 的凭据以及 Registry
和 ERC2771Forwarder
合约的工件,并使用 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。

将 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
。签名分为 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 费用。该服务将促进最终用户的 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
-
打开应用程序:http://localhost:3000/[http://localhost:3000/, window=_blank]
-
在 Metamask 中更改为 Sepolia 网络
-
输入一个名称以注册并在 Metamask 中签署 meta-transaction
-
您的姓名将被注册,显示创建 meta-transaction 的地址和姓名。
使用前端来亲身体验!比较拥有资金的帐户注册表时会发生什么,然后尝试使用 ETH 余额为零的帐户进行注册。