这篇文章上次修改于 660 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

1 ERC-20 token 支付手续费流程

流程

2023-02-11T06:43:09.png

1)client 向 relay service 发送签名后的请求,不需要用 ETH 支付手续费。

2)relay service 将请求放到 tx 中,为用户用 ETH 垫付手续费,将 tx 发送到区块链网络中,目标是 RelayHub 合约。

3-5)RelayHub 合约请求 TokenPaymaster 合约将最大可能的手续费换算成 ERC-20 token,并从用户账户扣除。

6-8)RelayHub 合约请求 Forwarder 调用目标合约。Forwarder 会先验证签名和 nonce,再将请求转发给目标合约。

9-11)RelayHub 合约请求 TokenPaymaster 合约将多向用户收取的 ERC-20 token 退还给用户。

12-13)RelayHub 合约收到 TokenPaymaster 合约将 ERC-20 token 换成的 ETH 后,为 relay service 报销其为用户垫付的 ETH 手续费。

GSN 组件:

  • provider:供 client 使用,会为 client 拼接 relay request,并发送到 relayer。当发送完 relay request 后,还会发送审计请求,作为后续惩罚 relayer 的依据。
  • relay service:支付 gas 的第三方服务。将用户的 tx 发送到区块链,并垫付手续费。是去中心化的,志愿者可以自己启动一个 relay service。relay service 需要在 RelayHub 合约上质押一定数量的 token,如果被发现作恶,则会被惩罚。
  • 合约:

    • RelayHub:是 GSN 中的主要合约,连接了 client、relayer 和 paymaster。会包含如下合约的地址:

      • RelayRegistrar:管理 relayer 的注册信息。
      • StakeManager:管理 relayer 的质押信息。
      • Penalizer:惩罚 relayer 的规则。
    • Paymaster:决定是否接受 tx,并退还 relayer 垫付的手续费。需要开发者自己实现,不过提供了一些示例
    • Forwarder:验证签名,将用户的地址添加到 call data 中,将请求转发给 target 合约。
    • ERC2771Recipient:target 合约需要继承该合约,兼容 EIP-2771 标准,以从 call data 中获取用户地址。

2 Provider

代码 使用示例 demo

@opengsn/provider 是 web3 provider 的封装,供 client 调用,为 client 拼接 relay request,并发送给 relayer。

发送 tx 过程:

2023-02-11T06:43:57.png

3 Relay Service

代码 使用示例

是支付 gas 的第三方 relayer。

meta tx 不直接发送到区块链,而是发送元交易到第三方 relayer,该第三方支付 gas。

  • 志愿者可以自己启动一个 relayer,但须提前在 RelayHub 注册并抵押 ETH。正确完成 relay tx 会收到来自 RelayHub 的奖励,若恶意攻击则会被没收抵押。
  • relay manager 需确保有足够 ETH 来代替 DApp 使用者支付 gas,若 ETH 不足则会变成非活跃状态,不会被 Client 选择。

需要在 RelayHub 注册 relayer,目的是:

  • 为 relayer 质押 ETH 或 ERC-20 token,以免 relayer 提交无效 tx。
  • 为 relayer 提供初始资金以便发送 tx,默认是 2 ETH。
  • 将 relayer 添加到链上 relayers 列表,供 client 使用。

可自行启动 relayer 后,在该页面进行注册:https://relays.opengsn.org/

3.1 config

定义 默认值

3.2 relay service 初始化

  1. 解析配置文件。
  2. 根据配置文件中的 RelayHub 地址创建 RelayHub。从 RelayHub 获得 StakeManager,Penalizer 和 RelayRegistrar 的地址并进行创建。
  3. 根据配置文件决定是否创建 paymaster reputation manager。
  4. 根据配置文件决定是否初始化 PenalizerService。PenalizerService 会周期性地查看内存中是否新增非法 tx 信息(例如,tx 中调用的不是 IRelayHub.relayCall() 函数,tx 的 nonce 重复),如果有则调用 penalizer 合约中的惩罚函数,取消 relayer 的注册,并将其质押奖励给提交非法 tx 者。为了防止股权绑架(stake kidnapping),relayer 的质押一半被 burn,一半用于奖励。
  5. 初始化 relayer,会初始化 transaction manager 和 registration manager。

    • transaction manager 会监听 TransactionBroadcast 事件并发送交易。
    • registration manager 会检查并更新 relay manager 的质押信息,如下图所示。

      2023-02-14T02:54:52.png

3.3 relay service 启动

启动 http server,会启动 relayer。relayer 会周期性地执行如下步骤:

2023-02-14T02:55:44.png

3.4 relay service 监听请求

  • /getaddr,返回合约地址信息。如果运行 paymaster reputation,还会返回 paymaster reputation 相关信息。

    例如:

    {
        "relayWorkerAddress": "0x249c573c2cf62b798db4e6b563fbc636247f098b",
        "relayManagerAddress": "0x7e282733bbca1994fa0d63848b40a1df2ebb5623",
        "relayHubAddress": "0xbF06d99FDE1dc4e4C24F4191Fad82F8f5524Ce62",
        "ownerAddress": "0x8C1FD2DE219c98f5F88620422e36a8A32f83324E",
        "minMaxPriorityFeePerGas": "1000000",
        "maxMaxFeePerGas": "500000000000",
        "minMaxFeePerGas": "1000000",
        "maxAcceptanceBudget": "285252",
        "chainId": "10",
        "networkId": "10",
        "ready": true,
        "version": "3.0.0-beta.3"
    }
  • /stats,返回状态信息,供该页面展示:https://relays.opengsn.org/
  • /relay,relay worker 会发送 tx 并垫付手续费

    1. 进行一系列检查

      • 如果运行 PaymasterReputation,还会检查 request 中 paymaster 的名誉
      • 请求消耗的 gas 不能超过 paymaster.getGasAndDataLimits()
      • 请求中 paymaster 的 RelayHub 的余额不能过小
      • 检查 relay worker 余额是否够发送 tx
    2. 发送交易,调用 relayHub.RelayCall(),并将 tx 保存到本地。
    3. 发送交易后,检查 relay worker 的余额,必要时进行补充:如果 relay manager 的余额小于配置,则从 RelayHub 中撤回一部分;如果 relay worker 的余额小于配置,则将 relay manager 的余额转给 relay worker 一部分。
  • /audit,如果配置文件中运行 penalizer,则监听该方法。如果发现 nonce 重复或没有调用 IRelayHub.relayCall() 函数的 tx,则将非法 tx 信息更新到内存,并发送交易,调用 penalizer.commit() 声明惩罚。

3.5 惩罚 relayer

@opengsn/provider 向 relayer 发送 tx 后,还会请求 /audit 审计 tx。penalizer service 会周期性地根据审计结果对 relayer 实施惩罚:

2023-02-14T02:56:19.png

根据配置决定是否运行 Penalizer Service。

3.6 计算 paymaster 名誉

  • 根据配置决定是否运行 Paymaster Reputation Manager。
  • 周期性检查,如果发现 RelayHub 中发生了被 paymaster 拒绝或接受的 event,则更新 paymaster 的名誉。拒绝会导致 paymaster 名誉分值减一,接受会加一。
  • 如果 relayer 发现请求中的 paymaster 名誉低于配置中的阈值,则拒绝 relay tx。

4 合约

4.1 RelayHub

合约代码

是 GSN 中的主要合约。连接了 client、relayer 和 paymaster,这样它们就可以互相不需要了解或信任对方。

Dapp 开发人员不需要了解或信任 RelayHub,就可以集成 GSN 。

RelayHub 可以自行部署,但自行部署的 RelayHub 无法共享已存在的 relayer。

RelayHub 中会保存合约 StakeManager、Penalizer 和 RelayRegistrar 的地址。

支付手续费流程相关的方法:

  • relayCall:relay 一个 tx。

    function relayCall(
        string calldata domainSeparatorName,
        uint256 maxAcceptanceBudget,
        GsnTypes.RelayRequest calldata relayRequest,
        bytes calldata signature,
        bytes calldata approvalData
    )
    external
    returns (
        bool paymasterAccepted,
        uint256 charge,
        IRelayHub.RelayCallStatus status,
        bytes memory returnValue
    );
    1. 验证 msg.sender 是否为注册过的 relayer。
    2. 验证 relay manager 的质押 token 数量是否够:通过 relayer 查找到 relay manager,调用 stakeManager.getStakeInfo() 获得 relay manager 的质押信息,检查其否质押了最小额度的 token。
    3. 验证 paymaster 存放在 RelayHub 的 ETH 数量是否够支付最大可能的手续费,msg.data 是否过长。
    4. 验证编码后的请求是否被正确打包,没有任何额外字节。
    5. 原子调用 paymaster.preRelayedCall(),forwarder.execute() 和 paymaster.postRelayedCall()。
    6. 更新 paymaster 和 relay manager 的 ETH 余额:从 paymaster 中扣除实际消耗的 gas 和 fee;为 relay manager 报销垫付的 gas 和 fee。
  • depositFor:Paymaster 会调用该方法存 ETH 到 RelayHub,以便 RelayHub 为 tx 支付手续费。

    function depositFor(address target) external payable;

Relayer 相关的方法:

  • addRelayWorkers:被 relay manager 调用,添加被其控制的 worker。

    function addRelayWorkers(address[] calldata newRelayWorkers) external;
  • onRelayServerRegistered:被 RelayRegistrar 回调,通知 RelayHub 该 relay manager 已经更新了注册信息。

    function onRelayServerRegistered(address relayManager) external;
  • setMinimumStakes:设置或更改给定 token 的最新额度,是 relay manager 质押到 RelayHub 的最小额度。零表示该 token 不允许被质押。

    function setMinimumStakes(IERC20[] memory token, uint256[] memory minimumStake) external;

Penalizer 相关的方法:

  • penalize:如果 Penalizer 发现 relay worker 违反了 Penalizer 合约中的某些规定,如 relay worker 所 relay 的 tx 不是调用 RelayHub 的 relayCall() 方法,会调用此方法进行惩罚。RelayHub 会根据给定的 relay worker 查找到 relay manager,并调用 StakeManager 合约进行惩罚。

    function penalize(address relayWorker, address payable beneficiary) external;

4.2 Token Paymaster

合约代码

用来为 tx 支付手续费。Paymaster 会保存合约 RelayHub 和 Forwarder 的地址。

没有经过审计,只是一个示例。

Dapp 开发者需要实现 Paymaster 合约,只要继承 [BasePaymaster](https://github.com/opengsn/gsn/blob/master/packages/contracts/src/BasePaymaster.sol)即可。该合约需要在 RelayHub 存一定数量的 ETH,并在 tx 执行后被 RelayHub 收费。

Dapp 开发者需要实现两个被 RelayHub 回调的函数:preRelayedCall and postRelayedCall

BasePaymaster 中:

  • preRelayedCall 会做一系列检查,继承 BasePaymaster 的合约需要实现 _preRelayedCall 即可:

    function preRelayedCall(
          GsnTypes.RelayRequest calldata relayRequest,
          bytes calldata signature,
          bytes calldata approvalData,
          uint256 maxPossibleGas
      )
      external
      override
      returns (bytes memory, bool) {
          _verifyRelayHubOnly();
          _verifyForwarder(relayRequest);
          _verifyValue(relayRequest);
          _verifyPaymasterData(relayRequest);
          _verifyApprovalData(approvalData);
          return _preRelayedCall(relayRequest, signature, approvalData, maxPossibleGas);
      }
  • postRelayedCall 同理:

    function postRelayedCall(
        bytes calldata context,
        bool success,
        uint256 gasUseWithoutPost,
        GsnTypes.RelayData calldata relayData
    )
    external
    override
    {
        _verifyRelayHubOnly();
        _postRelayedCall(context, success, gasUseWithoutPost, relayData);
    }

在给出的 TokenPaymaster(继承 BasePaymaster )示例中:

  • 构造函数中会添加支持的 uniswap 和 token,并执行 token.approve(),以允许 uniswap 转移 token:

    constructor(IUniswapV3[] memory _uniswaps) {
        uniswaps = _uniswaps;
    
        for (uint256 i = 0; i < _uniswaps.length; i++){
            supportedUniswaps[_uniswaps[i]] = true;
            tokens.push(IERC20(_uniswaps[i].tokenAddress()));
            tokens[i].approve(address(_uniswaps[i]), type(uint256).max);
        }
    }
  • _preRelayedCall 为 tx 预付最大可能的 token 费用:

    function _preRelayedCall(
        GsnTypes.RelayRequest calldata relayRequest,
        bytes calldata signature,
        bytes calldata approvalData,
        uint256 maxPossibleGas
    )
    internal
    override
    virtual
    returns (bytes memory context, bool revertOnRecipientRevert) {
        (signature, approvalData);
    
        (IERC20 token, IUniswapV3 uniswap) = _getToken(relayRequest.relayData.paymasterData);
        (address payer, uint256 tokenPrecharge) = _calculatePreCharge(token, uniswap, relayRequest, maxPossibleGas);
        token.transferFrom(payer, address(this), tokenPrecharge);
        return (abi.encode(payer, tokenPrecharge, token, uniswap), false);
    }
    1. 验证 token 是否支持 uniswap,即 caller 是否可以用 ERC20 token 进行支付。
    2. 计算最大可能的 token 费用。会调用 relayHub.calculateCharge() 计算出 ETH 费用后,再换算成 token。
    3. 调用 token.transferFrom() 向用户收取 token 费用。
  • _postRelayedCall 向用 caller 退还未使用的 gas。

    function _postRelayedCall(
        bytes calldata context,
        bool,
        uint256 gasUseWithoutPost,
        GsnTypes.RelayData calldata relayData
    )
    internal
    override
    virtual
    {
        (address payer, uint256 tokenPrecharge, IERC20 token, IUniswapV3 uniswap) = abi.decode(context, (address, uint256, IERC20, IUniswapV3));
        _postRelayedCallInternal(payer, tokenPrecharge, 0, gasUseWithoutPost, relayData, token, uniswap);
    }
    
    function _postRelayedCallInternal(
        address payer,
        uint256 tokenPrecharge,
        uint256 valueRequested,
        uint256 gasUseWithoutPost,
        GsnTypes.RelayData calldata relayData,
        IERC20 token,
        IUniswapV3 uniswap
    ) internal {
        uint256 ethActualCharge = relayHub.calculateCharge(gasUseWithoutPost + gasUsedByPost, relayData);
        uint256 tokenActualCharge = uniswap.getTokenToEthOutputPrice(valueRequested + ethActualCharge);
        uint256 tokenRefund = tokenPrecharge - tokenActualCharge;
        _refundPayer(payer, token, tokenRefund);
        _depositProceedsToHub(ethActualCharge, uniswap);
        emit TokensCharged(gasUseWithoutPost, gasUsedByPost, ethActualCharge, tokenActualCharge);
    }
    1. 调用 relayHub.calculateCharge() 计算出实际需要支付的 ETH 费用后,换算成 token。
    2. 预付的 token 减去实际需要支付的 token,得到需要退还的多余 token,并调用 token.transfer() 退还给 payer。
    3. 将收取的 token 通过 uniswap 换成 ETH 后,调用 relayHub.depositFor() 存到 RelayHub,以便 RelayHub 为 tx 支付手续费。

4.3 Forwarder

合约代码

  • 接收 ForwardRequest 并验证是否合法,验证 sender 的签名和 nonce。用户合约仅依赖 forwarder 保证安全性。

    struct ForwardRequest {
        address from;
        address to;
        uint256 value;
        uint256 gas;
        uint256 nonce;
        bytes data;
        uint256 validUntilTime;
    }.
    
    function verify(
          ForwardRequest calldata req,
          bytes32 domainSeparator,
          bytes32 requestTypeHash,
          bytes calldata suffixData,
          bytes calldata sig)
      external override view {
          _verifyNonce(req);
          _verifySig(req, domainSeparator, requestTypeHash, suffixData, sig);
      }
  • 将 20 字节的 sender 地址添加到 ForwardRequest 的 data 字段,并调用 ForwardRequest 中 to 字段所代表的目标合约。

    function execute(
        ForwardRequest calldata req,
        bytes32 domainSeparator,
        bytes32 requestTypeHash,
        bytes calldata suffixData,
        bytes calldata sig
    )
    external payable
    override
    returns (bool success, bytes memory ret) {
        ...
        bytes memory callData = abi.encodePacked(req.data, req.from);
        ...
            (success,ret) = req.to.call{gas : req.gas, value : req.value}(callData);
        ...
        return (success,ret);
    }

4.4 Target Contract

开发者编写的合约,获取原始 sender 并执行原始 tx。

需要兼容 ERC-2771 标准

  • 继承 [ERC2771Recipient.sol](https://github.com/opengsn/gsn/blob/master/packages/contracts/src/ERC2771Recipient.sol)
  • [将 msg.sender 替换为 _msgSende](https://docs.opengsn.org/contracts/#receiving-a-relayed-call)r,以获取原始 sender。因为 target contract 只接收到 Forwarder 的请求,所以 msg.sender 是 Forwarder 的地址,而非用户的地址。

    function _msgSender() internal override virtual view returns (address ret) {
            ...
        assembly {
            ret := shr(96, calldataload(sub(calldatasize(), 20)))
        }
            ...
    }

5 CLI

代码 使用示例

CLI 用来部署 GSN 合约,启动 relayer 等。

gsn start: 在本地测试环境中运行 GSN。

  1. 部署 GSN 合约:

    2023-02-11T06:46:18.png

  2. 启动一个 relayer。

参考

Ethereum Gas Station Network (GSN)

Paying for your user's meta-transaction

Creating a Paymaster