solidity学习之EIP712

什么是EIP712

EIP712是一种特殊的类型化数据签名,与普通签名不同,EIP712的签名数据是结构化的。使用支持EIP712的Dapp进行签名时,Dapp会展示签名消息的结构化详细数据,用户可以对数据进行验证,确认后再进行签名。

实现逻辑

EIP712分为链下签名和链上校验两部分,链下的签名结构定义需要与链上的验证合约保持一致。

验签逻辑则和普通签名相同,通过r,s,v验证公钥是否一致即可。

具体实现

链下签名

一个标准的签名结构如下所示

json 复制代码
{
  "types": {
    "EIP712Domain": [
      { "name": "name", "type": "string" },
      { "name": "version", "type": "string" },
      { "name": "chainId", "type": "uint256" },
      { "name": "verifyingContract", "type": "address" }
    ],
    "Person": [
      { "name": "name", "type": "string" },
      { "name": "wallet", "type": "address" }
    ],
    "Mail": [
      { "name": "from", "type": "Person" },
      { "name": "to", "type": "Person" },
      { "name": "contents", "type": "string" }
    ]
  },
  "primaryType": "Mail",
  "domain": {
    "name": "MyDapp",
    "version": "1",
    "chainId": 1,
    "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"
  },
  "message": {
    "from": {
      "name": "Alice",
      "wallet": "0x1234567890abcdef1234567890abcdef12345678"
    },
    "to": {
      "name": "Bob",
      "wallet": "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
    },
    "contents": "Hello, Bob!"
  }
}

其中types是签名信息中出现的数据结构的类型定义,包括固定的EIP712Domain以及自定义的结构体,在这个示例中是PersonMail。因为EIP712支持嵌套结构,可以看到Mail中出现了Person的成员变量。

domainmessage就是types中定义的结构的具体实现,domain就是固定的指向EIP712Domain,其中nameversion需要与验签合约中定义的一致,chainIdverifyContract就是合约部署的链与地址。

message在这个示例中就是一个Mail对象,可以看到primaryTypeMail,这代表了message的对象类型,因为支持嵌套,所以在解析时会从primaryType开始解析,然后逐步解析内置的其他结构体。在ether.js中会自动分析primaryType,所以无需指定。

签名时会按照结构体来向用户展示message信息。

链上合约

solidity 复制代码
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract EIP712Storage {
    using ECDSA for bytes32;

    bytes32 private constant EIP712DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
    bytes32 private constant STORAGE_TYPEHASH = keccak256("Person(string name,address wallet)");
    bytes32 private DOMAIN_SEPARATOR;
 }

在变量中定义了两个TYPEHASH常量,分别是domainmessage的type,用于后面生成签名摘要。

solidity 复制代码
    constructor(){
        DOMAIN_SEPARATOR = keccak256(abi.encode(
            EIP712DOMAIN_TYPEHASH, // type hash
            keccak256(bytes("EIP712Storage")), // name
            keccak256(bytes("1")), // version
            block.chainid, // chain id
            address(this) // contract address
        ));
        owner = msg.sender;
    }

在构造函数中定义了domainnameversion,因此链下签名中的domain也要保持一致。

solidity 复制代码
    function permitStore(string memory name, bytes memory _signature) public {
        // 检查签名长度,65是标准r,s,v签名的长度
        require(_signature.length == 65, "invalid signature length");
        bytes32 r;
        bytes32 s;
        uint8 v;
        // 目前只能用assembly (内联汇编)来从签名中获得r,s,v的值
        assembly {
            /*
            前32 bytes存储签名的长度 (动态数组存储规则)
            add(sig, 32) = sig的指针 + 32
            等效为略过signature的前32 bytes
            mload(p) 载入从内存地址p起始的接下来32 bytes数据
            */
            // 读取长度数据后的32 bytes
            r := mload(add(_signature, 0x20))
            // 读取之后的32 bytes
            s := mload(add(_signature, 0x40))
            // 读取最后一个byte
            v := byte(0, mload(add(_signature, 0x60)))
        }

        // 获取签名消息hash
        bytes32 digest = keccak256(abi.encodePacked(
            "\x19\x01",
            DOMAIN_SEPARATOR,
            keccak256(abi.encode(STORAGE_TYPEHASH, name, msg.sender))
        )); 
        
        address signer = digest.recover(v, r, s); // 恢复签名者
        require(signer == msg.sender, "EIP712Storage: Invalid signature"); // 检查签名
    }

可以看到在方法中重新生成了一个签名摘要digest,也就是message hash,其中 "\x19\x01"是签名哈希的固定前缀,然后拼接上domainmessage内容的哈希,再调用recover恢复出signer

此处的recover方法是openzeppelin库的语法糖,因为前面通过 using ECDSA for bytes32;引入了ECDSA,用recover替代了底层的ecrecover实现,使校验更方便。

ERC20 Permit

基于EIP712,可以在链下实现对ERC20token的授权,称为ERC20Permit。

即链下实现签名,链上合约验证,验证成功后调用approve方法。在这种情况下token的owner无需持有gas,只需要在链下签名后将签名给到有gas的B,由B去执行,就可以实现将token授权给B甚至第三方的操作。

solidity 复制代码
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "./IERC20Permit.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";

/**
 * @dev ERC20 Permit 扩展的接口,允许通过签名进行批准,如 https://eips.ethereum.org/EIPS/eip-2612[EIP-2612]中定义。
 *
 * 添加了 {permit} 方法,可以通过帐户签名的消息更改帐户的 ERC20 余额(参见 {IERC20-allowance})。通过不依赖 {IERC20-approve},代币持有者的帐户无需发送交易,因此完全不需要持有 Ether。
 */
contract ERC20Permit is ERC20, IERC20Permit, EIP712 {
    mapping(address => uint) private _nonces;

    bytes32 private constant _PERMIT_TYPEHASH =
        keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");

    /**
     * @dev 初始化 EIP712 的 name 以及 ERC20 的 name 和 symbol
     */
    constructor(string memory name, string memory symbol) EIP712(name, "1") ERC20(name, symbol){}

    /**
     * @dev See {IERC20Permit-permit}.
     */
    function permit(
        address owner,
        address spender,
        uint256 value,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) public virtual override {
        // 检查 deadline
        require(block.timestamp <= deadline, "ERC20Permit: expired deadline");

        // 拼接 Hash
        bytes32 structHash = keccak256(abi.encode(_PERMIT_TYPEHASH, owner, spender, value, _useNonce(owner), deadline));
        bytes32 hash = _hashTypedDataV4(structHash);
        
        // 从签名和消息计算 signer,并验证签名
        address signer = ECDSA.recover(hash, v, r, s);
        require(signer == owner, "ERC20Permit: invalid signature");
        
        // 授权
        _approve(owner, spender, value);
    }

    /**
     * @dev See {IERC20Permit-nonces}.
     */
    function nonces(address owner) public view virtual override returns (uint256) {
        return _nonces[owner];
    }

    /**
     * @dev See {IERC20Permit-DOMAIN_SEPARATOR}.
     */
    function DOMAIN_SEPARATOR() external view override returns (bytes32) {
        return _domainSeparatorV4();
    }

    /**
     * @dev "消费nonce": 返回 `owner` 当前的 `nonce`,并增加 1。
     */
    function _useNonce(address owner) internal virtual returns (uint256 current) {
        current = _nonces[owner];
        _nonces[owner] += 1;
    }
}