EIP712 结构化数据签名

  • DeCert.me
  • 发布于 2025-12-15 16:38
  • 阅读 20

数字签名一文中,我们学习了如何对消息进行签名和验证。但在实际应用中,我们经常需要对复杂的结构化数据进行签名,比如订单、投票、授权等。

EIP-712 正是为了解决这个问题而生。它定义了一种对结构化数据进行签名的标准方法,使得:

  • 签名数据在钱包界面中可读性更好(用户能看懂自己在签什么)
  • 签名更安全(防止签名被用于其他用途)
  • 验证更标准化(链上验证有统一的规范)

EIP-712 vs 基础签名

让我们先对比一下 EIP-712 与基础的消息签名有什么不同:

基础签名(eth_sign / personal_sign)

数字签名中,我们使用的是简单消息签名:

// 签名的内容
bytes32 messageHash = keccak256(abi.encodePacked("Hello, Ethereum!"));
bytes32 ethSignedMessageHash = keccak256(abi.encodePacked(
    "\x19Ethereum Signed Message:\n32",
    messageHash
));

问题

  • 用户只能看到一串哈希值或简单文本,不知道具体在签什么
  • 没有明确的数据结构,容易混淆
  • 缺少上下文信息(哪个合约、哪条链)

EIP-712 结构化签名(eth_signTypedData)

EIP-712 改进了这个过程:

// 签名的内容
struct Order {
    address from;
    address to;
    uint256 amount;
}

// 包含域信息(合约地址、链ID等)
bytes32 digest = keccak256(abi.encodePacked(
    "\x19\x01",
    domainSeparator,  // 包含合约地址、链ID等信息
    structHash        // 结构化数据的哈希
));

优势

  • ✅ 用户在钱包中能看到具体的数据结构和值
  • ✅ 包含域信息(domain),防止跨链/跨合约重放
  • ✅ 标准化的格式,钱包能更好地展示

eth_sign

eth_sign(上) 和 eth_signTypedData(下) 签名对比

eth_signTypedData

结构化数据签名流程

EIP-712 提供了一个标准的方法来签名结构化数据:

digest = keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(message))

这包括几个关键步骤,让我们逐一了解:

  1. \x19\x01: 这是一个固定的字节,用于区分不同类型的签名以防冲突。0x19 表示是以太坊的签名消息,0x01 则是特定于 EIP-712 的标识。

    更多签名数据标准参考 EIP-191

  2. 创建域分隔符 (EIP712Domain Separator)

    域分隔符定义了签名消息的上下文环境,例如合约的名称、版本号、链 ID 和验证合约地址等。这可以帮助确保签名在正确的环境中被验证,避免在多个应用间的签名冲突,并帮助防止重放攻击。

    域分隔符可以包含以下字段,具体取决于协议设计者的需求:

    • string name:名称,通常是 DApp 或协议的名称
    • string version:当前版本号,不同版本的签名可能不兼容
    • uint256 chainId:链 ID,表明合约所在的区块链的链ID,以确保签名不会在不同的链之间被重用
    • address verifyingContract:将用来验证签名的智能合约地址
    • bytes32 salt:提供了额外的安全随机性

    示例代码:

    bytes32 domainSeparator = keccak256(
        abi.encode(
            keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
            keccak256(bytes(name)),
            keccak256(bytes(version)),
            chainId,
            verifyingContract
        )
    );
  3. 创建结构化数据的哈希

    假设我们有一个名为 Order 的结构,它包括以下字段:

    struct Order {
        address from;
        address to;
        uint256 amount;
    }

    首先定义并创建该结构的类型哈希:

    string constant TYPEHASH = keccak256("Order(address from,address to,uint256 amount)");

    然后将类型哈希与实际数据值一起编码并哈希:

    bytes32 structHash = keccak256(
        abi.encode(
            TYPEHASH,
            order.from,
            order.to,
            order.amount
        )
    );
  4. 生成最终签名哈希

    结合域分隔符和结构化数据哈希,创建最终的签名哈希:

    bytes32 digest = keccak256(
        abi.encodePacked(
            "\x19\x01",
            domainSeparator,
            structHash
        )
    );

合约中验证签名

合约中可以使用 ecrecover 函数来验证签名,确保签名者的地址与预期相匹配,从而验证签名的真实性和完整性。

签名验证步骤如下:

  • 构造消息哈希:为了验证签名,首先需要重建签名时所用的相同消息哈希。这通常涉及将原始参数按照指定格式进行序列化与哈希处理。
  • 使用 ecrecover 函数恢复签名者的地址:Solidity 语言提供的 ecrecover 函数能够通过给定的消息哈希值及签名(包括r, s, v三个参数)来确定签名者的地址。
  • 校验恢复的地址与预期地址是否匹配:通过比较 ecrecover 函数输出的地址与事先定义的期望地址,确保恢复出的地址与预期一致,从而验证签名者的身份。这一步是确认交易发起者身份合法性的关键环节。

完整合约示例

接下来,我们将给出一个示例智能合约,展示如何在合约中集成 EIP-712 签名机制。这个合约将包括创建 EIP-712 域分隔符,结构化数据的哈希,以及验证签名的功能。

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

contract EIP712Example {
    struct Order {
        address from;
        address to;
        uint256 amount;
    }

    bytes32 constant ORDER_TYPEHASH =
        keccak256(
            "Order(address from,address to,uint256 amount)"
        );

    bytes32 public DOMAIN_SEPARATOR;

    constructor(string memory name, string memory version) {
        DOMAIN_SEPARATOR = keccak256(
            abi.encode(
                keccak256(
                    "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
                ),
                keccak256(bytes(name)),
                keccak256(bytes(version)),
                block.chainid,
                address(this)
            )
        );
    }

    function _hashOrder(Order memory order) internal view returns (bytes32) {
        return keccak256(
            abi.encode(
                ORDER_TYPEHASH,
                order.from,
                order.to,
                order.amount
            )
        );
    }

    function _hashTypedData(bytes32 structHash) internal view returns (bytes32) {
        return keccak256(
            abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash)
        );
    }

    function verify(
        Order memory order,
        bytes memory signature
    ) external view returns (bool) {
        uint8 v;
        bytes32 r;
        bytes32 s;

        if (signature.length != 65) {
            return false;
        }

        assembly {
            r := mload(add(signature, 32))
            s := mload(add(signature, 64))
            v := byte(0, mload(add(signature, 96)))
        }

        bytes32 structHash = _hashOrder(order);
        bytes32 digest = _hashTypedData(structHash);
        address signer = ecrecover(digest, v, r, s);

        return signer == order.from;
    }
}

在客户端进行签名

使用 ethers.js

ethers v6 (推荐)

import { BrowserProvider } from 'ethers';

const domain = {
    name: 'MyOrderApp',
    version: '1',
    chainId: 10, // 使用具体的Chain ID
    verifyingContract: '0x2d04c136Ebb705bd2cE858e28BeFe258C1Ec51F7', // 合约具体的合约地址
};

// 类型的定义
const types = {
    Order: [
        { name: 'from', type: 'address' },
        { name: 'to', type: 'address' },
        { name: 'amount', type: 'uint256' }
    ]
};

// 创建一个符合 Order 结构的消息实例
const order = {
    from: '0xfrom...',
    to: '0xto...',
    amount: 1000
};

async function requestSignature() {
    // 使用 ethers v6 连接到以太坊钱包
    const provider = new BrowserProvider(window.ethereum);

    await provider.send('eth_requestAccounts', []); // 请求用户授权
    const signer = await provider.getSigner();

    // 发起签名
    const signature = await signer.signTypedData(domain, types, order);
    console.log('Signature:', signature);
}

ethers v5 (旧版本)

const domain = {
    name: 'MyOrderApp',
    version: '1',
    chainId: 10, // 使用具体的Chain ID
    verifyingContract: '0x2d04c136Ebb705bd2cE858e28BeFe258C1Ec51F7', // 合约具体的合约地址
};

// 类型的定义
const types = {
    Order: [
        { name: 'from', type: 'address' },
        { name: 'to', type: 'address' },
        { name: 'amount', type: 'uint256' }
    ]
};

// 创建一个符合 Order 结构的消息实例
const order = {
    from: '0xfrom...',
    to: '0xto...',
    amount: 1000
};

async function requestSignature() {
    // 使用 ethers 连接到以太坊钱包
    const provider = new ethers.providers.Web3Provider(window.ethereum);

    await provider.send('eth_requestAccounts', []); // 请求用户授权
    const signer = provider.getSigner();

    // 发起签名
    const signature = await signer._signTypedData(domain, types, order);
    console.log('Signature:', signature);
}

使用 viem

import { createWalletClient, custom } from 'viem'
import { mainnet } from 'viem/chains'

const domain = {
    name: 'MyOrderApp',
    version: '1',
    chainId: 10,
    verifyingContract: '0x2d04c136Ebb705bd2cE858e28BeFe258C1Ec51F7',
}

const types = {
    Order: [
        { name: 'from', type: 'address' },
        { name: 'to', type: 'address' },
        { name: 'amount', type: 'uint256' }
    ]
}

const order = {
    from: '0xfrom...',
    to: '0xto...',
    amount: 1000n  // 注意:viem 使用 bigint
}

async function requestSignature() {
    const client = createWalletClient({
        chain: mainnet,
        transport: custom(window.ethereum)
    })

    const [account] = await client.requestAddresses()

    // 发起签名
    const signature = await client.signTypedData({
        account,
        domain,
        types,
        primaryType: 'Order',
        message: order,
    })

    console.log('Signature:', signature)
}

参考

总结

EIP-712 是基础消息签名的进阶版本,它为结构化数据签名提供了标准化的解决方案。

核心优势

  • 📊 可读性:用户能在钱包中看到具体的数据结构和值,而不是一串哈希
  • 🔒 安全性:通过域分隔符(Domain Separator)防止跨链、跨合约的签名重放攻击
  • 🎯 标准化:统一的签名格式,钱包DApp 都能正确解析和展示

什么时候使用 EIP-712

  • ✅ 需要签名复杂的数据结构(订单、投票、许可等)
  • ✅ 需要让用户清楚地看到签名内容
  • ✅ 需要防止跨合约/跨链的签名重放

什么时候使用基础签名

  • 简单的字符串消息签名(如登录验证)
  • 不需要复杂数据结构的场景

通过 EIP-712,开发者可以实施更安全、更透明的签名方案,大幅提升用户体验和合约安全性。

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论