eip712结构化数据签名

eip712结构化数据签名

EIP-712 结构化数据签名

一、为什么需要 EIP-712?

传统签名的问题

在第 07 章我们学过用 eth_sign 签名。但用户在钱包中看到的是一串不可读的哈希值

MetaMask 弹窗:
  "您正在签名以下消息:"
  0x1bf2c0ce4546651a1a2feb457b39d891a6b83931cc2454434f39961345ac378c

用户: "这是什么??我签的是转账还是授权?完全看不懂!"

这会导致:

  1. 用户不知道自己签了什么(可能被钓鱼)
  2. 恶意 DApp 可以诱骗用户签名(用户以为签的是登录,实际是授权转账)

EIP-712 的解决方案

EIP-712 定义了一种结构化数据签名标准,让钱包能以可读的形式展示签名内容:

MetaMask 弹窗(EIP-712):
  ┌─────────────────────────────────┐
  │  EIP712Storage 请求签名           │
  │                                 │
  │  Domain:                        │
  │    Name: EIP712Storage          │
  │    Version: 1                   │
  │    Chain ID: 11155111           │
  │    Contract: 0x1234...5678      │
  │                                 │
  │  Storage:                       │
  │    Spender: 0xABCD...EF01       │
  │    Number: 42                   │
  │                                 │
  │  [签名] [拒绝]                   │
  └─────────────────────────────────┘

用户: "哦!我是在授权 0xABCD 把 number 设为 42,确认!"

二、EIP-712 的核心结构

EIP-712 的签名消息由三部分组成:

签名数据 = "\x19\x01" + DOMAIN_SEPARATOR + structHash

其中:
  \x19\x01     → EIP-712 固定前缀(区别于其他签名标准)
  DOMAIN_SEPARATOR → 域分隔符(唯一标识这个合约)
  structHash   → 结构化数据的哈希(具体的业务内容)

2.1 Domain Separator(域分隔符)

防止一个合约的签名被拿到另一个合约使用:

bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode(
    keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
    keccak256(bytes("EIP712Storage")),  // 合约名称
    keccak256(bytes("1")),              // 版本号
    block.chainid,                     // 链 ID
    address(this)                      // 合约地址
));
字段作用
name合约/应用名称,让用户知道是哪个应用请求签名
version版本号,升级后旧签名失效
chainId防跨链重放
verifyingContract防跨合约重放

2.2 TypeHash(类型哈希)

定义结构化数据的”模板”:

// 定义数据结构的 TypeHash
bytes32 constant STORAGE_TYPEHASH = keccak256(
    "Storage(address spender,uint256 number)"
);

2.3 StructHash(结构哈希)

将具体的数据填入模板并哈希:

bytes32 structHash = keccak256(abi.encode(
    STORAGE_TYPEHASH,
    msg.sender,    // spender: 谁在操作
    _num           // number: 要存储的值
));

三、完整的签名与验证流程

┌───────────────────────────────────────────────────┐
│                  链下(前端/钱包)                    │
├───────────────────────────────────────────────────┤
│  1. 构造 EIP-712 签名请求(包含 domain + message)   │
│  2. 调用 eth_signTypedData_v4 方法                  │
│  3. 钱包弹窗展示结构化数据,用户确认                  │
│  4. 钱包用私钥签名,返回 signature (v, r, s)        │
└───────────────────────────────────────────────────┘


┌───────────────────────────────────────────────────┐
│                  链上(智能合约)                     │
├───────────────────────────────────────────────────┤
│  1. 重新构造 digest:                                │
│     digest = keccak256("\x19\x01" + DOMAIN + structHash) │
│  2. ecrecover(digest, v, r, s) → 恢复签名者地址     │
│  3. 验证签名者是否为 owner                          │
│  4. 通过 → 执行操作                                │
└───────────────────────────────────────────────────┘

四、代码详解

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract EIP712Storage {
    using ECDSA for bytes32;

    // TypeHash: 定义 Domain 的结构
    bytes32 private constant EIP712DOMAIN_TYPEHASH = keccak256(
        "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
    );

    // TypeHash: 定义业务数据的结构
    bytes32 private constant STORAGE_TYPEHASH = keccak256(
        "Storage(address spender,uint256 number)"
    );

    // Domain Separator: 部署时计算,唯一标识此合约
    bytes32 private DOMAIN_SEPARATOR;

    uint256 number;     // 要存储的值
    address owner;      // 有权签名的地址

    constructor() {
        DOMAIN_SEPARATOR = keccak256(abi.encode(
            EIP712DOMAIN_TYPEHASH,
            keccak256(bytes("EIP712Storage")),  // name
            keccak256(bytes("1")),              // version
            block.chainid,                     // chainId
            address(this)                      // verifyingContract
        ));
        owner = msg.sender;
    }

    /// @notice 通过 EIP-712 签名来修改 number(无需 owner 直接调用)
    function permitStore(uint256 _num, bytes memory _signature) public {
        require(_signature.length == 65, "Invalid signature length");

        // 提取 r, s, v
        bytes32 r; bytes32 s; uint8 v;
        assembly {
            r := mload(add(_signature, 0x20))
            s := mload(add(_signature, 0x40))
            v := byte(0, mload(add(_signature, 0x60)))
        }

        // 构造 EIP-712 digest
        bytes32 digest = keccak256(abi.encodePacked(
            "\x19\x01",                          // EIP-712 前缀
            DOMAIN_SEPARATOR,                    // 域分隔符
            keccak256(abi.encode(                 // structHash
                STORAGE_TYPEHASH,
                msg.sender,                      // spender
                _num                             // number
            ))
        ));

        // 从签名恢复签名者地址
        address signer = digest.recover(v, r, s);
        // 验证签名者是 owner
        require(signer == owner, "Invalid signature");

        // 签名有效,修改状态
        number = _num;
    }

    function retrieve() public view returns (uint256) {
        return number;
    }
}

五、前端签名代码(ethers.js)

const { ethers } = require("ethers");

async function signEIP712() {
    const provider = new ethers.BrowserProvider(window.ethereum);
    const signer = await provider.getSigner();

    // 定义 Domain(与合约中一致)
    const domain = {
        name: "EIP712Storage",
        version: "1",
        chainId: 11155111,  // Sepolia
        verifyingContract: "0x合约地址"
    };

    // 定义类型
    const types = {
        Storage: [
            { name: "spender", type: "address" },
            { name: "number", type: "uint256" }
        ]
    };

    // 定义具体数据
    const message = {
        spender: await signer.getAddress(),
        number: 42
    };

    // 签名!钱包会弹窗展示结构化数据
    const signature = await signer.signTypedData(domain, types, message);
    console.log("签名:", signature);

    // 将 signature 发送给合约的 permitStore 函数
}

六、EIP-712 vs 普通签名 对比

对比项eth_sign(普通签名)EIP-712(结构化签名)
用户看到的不可读的哈希值结构化的可读数据
安全性容易被钓鱼用户能看懂签名内容
前缀\x19Ethereum Signed Message:\n32\x19\x01
跨合约保护Domain Separator
标准化程度基础高度标准化
钱包支持所有钱包主流钱包(MetaMask等)

七、\x19 前缀家族

前缀标准用途
\x19Ethereum Signed Message:\nEIP-191普通消息签名
\x19\x01EIP-712结构化数据签名
\x19\x00EIP-191带验证者的数据

八、总结

EIP-712 的核心价值:

  1. 可读性:用户在钱包中能看懂签名内容
  2. 安全性:Domain Separator 防止跨合约/跨链重放
  3. 标准化:所有 DApp 使用统一格式,钱包统一展示
  4. 基石作用:ERC-2612(Permit)、ERC-4337(账户抽象)等标准都建立在 EIP-712 之上