Skip to content

Latest commit

 

History

History
 
 

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 

readme.md

title 52. EIP712 类型化数据签名
tags
solidity
erc20
eip712
openzepplin

WTF Solidity极简入门: 52. EIP712 类型化数据签名

我最近在重新学 Solidity,巩固一下细节,也写一个“WTF Solidity极简入门”,供小白们使用(编程大佬可以另找教程),每周更新 1-3 讲。

推特:@0xAA_Science@WTFAcademy_

社区:Discord微信群官网 wtf.academy

所有代码和教程开源在 github: github.com/AmazingAng/WTF-Solidity


这一讲,我们介绍一种更先进、安全的签名方法,EIP712 类型化数据签名。

EIP712

之前我们介绍了 EIP191 签名标准(personal sign) ,它可以给一段消息签名。但是它过于简单,当签名数据比较复杂时,用户只能看到一串十六进制字符串(数据的哈希),无法核实签名内容是否与预期相符。

image1

EIP712类型化数据签名是一种更高级、更安全的签名方法。当支持 EIP712 的 Dapp 请求签名时,钱包会展示签名消息的原始数据,用户可以在验证数据符合预期之后签名。

image2

EIP712 使用方法

EIP712 的应用一般包含链下签名(前端或脚本)和链上验证(合约)两部分,下面我们用一个简单的例子 EIP712Storage 来介绍 EIP712 的使用方法。EIP712Storage 合约有一个状态变量 number,需要验证 EIP712 签名才可以更改。

链下签名

  1. EIP712 签名必须包含一个 EIP712Domain 部分,它包含了合约的 name,version(一般约定为 “1”),chainId 和 verifyingContract(验证签名的合约地址)。

    EIP712Domain: [
        { name: "name", type: "string" },
        { name: "version", type: "string" },
        { name: "chainId", type: "uint256" },
        { name: "verifyingContract", type: "address" },
    ]

    这些信息会在用户签名时显示,并确保只有特定链的特定合约才能验证签名。你需要在脚本中传入相应参数。

    const domain = {
        name: "EIP712Storage",
        version: "1",
        chainId: "1",
        verifyingContract: "0xf8e81D47203A594245E36C48e151709F0C19fBe8",
    };
  2. 你需要根据使用场景自定义一个签名的数据类型,他要与合约匹配。在 EIP712Storage 例子中,我们定义了一个 Storage 类型,它有两个成员: address 类型的 spender,指定了可以修改变量的调用者;uint256 类型的 number,指定了变量修改后的值。

    const types = {
        Storage: [
            { name: "spender", type: "address" },
            { name: "number", type: "uint256" },
        ],
    };
  3. 创建一个 message 变量,传入要被签名的类型化数据。

    const message = {
        spender: "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4",
        number: "100",
    };

    image3

  4. 调用钱包对象的 signTypedData() 方法,传入前面步骤中的 domaintypes,和 message 变量进行签名(这里使用 ethersjs v6)。

    // 获得provider
    const provider = new ethers.BrowserProvider(window.ethereum)
    // 获得signer后调用signTypedData方法进行eip712签名
    const signature = await signer.signTypedData(domain, types, message);
    console.log("Signature:", signature);

    image4

链上验证

接下来就是 EIP712Storage 合约部分,它需要验证签名,如果通过,则修改 number 状态变量。它有 5 个状态变量。

  1. EIP712DOMAIN_TYPEHASH: EIP712Domain 的类型哈希,为常量。
  2. STORAGE_TYPEHASH: Storage 的类型哈希,为常量。
  3. DOMAIN_SEPARATOR: 这是混合在签名中的每个域 (Dapp) 的唯一值,由 EIP712DOMAIN_TYPEHASH 以及 EIP712Domain (name, version, chainId, verifyingContract)组成,在 constructor() 中初始化。
  4. number: 合约中存储值的状态变量,可以被 permitStore() 方法修改。
  5. owner: 合约所有者,在 constructor() 中初始化,在 permitStore() 方法中验证签名的有效性。

另外,EIP712Storage 合约有 3 个函数。

  1. 构造函数: 初始化 DOMAIN_SEPARATORowner
  2. retrieve(): 读取 number 的值。
  3. permitStore: 验证 EIP712 签名,并修改 number 的值。首先,它先将签名拆解为 r, s, v。然后用 DOMAIN_SEPARATOR, STORAGE_TYPEHASH, 调用者地址,和输入的 _num 参数拼出签名的消息文本 digest。最后利用 ECDSArecover() 方法恢复出签名者地址,如果签名有效,则更新 number 的值。
// SPDX-License-Identifier: MIT
// By 0xAA 
pragma solidity ^0.8.0;

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("Storage(address spender,uint256 number)");
    bytes32 private DOMAIN_SEPARATOR;
    uint256 number;
    address owner;

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

    /**
     * @dev Store value in variable
     */
    function permitStore(uint256 _num, 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, msg.sender, _num))
        )); 
        
        address signer = digest.recover(v, r, s); // 恢复签名者
        require(signer == owner, "EIP712Storage: Invalid signature"); // 检查签名

        // 修改状态变量
        number = _num;
    }

    /**
     * @dev Return value 
     * @return value of 'number'
     */
    function retrieve() public view returns (uint256){
        return number;
    }    
}

部署复现

  1. Remix 部署 EIP712Storage 合约。

  2. 运行 eip712storage.html,根据浏览器的内容安全策略(Content Security Policy)的要求,MetaMask 不能通过打开的本地文件(file:// 协议)与 DApp 通信。 可以使用 Node 静态文件服务器 http-server 启动本地服务,在包含 eip712storage.html 文件的目录下执行以下命令:

    npm install -g http-server
    http-server

    在浏览器中打开 http://127.0.0.1:8080 就可以访问了。 然后将 Contract Address 改为部署的 EIP712Storage 合约地址,然后依次点击 Connect MetamaskSign Permit 按钮签名。签名要使用部署合约的钱包,比如 Remix 测试钱包:

    public_key: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
    private_key: 503f38a9c967ed597e47fe25643985f032b072db8075426a92110f82df48dfcb
  3. 调用合约的 permitStore() 方法,输入相应的 _num 和签名,修改 number 的值。

  4. 调用合约的 retrieve() 方法,看到 number 的值已经改变。

总结

这一讲,我们介绍了 EIP712 类型化数据签名,一种更先进、安全的签名标准。在请求签名时,钱包会展示签名消息的原始数据,用户可以在验证数据后签名。该标准应用广泛,在 Metamask,Uniswap 代币对,DAI 稳定币等场景均有使用,希望大家好好掌握。