概述

本文在上一篇文章介绍的EIP712的基础上进一步讨论了EIP712结构化哈希的进一步应用:

  1. Meta-transactions,解决用户gas费用问题
  2. ERC20-Permit

Meta-transactions

meta-transactions指在交易中包含另一个实际交易。具体流程为用户签署实际交易,将交易提交给区块链运营商,此过程不需要gas费用和与区块链交互。运营商收到用户提交的交易后,由运营商较此交易提交给区块链。此过程实现了的意义在于将用户与区块链交易的gas费转移到运营商身上,有效降低了用户使用区块链的门槛。当然,运营商可以直接与矿池合作降低交易费用。

流程图如下:

Meta transactions flow

此流程已被EIP2771标准化。

合约调用概述

由于此过程设计合约调用的流程,所以我们在此处简单介绍然后实现以太坊合约调用。此处我们仅仅讨论合约交互的基本原理,关于具体实现,读者可自行使用ethers.js等库实现。

第一步,生成calldata

我们与合约交互需要生成calldata数据。此过程是根据我们输入的数据和abi进行编码得到的,在solidity中,一般使用abi.encodeWithSignature得到,我们在Foundry教程:使用多种方式编写可升级的智能合约(上)测试中已经多次使用此函数。当然,我们可以使用Foundry提供的cast完成此步骤,具体可以参考cast calldata。在下图中,我们给出一个例子。

castCalldata.png

除此之外,我们也可以在网页中进行操作,示意图如下: calldataweb.png

ethers.js中,此过程在我们进行合约调用时隐形进行。

第二步,生成并签名交易数据。

我们在上一篇中已经介绍了以太坊交易签名。在此处,我们给出标准交易签名的内容:

rlp(
    [
        chain_id, 
        nonce, 
        max_priority_fee_per_gas, 
        max_fee_per_gas, 
        gas_limit, 
        destination, 
        amount, 
        data, 
        access_list
    ]
)

此处,我们主要需要将data设置为calldata,将destination设置为合约地址。为了简洁,我们不再详细介绍其他参数。

第三步,发送交易至以太坊节点。当交易发送到以太坊节点后,以太坊节点检验交易的有效性。当以太坊节点查询到交易内的destination为合约地址后,以太坊节点将calldata内的数据发送到EVM中。EVM获得calldata数据后,会首先提取calldata前4 byte(即函数选择器),并查询本地的函数选择器映射表选择需要运行的堆栈。完整过程见下图:

EVM Opcodes

*上图来自Deconstructing a Solidity Contract — Part III: The Function Selector

完成上述流程后,EVM会输出结果并广播结果实现区块确认。当然,如果合约运行过程中出现代码错误在gas费充足的情况下会抛出异常。

注意抛出异常操作仍需要gas费用,所以在下文编写合约时我们使用了1/64规则以避免call操作耗尽所有gas导致异常无法抛出。

其中,第二三步可以使用eth_sendtransaction接口实现,也可以使用cast send实现这一过程,具体文档参考cast send

上述过程完整描述了在以太坊区块链中如何完成合约调用。

基本流程

从上文中,我们已经知道了合约调用基本流程。我们需要在用户与合约正常的交互中插入运营商的合约,具体步骤可参考下图: EIP2771

第一步,交易用户需要签名以下数据(由EIP2770规定):

struct ForwardRequest {
   address from;
   address to;
   uint256 value;
   uint256 gas;
   uint256 nonce;
   bytes data;
   uint256 validUntil;
}

其中,各个参数的意义为:

  • from - 签名者的地址
  • to - 目标合约地址
  • value - 向目标合约转移的eth数量
  • gas - gas 费用
  • nonce - 交易nonce,防止重放攻击
  • data - 具体的calldata,我们需要安装上文的方法进行生成
  • validUntil - 有效期,一般设置为区块高度。在下文中,我们不会进行实现此参数

将上述参数使用EIP712进行架构化哈希得到最终的数据并进行签名。完成签名后将请求发送给运营商。

第二步,运营商得到用户发送的数据和签名后,验证用户签名。验证成功后,提取用户发送的ForwardRequest结构体,并使用运营商私钥对其签名并将其发送给可信转发合约。这一步运营商才真正与区块链进行交互,所以运营商需要缴纳gas费用。

第三步,可信转发合约会检测运营商发送的请求是否由运营商签名,如果签名确认正确,则会提取出ForwardRequest中的各个参数,对to地址使用call进行调用。

第四步,接受合约接受可信转发合约发送的call请求执行合约。

合约实现

此处的合约实现主要实现可信转发合约和接受合约。

可信转发合约

此处我们主要基于openzepplin库提供的Meta Transactions合约为大家介绍合约实现。值得注意的是,目前此合约为最简形式,如果需要使用合约构建大规模系统,请参考GSN的合约代码。

首先,我们编写Forwarder合约,本合约的具体实现较长,我们在此处仅分析核心函数:

verify函数,此函数用于校验请求是否正确:

    function verify(ForwardRequest calldata req, bytes calldata signature)
        public
        view
        returns (bool)
    {
        address signer = _hashTypedDataV4(
            keccak256(
                abi.encode(
                    _TYPEHASH,
                    req.from,
                    req.to,
                    req.value,
                    req.gas,
                    req.nonce,
                    keccak256(req.data)
                )
            )
        ).recover(signature);
        return _nonces[req.from] == req.nonce && signer == req.from;
    }

此代码较为简单,我们已在基于链下链上双视角深入解析以太坊签名与验证此文中进行了相关介绍。此函数的功能是验证ForwardRequest的请求是否由运营商签名。当然,此处也验证了nonce是否正确。设置nonce的目的是避免重放攻击。如果不设置此参数,攻击者可以在链上查找到用户的签名并重复使用。加入nonce并设置每次运行改变,可以有效避免签名被重复使用。在以太坊正常交易中,用户的nonce也会在每次进行交易后自加1,也是为了避免重放攻击。

execute函数,此函数用于将请求转发给接受合约,并由接受合约运行,代码如下:

function execute(ForwardRequest calldata req, bytes calldata signature)
    public
    payable
    returns (bool, bytes memory)
{
    require(
        _senderWhitelist[msg.sender],
        "AwlForwarder: sender of meta-transaction is not whitelisted"
    );
    require(
        verify(req, signature),
        "AwlForwarder: signature does not match request"
    );
    _nonces[req.from] = req.nonce + 1;

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

    if (!success) {
        assembly {
            let p := mload(0x40)
            returndatacopy(p, 0, returndatasize())
            revert(p, returndatasize())
        }
    }

    assert(gasleft() > req.gas / 63);

    emit MetaTransactionExecuted(req.from, req.to, req.data);

    return (success, returndata);
}

此函数在运行data之前进行了一系列检查:

  1. 判断请求发送者是否为运营商节点
  2. 验证用户签名

完成以上步骤后,进入data运行阶段,具体代码如下:

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

与一般的call调用不同,我们没有直接将req.data,即calldata作为请求体,而是在req.data后增加了req.from,即用户地址。这样设计是为了被调用的接受合约可以从非标准的calldata中获得用户地址,我们会在下文向大家展示提取函数。如果你的接受合约内需要msg.sender,则需要对接受合约设计此提取函数。

如果你接受合约不涉及msg.sender,此时已经可以宣告合约开发完成。因为当合约接受到非标准的req.data后它已经会按照正常方式读取固定部分进行运行,而附加的地址则不再解析范围内,所以不会对函数的正常运行产生影响。

在增加地址的过程中,我们使用了abi.encodePacked,此函数会实现非标准的abi字节,具体参考solidity 文档Solidity Tutorial: all about ABI,后文可能较为易读。

此处在合约运行失败后使用了内联汇编的方式处理错误,核心为revert(offset, size)。此函数可以返回错误,但不消耗所有gas,由EIP140引入,可以参考EVM Codes

此处较难理解的为assert(gasleft() > req.gas / 63);,此函代码涉及到gas的一些复杂机制,具体可以参考Ethereum, The Concept of Gas and its Dangers。总结来说,正如上文所述,此操作为了在call运行后需要在本地留下1/64gas费以保证转发合约抛出可能异常。

完整代码可以参考我的仓库,生产级代码可以参考GSN的转发合约。

接受合约

我们需要特定的合约接受转发合约转发的req.data(在原有calldata的基础上增加了用户的address)并提取出sender。为达成此目的,我们使用了以下函数:

function _msgSender() internal view virtual returns (address ret) {
    if (msg.data.length >= 20 && isTrustedForwarder(msg.sender)) {
        assembly {
            ret := shr(96, calldataload(sub(calldatasize(), 20)))
        }
    } else {
        ret = msg.sender;
    }
}

其中的核心代码为汇编代码部分ret := shr(96, calldataload(sub(calldatasize(), 20)))。此代码的作用原理如下图: msgaddress.drawio.png

简单来说,可以将calldata视为一个长度为calldatasize()的列表。我们需要获得此列表中最后20 byte的数据,即用户地址。已知calldataload(i)会加载calldata[i, -1]的数据。我们通过sub(calldatasize(), 20)获得了calldata中用户地址的起始索引,并进一步使用calldataload将其加载到内存中。但在EVM中,一个标准不可变变量应占用32 byte的完整地址槽,而此处获得用户地址作为address类型变量占用的内存长度与规定不符。为了符合变量标准,我们使用shr操作码将20 byte的用户地址向右移96 bit(即 12 byte)实现了用户地址占用32 byte的条件,保证了在后期读取用户地址时不会出现错误。

如果你无法理解上述内容,建议参考:

function _msgData() internal view virtual returns (bytes calldata ret) {
    if (msg.data.length >= 20 && isTrustedForwarder(msg.sender)) {
        return msg.data[0:msg.data.length - 20];
    } else {
        return msg.data;
    }
}

此代码较为简单,不再解释。

你可以在这里找到GSN的实现。

以上内容就是标准化提取合约的内容,我们自行编写了合约逻辑部分需要继承上述函数,在此处,我编写了一个最为简单的Box合约,代码如下:

contract Box is ERC2771Recipient {
    constructor(address trustedForwarder) ERC2771Recipient(trustedForwarder) {}

    uint256 private _value;

    event NewValue(uint256 newValue);
    event Sender(address sender);

    function store(uint256 newValue) public {
        _value = newValue;
        emit NewValue(newValue);
        emit Sender(_msgSender());
    }

    function retrieve() public view returns (uint256) {
        return _value;
    }
}

合约测试

在此处,我们依旧使用上一篇提出的前往浏览器获得签名结果,再将签名结果手动输入测试合约的方法。

我们需要收集一些构建结构体所需要的数据:

  1. verifyingContractfromto都可以通过在测试合约中编写console2.log获得,具体代码参见代码仓库
  2. data,可以通过cast calldata命令获得,如cast calldata "store(uint256)" 20

获取签名结果的方法基本和上一篇相同,此处简单进行说明: 打开浏览器,前往此网站,完成钱包链接等操作。按下F12打开终端Console

与上次相同,首先构建结构体,如下:

const msgParams = JSON.stringify({
    domain: {
        name: 'Forwarder',
        chainId: 4,
        version: '1',
        verifyingContract: '0xce71065d4017f316ec606fe4422e11eb2c47c246',
    },

    message: {
        from: '0x11475691c2caa465e19f99c445abb31a4a64955c',
        to: '0x185a4dc360ce69bdccee33b3784b0282f7961aea',
        value: 0,
        gas: 500000000000,
        nonce: 0,
        data: '0x6057361d0000000000000000000000000000000000000000000000000000000000000014'
    },
    primaryType: 'ForwardRequest',
    types: {
        EIP712Domain: [
            { name: 'name', type: 'string' },
            { name: 'version', type: 'string' },
            { name: 'chainId', type: 'uint256' },
            { name: 'verifyingContract', type: 'address' },
        ],

        ForwardRequest: [
            { name: 'from', type: 'address' },
            { name: 'to', type: 'address' },
            { name: 'value', type: 'uint256' },
            { name: 'gas', type: 'uint256' },
            { name: 'nonce', type: 'uint256' },
            { name: 'data', type: 'bytes' },
        ],
    },
});

读者应该可以理解此结构体的含义,我们在此不再赘述。

构建结构体后,我们可以直接调用MetaMask的签名接口,输入以下命令:

const sign = await ethereum.request({
	method: 'eth_signTypedData_v4',
	params: ["0x11475691C2CAA465E19F99c445abB31A4a64955C", msgParams],
});

代码中0x11475691C2CAA465E19F99c445abB31A4a64955C应该替换为自己的地址。

完成MetaMask的交互后,在浏览器终端中输入sign,应该得到输出的签名结果。

为了方便进行Nonce的测试,我们需要将上文中给出的结构体中的nonce设置为1再进行一次签名获得签名结果以方便后文进行测试。

对于Meta-transactions含义的完整测试,我们不再本文继续讨论,读者可自行查阅代码

ERC20-Permit

ERC20标准成功的一个重要原因在于此标准引入了approvetransferFrom函数。前者用于用户授权ERC20合约使用自己所拥有的代币的权利,后者用于ERC20合约在授权范围内转移代币,包括DAIUniswap在内的大量合约使用了此函数,但此函数要求用户至少与合约进行两次交互,消耗的gas较多。而由EIP2612规定的ERC20-Permit完美解决了此类问题。

本节合约实现和测试相关内容大量参考了Testing EIP-712 Signatures。这是Foundry文档中的一节,如果读者英文水平较好,可以直接参考此文章。

运行流程

我们首先给出不实现EIP2612情况下的进行交互的步骤:

  1. 签署对ERC20合约的approve(address,amount)交易
  2. 等待交易确认
  3. 签署对ERC20合约中含有transferFrom函数的特定函数调用的交易

显然,为了调用含有transferFrom函数的目标函数,我们进行了两次交易,这也意味着我们需要缴纳两次gas费用。对于一般的用户而言,体验并不是很好。

当然,如果你想调用的函数中不含有transferFrom函数则不需要上述流程,可以直接调用。

如果合约实现EIP2612,步骤如下:

  1. Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)结构体进行EIP712结构化哈希和签名
  2. 向ERC20合约中的目标函数发送此签名
  3. 目标函数运行并返回结果

我们在此流程内仅进行了一次交易,所以用户仅需要缴纳一笔gas费用,用户体验更好。当然,理论上使用合约钱包也可以实现目的,但由于合约钱包在目前以太坊生态系统中未广泛使用,而且用户体验与正常的账户钱包不同,而使用EIP2612则不存在此类问题。

总体而言,EIP2612的流程较为简单。

由于此EIP标准较为简单,所以在此处我们也给出EIP标准的具体内容。

该标准最重要的规定是哈希结构体:

{
  "types": {
    "EIP712Domain": [
      {
        "name": "name",
        "type": "string"
      },
      {
        "name": "version",
        "type": "string"
      },
      {
        "name": "chainId",
        "type": "uint256"
      },
      {
        "name": "verifyingContract",
        "type": "address"
      }
    ],
    "Permit": [{
      "name": "owner",
      "type": "address"
      },
      {
        "name": "spender",
        "type": "address"
      },
      {
        "name": "value",
        "type": "uint256"
      },
      {
        "name": "nonce",
        "type": "uint256"
      },
      {
        "name": "deadline",
        "type": "uint256"
      }
    ],
    "primaryType": "Permit",
    "domain": {
      "name": erc20name,
      "version": version,
      "chainId": chainid,
      "verifyingContract": tokenAddress
  },
  "message": {
    "owner": owner,
    "spender": spender,
    "value": value,
    "nonce": nonce,
    "deadline": deadline
  }
}}

除了规定结构体外,标准还规定了一系列的函数,如下:

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

作用如下:

  1. permit函数验证用户的发送的授权签名是否正确并修改用户的授权。其中owner为代币持有者而spender为被授权的代币使用者
  2. nonces函数返回用户的nonce,在后文中我们没有进行实现
  3. DOMAIN_SEPARATOR返回需要签名的domain字段

合约实现

为了增加文章多样性,此处我们选择完全使用foundry完成合约编写和测试。

此处我们选择solmateERC20合约作为基准为大家解释相关代码。与openzeppelin提供的ERC20合约,solmate提供的合约原生支持EIP2612标准,而且solmate的合约在gas方面更有优势且实现更加简单。

关于EIP2612代码如下:

function permit(
    address owner,
    address spender,
    uint256 value,
    uint256 deadline,
    uint8 v,
    bytes32 r,
    bytes32 s
) public virtual {
    require(deadline >= block.timestamp, "PERMIT_DEADLINE_EXPIRED");

    // Unchecked because the only math done is incrementing
    // the owner's nonce which cannot realistically overflow.
    unchecked {
        address recoveredAddress = ecrecover(
            keccak256(
                abi.encodePacked(
                    "\x19\x01",
                    DOMAIN_SEPARATOR(),
                    keccak256(
                        abi.encode(
                            keccak256(
                                "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
                            ),
                            owner,
                            spender,
                            value,
                            nonces[owner]++,
                            deadline
                        )
                    )
                )
            ),
            v,
            r,
            s
        );

        require(recoveredAddress != address(0) && recoveredAddress == owner, "INVALID_SIGNER");

        allowance[recoveredAddress][spender] = value;
    }

    emit Approval(owner, spender, value);
}

此代码与我们之前使用的验证EIP712签名的函数基本类似,但此处为了方便从前端进行调用,将结构体拆分为了多个参数,当然此处也没有直接使用合并的签名而是使用v r s部分进行签名验证。此处为了使人疑惑的是uncheckeduncheckedsolidity 0.8.0后引入的,在0.8.0后,solidity语言会自动检测计算是否导致溢出,如果溢出则抛出异常。但使用unchecked部分的代码不会进行溢出检测,当然删除溢出检查一方面增加了合约计算溢出的风险,另一方面减少了gas费用。此处,nonces[owner]++是唯一的计算,而且我们可以确认此数值不可能产生溢出情况,为减少gas使用了unchecked标识。

nonce在每一次交易后自增,其数据类型为uint256,用户不能实现如此多次的交易。

DOMAIN_SEPARATOR计算方面,此合约中与此相关的有以下部分:

constructor(
    string memory _name,
    string memory _symbol,
    uint8 _decimals
) {
    name = _name;
    symbol = _symbol;
    decimals = _decimals;

    INITIAL_CHAIN_ID = block.chainid;
    INITIAL_DOMAIN_SEPARATOR = computeDomainSeparator();
}

function DOMAIN_SEPARATOR() public view virtual returns (bytes32) {
    return block.chainid == INITIAL_CHAIN_ID ? INITIAL_DOMAIN_SEPARATOR : computeDomainSeparator();
}

function computeDomainSeparator() internal view virtual returns (bytes32) {
    return
        keccak256(
            abi.encode(
                keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
                keccak256(bytes(name)),
                keccak256("1"),
                block.chainid,
                address(this)
            )
        );
}

在合约初始化阶段选择当前的chainId进行初始化DOMAIN_SEPARATOR,但为了方便合约在不同链内复用,此处增加了block.chainid == INITIAL_CHAIN_ID ? INITIAL_DOMAIN_SEPARATOR : computeDomainSeparator();语句,利用三目表达式实现在不同的链内不同的DOMAIN_SEPARATOR。详细来说,此函数会首先检测目前的链是否为初始化时的链,如果是则返回初始化时已经计算好的INITIAL_DOMAIN_SEPARATOR,如果不是则利用当前的链ID重新计算DOMAIN_SEPARATOR

我们基于此合约开发了一个极为简单的Deposit存款合约,该合约较为简单,代码如下:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {ERC20} from "./ERC20.sol";

contract Deposit {
    event TokenDeposit(address user, address tokenContract, uint256 amount);
    event TokenWithdraw(address user, address tokenContract, uint256 amount);

    mapping(address => mapping(address => uint256)) public userDeposits;

    function deposit(address _tokenContract, uint256 _amount) external {
        ERC20(_tokenContract).transferFrom(msg.sender, address(this), _amount);

        userDeposits[msg.sender][_tokenContract] += _amount;

        emit TokenDeposit(msg.sender, _tokenContract, _amount);
    }

    function depositWithPermit(
        address _tokenContract,
        uint256 _amount,
        address _owner,
        address _spender,
        uint256 _value,
        uint256 _deadline,
        uint8 _v,
        bytes32 _r,
        bytes32 _s
    ) external {
        ERC20(_tokenContract).permit(
            _owner,
            _spender,
            _value,
            _deadline,
            _v,
            _r,
            _s
        );

        ERC20(_tokenContract).transferFrom(_owner, address(this), _amount);

        userDeposits[_owner][_tokenContract] += _amount;

        emit TokenDeposit(_owner, _tokenContract, _amount);
    }
    function withdraw(address _tokenContract, uint256 _amount) external {
        require(
            _amount <= userDeposits[msg.sender][_tokenContract],
            "INVALID_WITHDRAW"
        );

        userDeposits[msg.sender][_tokenContract] -= _amount;

        ERC20(_tokenContract).transfer(msg.sender, _amount);

        emit TokenWithdraw(msg.sender, _tokenContract, _amount);
    }
}

上述合约并不复杂,我们通过用户操作的完整流程介绍每一个函数的作用:

  1. 用户对需要进行存款的ERC20合约代币的permit进行签名操作,此过程在链下完成
  2. 完成签名后,将签名内容输入depositWithPermit函数,授权合约访问你的代币并将授权的代币转入存款合约内
  3. 当用户需要代币资产时使用withdraw函数将代币从存款合约转移到自己名下

上述流程在代币合约实现permit函数时可以使用。但部分代币合约没有实现此函数,则需要对代币合约调用approve(address spender, uint256 amount)进行手动授权,再调用存款合约内的deposit函数进行存款,最后可以使用withdraw函数提取存款。

显然,对于用户而言使用实现EIP2612的代币合约进行存款操作更加方便,只需要签署一笔交易就可以实现存款。

部分读者可能对ERC20合约的本质理解不清楚,实际上ERC20合约的核心就是mapping(address => uint256) public balanceOf;,此映射关系实现了对地址拥有代币的记录,我们进行的转账等操作都是对这一映射中数值的改变。

为了方便后文的合约测试,我们在此处实现一个用于EIP712结构化哈希的合约,代码如下:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

contract SigUtils {
    bytes32 internal DOMAIN_SEPARATOR;

    constructor(bytes32 _DOMAIN_SEPARATOR) {
        DOMAIN_SEPARATOR = _DOMAIN_SEPARATOR;
    }

    // keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
    bytes32 public constant PERMIT_TYPEHASH =
        0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9;

    struct Permit {
        address owner;
        address spender;
        uint256 value;
        uint256 nonce;
        uint256 deadline;
    }

    // computes the hash of a permit
    function getStructHash(Permit memory _permit)
        internal
        pure
        returns (bytes32)
    {
        return
            keccak256(
                abi.encode(
                    PERMIT_TYPEHASH,
                    _permit.owner,
                    _permit.spender,
                    _permit.value,
                    _permit.nonce,
                    _permit.deadline
                )
            );
    }

    // computes the hash of the fully encoded EIP-712 message for the domain, which can be used to recover the signer
    function getTypedDataHash(Permit memory _permit)
        public
        view
        returns (bytes32)
    {
        return
            keccak256(
                abi.encodePacked(
                    "\x19\x01",
                    DOMAIN_SEPARATOR,
                    getStructHash(_permit)
                )
            );
    }
}

上述合约较为简单,我们已经实现过多次类似的合约,此处不再赘述。

合约测试

本部分介绍的代码来自Testing EIP-712 Signatures代码仓库

除了上文给出的SigUtils.sol文件,我们也创建了MockERC20合约用于测试(位于test/EIP2612/utils),此合约代码如下:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {ERC20} from "../../../src/EIP-2612/ERC20.sol";

contract MockERC20 is ERC20 {
    constructor() ERC20("Mock Token", "MOCK", 18) {}

    function mint(address _to, uint256 _amount) public {
        _mint(_to, _amount);
    }
}

此合约仅仅实现了mint函数以方便后文进行测试。

Testing EIP-712 Signatures中详细解释了此项目测试的所有流程,在本文中我们仅仅进行简单的解释。

初始化阶段的代码为:

MockERC20 internal token;
SigUtils internal sigUtils;

uint256 internal ownerPrivateKey;
uint256 internal spenderPrivateKey;

address internal owner;
address internal spender;

function setUp() public {
    token = new MockERC20();
    sigUtils = new SigUtils(token.DOMAIN_SEPARATOR());

    ownerPrivateKey = 0xA11CE;
    spenderPrivateKey = 0xB0B;

    owner = vm.addr(ownerPrivateKey);
    spender = vm.addr(spenderPrivateKey);

    token.mint(owner, 1e18);
}

主要完成了各行业的初始化和用户的初始化。在用户初始化中,使用了vm.addr()函数,此函数会返回指定私钥的地址。此处也使用mint函数为owenr铸造了代币。

通过私钥计算地址的方法我们已在上一篇文章中给出

完成初始化后,我们首先实现对核心功能Permit的测试,具体代码如下:

function test_Permit() public {
    SigUtils.Permit memory permit = SigUtils.Permit({
        owner: owner,
        spender: spender,
        value: 1e18,
        nonce: 0,
        deadline: 1 days
    });

    bytes32 digest = sigUtils.getTypedDataHash(permit);

    (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, digest);

    token.permit(
        permit.owner,
        permit.spender,
        permit.value,
        permit.deadline,
        v,
        r,
        s
    );

    assertEq(token.allowance(owner, spender), 1e18);
    assertEq(token.nonces(owner), 1);
}

此处的vm.sign()函数可以实现使用私钥对哈希摘要进行签名。

完成核心功能的测试后,我们需要确认各个限制条件是否都能实现,主要的限制条件为:

  • deadline
  • signer
  • nonce

对于deadline的测试核心使用了vm.warp(),此函数可以实现改变区块时间。对于其他限制因素的测试,我们在前文基本进行过描述,读者可以自行阅读源代码。

在源代码中使用了vm.expectRevert函数,此函数会判断下一次调用抛出的异常是否符合预期。如果不符合,则测试失败

最后,我们测试transferFrom函数,基本思路与上文类似,先测试函数是否可以正常运行,再测试各个限制条件是否可以发挥作用。在此处,我们主要使用了vm.prank用于切换测试环境中的msg.sender的地址。

我们也需要对deposit合约进行测试,基本思路类似。请读者自行阅读源代码进行理解。

总结

本文主要介绍了EIP712的拓展使用方法,涉及以下EIP标准:

  • EIP2770
  • EIP2771
  • EIP2612

其中,前两者解决了合约交互方在没有ETH的情况下与合约交互的问题,后者解决了在ERC20合约内通过结构化哈希签名通过一次交易完成授权操作,优化了用户体验。

前者的优秀实践可以参考GSN项目,而后者在大量ERC20合约内都有所实现,而且在solmate项目实现的ERC20合约被默认实现。