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

关于我

欢迎和我交流

参考

Subscribe to xyyme.eth
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.