EIP-712 详解:用于真实以太坊应用的安全链下签名

本文介绍了 EIP-712 的原理、作用以及如何使用 EIP-712 实现安全的链下签名,使得钱包能够显示可读的信息,合约可以在链上验证签名。同时,通过一个 Go 语言和 Solidity 语言的例子,展示了如何在 Polygon Amoy 测试网上验证 EIP-712 签名,并介绍了基于 EIP-712 构建的 EIP-2612 Permit 签名流程。

EIP-712 详解:为真实以太坊应用提供安全的链下签名

签名原始字节 blobs 可能适用于测试,但实际应用程序需要的不仅仅是这些。用户应该知道他们批准的内容,而不仅仅是信任一个随机的十六进制字符串。

EIP-712 为以太坊签名带来了结构化,让钱包可以显示可读的字段,如 ownerspenderamount,同时生成可验证的链上哈希。在这篇文章中,我们将分解它的工作原理,它是如何支持 EIP-2612 "permit" 的,并演练一个在 Polygon Amoy 测试网上完整的 Go + Solidity 验证示例。

EIP-712 类型化结构化数据签名

签名一个不透明的字节 blob 很容易,但在实际应用中,我们需要签名具有丰富结构的 message,其中包含诸如 "amount"、"recipient" 或 "order ID" 等字段,而不仅仅是随机的十六进制。如果你自己编写哈希算法,很容易犯错,从而破坏安全性。

EIP‑712 通过定义一种清晰、经过同行评审的方式来解决这个问题,即将你的数据结构转换为钱包可以以人类可读形式显示的摘要。这意味着用户可以准确地看到他们正在签名的内容,合约可以在链上验证它,并且你可以通过在链下进行授权而不是在链上进行授权来节省 gas。

工作原理:

扩展的签名范围

  • 除了交易和原始字节字符串之外,EIP‑712 还添加了 结构化数据,因此钱包可以呈现人类可读的字段,而不是不透明的十六进制。

编码模式

  • 类型化数据"\x19\x01" ‖ domainSeparator ‖ hashStruct(message)

前导字节始终不同,因此每种情况都是明确的。

类型化数据

  • Solidity 风格的结构体 定义,例如
struct Mail { address from; address to; string contents; }
  • 支持的类型
  • 原子类型uint8–256int8–256bytes1–32booladdress
  • 动态类型bytesstring
  • 引用类型:固定/动态数组 (Type[n] / Type[]),嵌套结构体

hashStruct

  • typeHash = keccak256(encodeType("Mail(address from,address to,string contents)"))
  • encodeData = 将每个字段打包到 32 字节的槽中(动态类型的先进行哈希)
  • hashStruct(msg) = keccak256(typeHash ‖ encodeData(msg))

域名分隔符(Domain Separator)

  • 一个特殊的 EIP712Domain 结构体(例如 { name, version, chainId, verifyingContract }),作为整体进行一次哈希
domainSeparator = hashStruct(eip712Domain);

签名和验证

  • 客户端 调用 eth_signTypedData_v4(name, types, domain, message) → 钱包显示清晰的字段并返回签名
  • 合约 重新计算 digest = keccak256("\x19\x01"‖domainSeparator‖hashStruct(message)) 并使用 ecrecover 来验证签名者。

注意:有关更详细的规范,你可以在这里阅读

Permit

EIP‑2612 “permit” 构建在 EIP‑712 的 类型化结构化数据签名之上。它定义了一个 Permit(owner, spender, value, nonce, deadline) 结构体,钱包使用 EIP‑712 域名分隔符对其进行哈希,呈现给用户以供批准,并在链下签名。token 的 permit(owner, spender, value, deadline, v, r, s) 函数然后通过 EIP‑712 重新计算相同的摘要,使用 ecrecover 来验证持有者的签名和 nonce,并原子地设置 allowance[owner][spender] = value。通过在底层利用 EIP‑712,permit 实现了一种清晰、安全、gas 高效的 UX,而无需单独的链上 approve(...) 调用。

注意: 想要支持这种授权方式的 Token 必须实现 permit 方法。

例子:

在这个例子中,我们将编写一个智能合约,根据前面解释的规范来验证 EIP-712

智能合约视角:

pragma solidity ^0.8.0;

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

contract PermitVerifier is EIP712 {
    // keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)")
    bytes32 public constant PERMIT_TYPEHASH =
        0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9;
    constructor() EIP712("MyDApp", "1") {}

    function verifyPermit(
        address owner,
        address spender,
        uint256 value,
        uint256 nonce,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external view returns (bool) {
        bytes32 structHash = keccak256(
            abi.encode(
                PERMIT_TYPEHASH,
                owner,
                spender,
                value,
                nonce,
                deadline
            )
        );
        bytes32 digest = _hashTypedDataV4(structHash);
        return ECDSA.recover(digest, v, r, s) == owner;
    }
}

钱包视角:

package main

const (
   // Polygon Amoy 测试网的公共 RPC URL
   NodeRPCURL  = "https://polygon-amoy.drpc.org"
   AmoyChainID = 80002 // Polygon Amoy 测试网 Chain ID
  )

func main() {
   // 1) 连接到 Amoy
   client, err := ethclient.Dial("https://polygon-amoy.drpc.org")
   if err != nil {
      log.Fatal(err)
   }

   ctx := context.Background()
   // 2) 准备用户签名的相同的 EIP‑712 TypedData
   acc2Addr, acc2Priv := account.GetAccount(2)
   verifierAddr := common.HexToAddress("0xf80bb731f8ba49624dce8edb1a8188782287ff1e")
   domain := apitypes.TypedDataDomain{
      Name:              "MyDApp",
      Version:           "1",
      ChainId:           math.NewHexOrDecimal256(AmoyChainID),
      VerifyingContract: verifierAddr.Hex(),
   }

   types := apitypes.Types{
      "EIP712Domain": {
       {Name: "name", Type: "string"},
       {Name: "version", Type: "string"},
       {Name: "chainId", Type: "uint256"},
       {Name: "verifyingContract", Type: "address"},
      },
      "Permit": {
       {Name: "owner", Type: "address"},
       {Name: "spender", Type: "address"},
       {Name: "value", Type: "uint256"},
       {Name: "nonce", Type: "uint256"},
       {Name: "deadline", Type: "uint256"},
      },
   }

   deadline := big.NewInt(time.Now().Add(time.Hour).Unix())
   nonce := big.NewInt(0)
   message := apitypes.TypedDataMessage{
      "owner":    acc2Addr.Hex(),
      "spender":  acc2Addr.Hex(),
      "value":    "1000000000000000000",
      "nonce":    nonce.String(),
      "deadline": deadline.String(),
   }

   typedData := apitypes.TypedData{
      Types:       types,
      PrimaryType: "Permit",
      Domain:      domain,
      Message:     message,
   }

   // 3) 签名或提供你现有的 (v,r,s)
   domainSep, _ := typedData.HashStruct("EIP712Domain", typedData.Domain.Map())
   msgHash, _ := typedData.HashStruct("Permit", typedData.Message)
   digest := crypto.Keccak256(
      []byte("\x19\x01"),
      domainSep,
      msgHash,
   )

   sig, _ := crypto.Sign(digest, acc2Priv)
   r := common.BytesToHash(sig[:32])
   s := common.BytesToHash(sig[32:64])
   v := uint8(sig[64]) + 27

   // 4) ABI‑encode verifyPermit(owner,spender,value,nonce,deadline,v,r,s)
   verifierABI := `[{"inputs":[{"internalType":"address","name":"owner","type":"address"},{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"value","type":"uint256"},{"internalType":"uint256","name":"nonce","type":"uint256"},{"internalType":"uint256","name":"deadline","type":"uint256"},{"internalType":"uint8","name":"v","type":"uint8"},{"internalType":"bytes32","name":"r","type":"bytes32"},{"internalType":"bytes32","name":"s","type":"bytes32"}],"name":"verifyPermit","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"}]`
   parsed, _ := abi.JSON(strings.NewReader(verifierABI))

   calldata, _ := parsed.Pack(
      "verifyPermit",
      acc2Addr,
      acc2Addr, // spender (必须与签名内容匹配)
      big.NewInt(1e18),
      nonce,
      deadline,
      v, r, s,
     )

   // 5) 进行 eth_call
   msg := ethereum.CallMsg{
      To:   &verifierAddr,
      Data: calldata,
   }

   res, err := client.CallContract(ctx, msg, nil)
   if err != nil {
      log.Fatal(err)
   }

   // 6) 解码 bool 结果
   out, err := parsed.Unpack("verifyPermit", res)
   if err != nil {
      log.Fatal(err)
   }

   fmt.Println("Signature valid?", out[0].(bool))
   if !out[0].(bool) {
      log.Fatal("Signature verification failed")
   }

   fmt.Printf("Signature verified successfully for owner: %s\n", acc2Addr.Hex())
}

你应该看到的输出:

Signature verified successfully for owner: <你的地址>

在这个例子中,我们演练了一个纯粹的 只读 演示,以及如何链下检查 EIP‑712 签名的直觉。在真正的 permit 流程中,你不会止步于 verifyPermit,你实际上必须在链上调用 token 的 permit(...) 方法来:

  • 重新计算并验证 EIP‑712 摘要
  • 检查并更新所有者的 nonce 和 deadline
  • 设置 allowance(owner, spender)

只有在 permit(...) 调用之后,后续的 transferFrom(...) 才会成功。我们的示例展示了核心的签名和恢复逻辑,但生产环境的集成必须调用 tokenPermit.permit(...)

总结

EIP-712 为以太坊开发者提供了一种可靠的方式,可以从盲签名过渡到结构化、可验证的消息。它定义了如何哈希和签名类型化数据,以便钱包可以显示可读信息,合约可以信任链下授权,用户可以免受误导性提示的侵害。

通过将 EIP-712 与 EIP-2612 的 permit 流程 相结合,应用程序可以让用户在链下批准 token 授权,并在一次调用中在链上确认它们,从而降低 gas 成本并改善 UX。

该标准现在支撑着大多数现代签名模式,从 DEX 订单到 meta-transactions 和后端验证的 swaps。理解如何构建和验证这些摘要不仅有用,而且对于任何构建安全、用户友好的以太坊集成的开发者来说都是必不可少的。

资源

  • 交易类型的代码可以在 这里 找到
  • 原文链接: medium.com/@andrey_obruc...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Andrey Obruchkov
Andrey Obruchkov
江湖只有他的大名,没有他的介绍。