中文
铁叔

天地不仁 以万物为刍狗


  • 首页

  • 归档

  • 关于我

  • 公益404

  • 搜索

元交易及其实现

时间: 2021-10-12   |   分类: Defi   Solidity   | 字数: 1766 字 | 阅读: 4分钟 | 阅读次数:

什么是元交易

简单来说,元交易(meta transaction)就是由第三方代理用户发送的交易。

元交易的流程如下:

  1. 用户构建交易参数,对交易参数签名
  2. 第三方将交易签名发送至 Relay/Forwarder 合约
  3. Relay/Forwarder 合约验证用户的签名是否相符
  4. Relay/Forwarder 调用最终的合约

普通的交易如下图所示:

元交易的如下图所示:

注: 图中的 Relayer 就是本文的 Forwarder 合约

元交易可以用来做什么

元交易大体有两个用途:

  1. 我们知道,去中心化的体验门槛很高,普通用户要玩去中心化,起码需要: a. 下载交易所,充币,认证,购买以太坊 b. 安装metamask,记住助记词,生吃地址; c. 交易所提币; d. 了解去中心化dapp, 理解原理, 使用dapp

上述任何一个步骤都非常麻烦,一整套流程走下来,半天就过去了

因此,使用元交易,可以让用户没有以太,就可以体验 dapp

  1. 安全

假如你有冷钱包,里面有很多钱,你不想让冷钱包直接触网,这时,你可以使用这种方式,让 Relayer/Forwarder 合约作为你的代理发送交易。

如何实现元交易

我们构建这样的一个数据结构:

    struct ForwardRequest {
        // 用户地址
        address from;
        // 用户要调用的合约地址
        address to;
        // 交易发送的以太数量
        uint256 value;
        // 设置的gas费, 可以不需要
        uint256 gas;
        // Forwarder合约中记录的用户的nonce, 防止重放攻击
        uint256 nonce;
        // 用户调用的函数和参数
        bytes data;
    }

用户签名

待签名数据的构建基于 EIP-712, 如下:

    /// @notice Returns a hash of the given data, prepared using EIP712 typed data hashing rules.
    /// @param from origin sender
    /// @param to contract to call
    /// @param value send ETH value
    /// @param nonce from's nonce
    /// @param data encodewithselector contract call params
    /// @return digest hash digest
    function getDigest(
        address from,
        address to,
        uint256 value,
        uint256 nonce,
        bytes memory data
        ) public view returns (bytes32 digest) {
        digest = _hashTypedDataV4(
            keccak256(abi.encode(_TYPE_HASH, from, to, value, nonce, keccak256(data))));
    }

函数 _hashTypedDataV4 是根据 EIP-712 实现的, 代码如下:

    function _hashTypedDataV4(bytes32 structHash) internal view virtual returns (bytes32) {
        return ECDSAUpgradeable.toTypedDataHash(_domainSeparatorV4(), structHash);
    }

    function _domainSeparatorV4() internal view returns (bytes32) {
        return _buildDomainSeparator(_TYPE_HASH, _EIP712NameHash(), _EIP712VersionHash());
    }

    function _buildDomainSeparator(
        bytes32 typeHash,
        bytes32 nameHash,
        bytes32 versionHash
    ) private view returns (bytes32) {
        return keccak256(abi.encode(typeHash, nameHash, versionHash, block.chainid, address(this)));
    }

其中,_EIP712NameHash _EIP712VersionHash 是合约创建时设置的

然后, 使用用户的私钥对上面算出来的 digest 签名:

    import { utils } from 'ethers'

    const userkey = new utils.SigningKey(privateKey)
        , sig = userkey.signDigest(digest)
        , sigs = utils.joinSignature(sig)

合约验证签名合法性

验证签名的合法性基于两点:

  1. ecrecover 从digest,签名中解出的地址与用户地址一致

  2. nonce ForwardRequest.req 结构体中的 nonce 与合约中记录的nonce一致

验签代码如下:

    using ECDSAUpgradeable for bytes32;
    function verify(ForwardRequest calldata req, bytes calldata signature) public view returns (bool) {
        bytes32 digest = getDigest(req.from, req.to, req.value, req.nonce, req.data);
        address signer = digest.recover(signature);

        return _nonces[req.from] == req.nonce && signer == req.from;
    }

msg.sender 的问题

当 Forwarder 合约最终调用 to 合约时,to 合约中使用 msg.sender 时, msg.sender 值为 Forwarder 合约地址。如果需要在 to 合约中使用用户地址,则需要做一些修改。

首先,Forwarder 合约调用 to 合约时,已经将用户的地址附加在调用参数的后面。to 合约的被调用函数并不需要知道这个参数的存在,因为 to 合约的函数取哪些参数,如何获取这些参数是在合约编译时,已经确定了, to 合约只是按照偏移量去 sload 数据. 在最后增加一个参数不会影响原来参数的获取,也不会像c/c++那样破坏堆栈。

Forwarder合约的执行:

        (bool success, bytes memory returndata) = req.to.call{value: req.value}(
            abi.encodePacked(req.data, req.from)
        );

这样,Forwarder 合约就把用户的地址传给了 to 合约,剩下的就交给 to 合约了。

然后,to 合约要怎么得到用户地址呢?

我们先来看看 openzeppelin 的 Context 合约:

abstract contract Context {
    function _msgSender() internal view virtual returns (address) {
        return msg.sender;
    }

    function _msgData() internal view virtual returns (bytes calldata) {
        return msg.data;
    }
}

这是一个非常基础的合约,很多合约就是基于 Context 合约. 最开始看到这两个函数时,我非常迷惑, _msgSender() 就是简单的返回 msg.sender, 有什么作用呢?实际上,绝大部分时候,我们都是直接使用 msg.sender, 很少调用 _msgSender()。

在处理元交易时, _msgSender() 的作用就体现出来了, 我们可以重写 _msgSender 函数,来得到 用户地址,当然,前提是 to 合约需要配置 Forwarder 合约的地址。

在 to 合约中, 重写 _msgSender 函数代码如下:

    address private _trustedForwarder;
    
    function isTrustedForwarder(address forwarder) public view virtual returns (bool) {
        return forwarder == _trustedForwarder;
    }

    function _msgSender() internal view virtual override(ContextUpgradeable) returns (address sender) {
        if (isTrustedForwarder(msg.sender)) {
            // The assembly code is more direct than the Solidity version using `abi.decode`.
            assembly {
                sender := shr(96, calldataload(sub(calldatasize(), 20)))
            }
        } else {
            return msg.sender;
        }
    }

    function _msgData() internal view virtual override(ContextUpgradeable) returns (bytes calldata) {
        if (isTrustedForwarder(msg.sender)) {
            return msg.data[:msg.data.length - 20];
        } else {
            return msg.data;
        }
    }

这样, 就完成了对 to 合约的修改。 openzeppelin 的代码都是使用 _msgSender 来获取 msg.sender, 如果 to 合约继承了 openzeppelin 合约,那么继承的函数就直接支持了元交易。

此外,还有一种解决方式,就是在 to 合约中提供一个函数来设置用户的地址,Forwarder 调用 to 合约前,调用该函数设置用户地址;to合约执行时,从临时变量中读取用户地址; 执行完成后, Forwarder 合约在把地址重置。

显然这种方案没有第一种方案优雅,而且需要一个storage变量来存储用户地址,也增加了 gas 费用。

安全问题

重放攻击

例如,一个转账交易,如果没有检查,再次用同样的参数调用,就有可能再次转账。

解决的方式有很多种,一种是在 合约中为用户记录nonce值,每次交易自增nonce;另一种是记录交易hash,不允许重复的交易hash

样例代码

https://github.com/guotie/meta-tx

包含了使用, 部署,测试,Proxy 代理的实现。

#selector# #Solidity# #EVM# #ABI# #Ethereum# #EIP-712# #meta-transaction#

声明:元交易及其实现

链接:https://guotie.github.io/post/meta-transaction-and-implement/

作者:铁叔

声明: 本博客文章除特别声明外,均采用 CC BY-NC-SA 3.0许可协议,转载请注明出处!

创作实属不易,如有帮助,那就打赏博主些许茶钱吧 ^_^
WeChat Pay

微信打赏

Alipay

支付宝打赏

uniswap 环回交易的手续费
AAVE源代码分析 -- AAVE 利率
铁叔

铁叔

千里之行 始于足下

25 日志
14 分类
56 标签
GitHub twitter telegram email medium
标签云
  • Solidity
  • Defi
  • Aave
  • Compound
  • Abi
  • Dapp
  • Ethereum
  • Evm
  • Lend protocol
  • Lending
© 2010 - 2024 铁叔
Powered by - Hugo v0.119.0 / Theme by - NexT
/
0%