xyyme.eth

Posted on Jun 03, 2022Read on Mirror.xyz

EIP-712 使用详解

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

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;
}

那么需要写成(按照首字母排序,因此 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,即签名对象类型的哈希。接下来依次是对象的各个字段,如果是变长类型例如 stringbytes,则需要对其进行哈希。例如,这里的 mail.contentsstring 类型,因此需要进行哈希。

代码实践

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

合约

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()

运行脚本,得到的结果如下:

我们将 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 的代码进行测试,部署时 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 函数传入 holderspender,结果非零,为 type(uint).max,说明我们操作成功。

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

总结

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

关于我

欢迎和我交流

参考

https://www.8btc.com/article/6669785

https://eips.ethereum.org/EIPS/eip-712

https://learnblockchain.cn/article/1496