EIP-712笔记

EIP-712使用详情

利用签名技术我们可以实现一些功能例如白名单校验等。但是这种签名技术的应用场景比较简单,一般就是给一串字符串,或者一串哈希签名,如果我们想为更复杂的数据签名就无法实现了。

EIP-712出现是为了解决这个问题,利用EIP-712,我们可以对更大的数据集,例如对结构体进行签名。在Uniswap PancakeSwap等DEX在移除LP流动性的时候,我们需要先签名,然后再发送一笔交易移除流动性。正常情况下,其实我们先调用LP代币的授权方法,授权DEX合约可以转移LP,然后再去移除流动性。这种二合一的实现正是应用了EIP-712。他帮助我们只需要签名一次就可以将两部交易合并为一步交易,从而节省gas费用。

基本结构

EIP712Domain

顾名思义,是一个与 相关的结构体,总共包含五个字段:

  • name 合约或者协议名称

  • version 合约的版本

  • chainId 合约部署的链Id,一般使用block.chainid 即当前链Id

  • verifyingContract 签名的合约地址,一般用address(this)

  • salt 随机数盐,一般不常用

DOMAIN_SEPARATOR

EIP712Domain数据的哈希值,即:

DOMAIN_SEPARATOR = keccak256 (
            abi.encode(
                    EIP712DOMAIN_TYPEHASH,
                    keccak256(name,合约名称),
                    keccak256(version,合约版本),
                    chainId,
                    verifyingContract
            )
)

EIP712DOMAIN_TYPEHASH

bytes32 constant EIP712DOMAIN_TYPEHASH = keccak256(
            "EIP712Domain(string name,string version,uint256 chainId,address verifying)"
);

签名对象

这里我们以签名Mail 对象为例

struct Mail{
        address from;
        address to;
        string contents;
}

签名对象类型哈希

bytes32 internal constant TYPE_HASH = keccak256(
        "Mail(address from,address to,string contents)"
);

注意对象名要首字母大写,结构体字段按照函数签名编写,这是规范,套用即可

如果结构体中还有其他结构体,例如:

struct Transaction{
        Person from;
        Person to;
        Asset	tx;
}

struct Asset{
        address token;
        uint256 amount;
}

struct Person{
        address wallet;
        string name;
}

需要写成(按照首字母排序,因此AssetPerson 前面):

Transaction(Person from,Person to,Asset tx) Asset(address token,uint256 amount) Person(address wallet,string name)

签名对象值哈希

keccak256(
        abi.encode(
                TYPE_HASH,
                mail.from,
                mail.to,
                keccak256(bytes(mail.contents))
        )
);

其中第一个参数为TYPE_HASH ,即签名对象类型的哈希。接下来依次是对象的各个字段,如果变长类型例如string ,bytes 则需要对其进行哈希。例如:这里的mail.contentssrting 类型,因此需要进行哈希。

代码

合约

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.0;

contract EIP712Mail {
    //Mail 是带签名结构体
    struct Mail {
        address from;
        address to;
        string contents;
    }

    struct EIP712Domain {
        string name;
        string version;
        uint256 chainId;
        address verifyingContract;
    }
    bytes32 public immutable DOMAIN_SEPARATOR;

    //固定不变的
    bytes32 public constant EIP712DOMAIN_TYPEHASH =
        keccak256(
            "EIP712Domain(string name,strinf version,uint256 chainId,address verifyingContract)"
        );

    //签名对象哈希
    bytes32 internal constant TYPE_HASH =
        keccak256("Mail(address from,address to,string contents)");

    constructor() {
        //Domain的HASH
        DOMAIN_SEPARATOR = keccak256(
            //计算DMIAN_SEPARATOR哈希
            //这里的name为EIP712Mail,即合约名称
            abi.encode(
                EIP712DOMAIN_TYPEHASH,
                keccak256("EIP712Mail"),
                keccak256("1"),
                block.chainid,
                address(this)
            )
        );
    }

    //计算待签名的结构体的哈希
    function hashStruct(Mail memory mail) public pure returns (bytes32) {
        return
            keccak256(
                abi.encode(
                    TYPE_HASH,
                    mail.from,
                    mail.to,
                    keccak256(bytes(mail.contents))
                )
            );
    }

    function verify(
        Mail memory mail,
        address signer,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) public view returns (bool) {
        // Note:we need to use `encodePacked` here instead of `encode`
        //固定格式套用即可
        bytes32 digest = keccak256(
            abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, hashStruct(mail))
        );
        return ecrecover(digest, v, r, s) == signer;
    }
}

verify 函数接收三个参数,分别是待签名结构体,签名地址,v,r,s。其中v,r,s是构成签名的三部分,签名一共有65个字节,前32个字节是r,接下来32个字节是s,最后一个字节是v,ecrecover 是Solidity内置函数,可以用于验证签名,他会根据digest以及签名内容v,r,s来计算出签名人的地址。如果结果等于传入的签名地址。则说明验证签名正确。

使用JavaScript进行签名:

const { ethers } = require('ethers')
// 将合约部署在 hardhat node 本地链上
const provider = new ethers.JsonRpcProvider()

// 这里我们使用 hardhat node 自带的地址进行签名
const privateKey = `0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80`
const wallet = new ethers.Wallet(privateKey, provider)

async function sign() {
  // 获取 chainId
  const { chainId } = await provider.getNetwork()

  // 构造 domain 结构体
  // 最后一个地址字段,由于我们在合约中使用了 address(this)
  // 因此需要在部署合约之后,将合约地址粘贴到这里
  const domain = {
    name: 'EIP712Mail',
    version: '1',
    chainId: 4,
    verifyingContract: '0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0',
  }
  // The named list of all type definitions
  // 构造签名结构体类型对象
  const types = {
    Mail: [
      { name: 'from', type: 'address' },
      { name: 'to', type: 'address' },
      { name: 'contents', type: 'string' },
    ],
  }
  // The data to sign
  // 自行构造一个结构体的值
  const value = {
    from: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266',
    to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8',
    contents: 'xyyme',
  }
  const signature = await wallet.signTypedData(domain, types, value)

  // 将签名分割成 v r s 的格式
  let signParts = ethers.Signature.from(signature)
  console.log('>>> Signature:', signParts)
  // 打印签名本身
  console.log(signature)
}

sign()

我们将rsv签名,value值,以及签名地址传给verify 函数进行验证,结果为true,说明验证成功

应用

在Uniswap中运用了EIP-712,使得移除流动性的操作由两步变成一步,减少了Gas的使用。Dai合约中有一个Permit函数,用于第三方授权,同样也是应用了EIP-712标准。

在ERC20中,A可以调用approve 来对B进行授权,而Dai合约中的Permit函数的目的就是,A提前在联系啊对授权对象进行签名,这样第三方就可以拿着A的签名去调用permit 来实现A的授权操作,从而使A在不发送交易的情况下就能够完成授权操作。

Dai核心代码

contract Dai is LibNote {
    // ERC20 信息,name 和 version 用于 domain 签名
    string  public constant name     = "Dai Stablecoin";
    string  public constant symbol   = "DAI";
    string  public constant version  = "1";
    uint8   public constant decimals = 18;
    uint256 public totalSupply;

    mapping (address => uint)                      public balanceOf;
    mapping (address => mapping (address => uint)) public allowance;
    // nonces 用于避免重放攻击
    mapping (address => uint)                      public nonces;

    // --- EIP712 niceties ---
    bytes32 public DOMAIN_SEPARATOR;

    // 计算签名结构体 Permit 的哈希
    // bytes32 public constant PERMIT_TYPEHASH = keccak256("Permit(address holder,address spender,uint256 nonce,uint256 expiry,bool allowed)");

    bytes32 public constant PERMIT_TYPEHASH = 0xea2aa0a1be11a07ed86d755c93467f4f82362b452371d1ba94d1715123511acb;

    constructor(uint256 chainId_) public {
        wards[msg.sender] = 1;
        // 计算 domain 哈希
        DOMAIN_SEPARATOR = keccak256(abi.encode(
            keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
            keccak256(bytes(name)),
            keccak256(bytes(version)),
            chainId_,
            address(this)
        ));
    }

    // 常规授权方法
    function approve(address usr, uint wad) 
        external returns (bool) {
        allowance[msg.sender][usr] = wad;
        emit Approval(msg.sender, usr, wad);
        return true;
    }

    // --- Approve by signature ---
    // 重点是这里的 permit 函数
    function permit(address holder, address spender, uint256 nonce, uint256 expiry,
                    bool allowed, uint8 v, bytes32 r, bytes32 s) external
    {
        bytes32 digest =
            keccak256(abi.encodePacked(
                "\x19\x01",
                DOMAIN_SEPARATOR,
                keccak256(abi.encode(PERMIT_TYPEHASH,
                                     holder,
                                     spender,
                                     nonce,
                                     expiry,
                                     allowed))
        ));

        require(holder != address(0), "Dai/invalid-address-0");
        //在这判断是否为持有者签名(验证签名)
        require(holder == ecrecover(digest, v, r, s), "Dai/invalid-permit");
        //判断是否过期
        require(expiry == 0 || now <= expiry, "Dai/permit-expired");
        // 用于防止重放攻击
        require(nonce == nonces[holder]++, "Dai/invalid-nonce");
        uint wad = allowed ? uint(-1) : 0;
        //进行授权
        allowance[holder][spender] = wad;
        emit Approval(holder, spender, wad);
    }
}

permit 函数的参数中,holder地址需要在链下进行签名,spender 即被授权地址nonce用于防止重放攻击。