# EIP-712 使用详解

By [xyyme.eth](https://paragraph.com/@xyyme) · 2022-06-03

---

之前的[文章](https://mirror.xyz/xyyme.eth/-e1FodE7HZcwhw60VuGnbUue2SfCN4kn6JVg0JjCFS4)我们介绍过如何对数据进行签名，利用签名技术我们可以实现一些功能例如白名单校验等。但是这种签名技术的应用场景比较简单，一般就是给一串字符串，或者一串哈希签名，如果我们想为更复杂的数据签名就无法实现了。

EIP-712 的出现就是为了解决这个问题，利用 EIP-712，我们可以对更大的数据集，例如对结构体进行签名。那么这种签名格式有什么实际的应用场景呢。使用过 Uniswap，PancakeSwap 等 DEX 的朋友应该有印象，在移除 LP 流动性的时候，我们需要先签名，然后再发送一笔交易移除流动性。正常情况下，其实应该我们先调用 LP 代币的授权方法，授权 DEX 合约可以转移我们的 LP，然后再去移除流动性。而这种二合一的实现正是应用了 EIP-712。它帮助我们仅仅签名一次，就可以将两步交易合并为一步交易，从而节省 Gas 费用。这篇文章我们就来看看 EIP-712 到底是怎么使用的。

### 基本结构

#### 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 verifyingContract)"
    );
    

#### 签名对象

这里我们以签名 `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;
    }
    

那么需要写成（按照首字母排序，因此 `Asset` 在 `Person` 前面）：

    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.contents` 是 `string` 类型，因此需要进行哈希。

### 代码实践

看了这么多概念，是不是已经懵了。我们马上来看看代码：

#### 合约

    pragma solidity 0.8.10;
    
    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,string version,uint256 chainId,address verifyingContract)"
        );
    
        // 签名对象哈希
        bytes32 internal constant TYPE_HASH = keccak256(
            "Mail(address from,address to,string contents)"
        );
    
        constructor() {
            DOMAIN_SEPARATOR = keccak256(
                // 计算 DOMAIN_SEPARATOR 哈希
                // 这里的 name 为 EIP712Mail，即合约名
                // version 为 1
                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.providers.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.utils.splitSignature(signature);
        console.log(">>> Signature:", signParts);
        // 打印签名本身
        console.log(signature);
    }
    
    sign()
    

运行脚本，得到的结果如下：

![](https://storage.googleapis.com/papyrus_images/a2a212ce3b2ddccc34174cfb6aec83bdd7a1ced7da3b2e0d95d5901c065a82c3.png)

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

#### 使用 Python 进行签名：

也可以使用 Python 进行签名，不过语法稍微有些复杂。我们这里作展示，但是我个人还是推荐使用 JavaScript 进行签名。

    # 需要安装 web3, eth-account 依赖
    import eth_account
    from web3 import Web3
    from eth_account.messages import encode_structured_data
    
    web3 = Web3(Web3.HTTPProvider())
    
    private_key = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'
    account = web3.eth.account.privateKeyToAccount(private_key)
    
    domain = {
        "name": "EIP712Mail",
        "version": "1",
        "chainId": web3.eth.chain_id,
        "verifyingContract": "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0",
    }
    
    value = {
        "from": '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266',
        "to": '0x70997970c51812dc3a010c7d01b50e0d17dc79c8',
        "contents": 'xyyme'
    }
    
    // 这里是固定格式，套用即可
    msg = {
        "types": {
            "EIP712Domain": [
                {"name": "name", "type": "string"},
                {"name": "version", "type": "string"},
                {"name": "chainId", "type": "uint256"},
                {"name": "verifyingContract", "type": "address"}
            ],
            "Mail": [
                {"name": 'from', "type": 'address'},
                {"name": 'to', "type": 'address'},
                {"name": 'contents', "type": 'string'}
            ]
        },
        "domain": domain,
        "primaryType": 'Mail',
        "message": value
    }
    
    // 需要先对结构数据进行编码
    encoded_data = encode_structured_data(msg)
    
    // 再进行签名
    signed_message = web3.eth.account.sign_message(encoded_data, private_key)
    
    print(signed_message)
    r = signed_message['r']
    s = signed_message['s']
    v = signed_message['v']
    
    print(f'r => {hex(r)}')
    print(f's => {hex(s)}')
    print(f'v => {hex(v)}')
    

同样可以打印出 rsv 签名。不过我在使用 Python 的时候遇到了一个问题，就是如果待签名结构体中存在 `bytes` 字段，需要使用：

    bytes("...xxx...", "utf-8")
    

对内容进行编码，而这种类型无法进行 JSON 序列化，这是当前版本（5.29.1）存在的问题，更新到测试版（6.0.0b2）则可以成功签名，但是签名结果错误。这里我没有深究，也可能是我的使用方法有误。个人还是推荐使用 JavaScript 进行签名，更加简单易用。

### 应用

最开始我们提到过，Uniswap 中运用了 EIP-712，使得移除流动性的操作由两步变成一步，减少了 Gas 的使用。由于 Uniswap 的操作比较复杂，需要组 LP 等，用于演示的话会占用比较长的篇幅。我们这里使用 Dai 的合约进行演示，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` 用于防止重放攻击，这是什么意思呢？

假设 A 之前有一个对 B 进行授权的签名，后来 A 又取消了授权，也就是将授权额度减为 0。或者说 B 在一段时间内已经耗费完了所有授权。假设没有防止重放攻击，那么在这时，如果 B 是有恶意的，那么 B 就可以再次使用之前 A 的授权签名来进行授权，从而花费 A 的 token。如果加上了 `nonce` 字段，那么 A 在每次签名的时候，也对 `nonce` 进行签名，同时合约中对 `nonce` 进行记录，且是递增的，这样就可以确保每次的签名只能够使用一次，防止发生重放攻击。

接下来，我们来部署 Dai 的[代码](https://etherscan.io/address/0x6b175474e89094c44da98b954eedeac495271d0f#contracts)进行测试，部署时 `chainId` 使用 31337，这是 hardhat node 本地链的 chainId。然后使用 JavaScript 在链下进行签名：

    const {ethers} = require("ethers");
    const provider = new ethers.providers.JsonRpcProvider()
    
    const privateKey1 = `0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80` // Private key of account 1
    const wallet = new ethers.Wallet(privateKey1, provider)
    
    async function sign() {
        const { chainId } = await provider.getNetwork();
        const domain = {
            name: 'Dai Stablecoin',
            version: '1',
            chainId: chainId,
            verifyingContract: '0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9',
        };
        const types = {
            Permit: [
                {name: 'holder', type: 'address'},
                {name: 'spender', type: 'address'},
                {name: 'nonce', type: 'uint256'},
                {name: 'expiry', type: 'uint256'},
                {name: 'allowed', type: 'bool'},
            ]
        };
    
        // 这里 expiry 需要使用 0 或者比当前时间大的时间戳
        // 由于是初次授权，因此 nonce 为 0，下次递增
        const value = {
            holder: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266',
            spender: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8',
            nonce: 0,
            expiry: 2208963661,
            allowed: true
        };
        const signature = await wallet._signTypedData(
            domain,
            types,
            value
        );
    
        let signParts = ethers.utils.splitSignature(signature);
        console.log(">>> Signature:", signParts);
        console.log(signature);
    }
    
    sign()
    

打印出签名结果，使用第三方地址调用 `permit` 函数传入对应的参数。接下来我们调用 `allowance` 函数传入 `holder` 与 `spender`，结果非零，为 `type(uint).max`，说明我们操作成功。

对于 Uniswap 的签名操作，感兴趣的朋友可以自己实践操作一下，我们这里不再演示。

### 总结

EIP-712 初次看上去比较复杂，其实只要掌握了用法，基本都是套用即可，难度不高。在业界的一些应用也确实能够提高用户的体验。

### 关于我

欢迎[和我交流](https://linktr.ee/xyymeeth)

### 参考

[https://www.8btc.com/article/6669785](https://www.8btc.com/article/6669785)

[

EIP-712: Typed structured data hashing and signing
--------------------------------------------------

A procedure for hashing and signing of typed structured data as opposed to just bytestrings.

https://eips.ethereum.org



](https://eips.ethereum.org/EIPS/eip-712)

[

EIP2612: 通过链下签名授权实现更少 Gas 的 ERC20代币
-----------------------------------

本文介绍了一种通过线下签名的进行授权的方式，来转移 gas 费用。

https://learnblockchain.cn

![](https://storage.googleapis.com/papyrus_images/65bbda98b502850f5aff8924df3c0bdb2090c82a1369c8e9f7a74d2100f984ea.webp)

](https://learnblockchain.cn/article/1496)

---

*Originally published on [xyyme.eth](https://paragraph.com/@xyyme/eip-712)*
