☰
EIP-712 签名实战:Go 后端与前端联调避坑指南
2026/10/11 19:17:45 网站建设 项目流程

1. 说清楚一个事:为什么用户签名非要 EIP-712,而不是直接签字符串

做过 Web3 项目的朋友应该都有印象,早期很多 Demo 让用户在钱包里签一段随机字符串来证明自己拥有某个地址的私钥。当时觉得挺方便,直到我在某个模拟项目里被一位合作方的安全顾问问了一句:"你自己看清楚那段消息了吗?"我才意识到问题有多严重。

用户签名时,钱包弹窗里显示的是一段十六进制乱码,或者一长串没有语义的文本。用户根本不知道自己签的是什么,这种签名如果被钓鱼站点拿到,完全可以在别的场景里被恶意使用。比如你在一家 DApp 里签名了 "login",这段数据被别人拿去另一个合约里当授权用,用户毫无感知。这就不只是体验问题了,是实实在在的安全漏洞。

EIP-712 这个规范解决的就是这件事:它定义了一种结构化、可读、可验证的签名数据格式。签名的内容不再是乱码,而是一份带有明确字段名的 JSON 结构,钱包弹窗会直接把字段语义展示给用户看。用户在 MetaMask 里签名时会看到"你正在签署以下内容,包含这些字段",这就把签名的知情权还给用户了。

拿我最近在做的某个 Web3 登录模块举例,后端用 Go 生成 EIP-712 数据,前端拿到后用 ethers.js 唤起钱包签名,再把签名结果回传后端验证。整套链路里最核心的技术点就是:保证 Go 和前端对同一份数据计算出的哈希完全一致。只要有一处字节对不上,签名就会验证失败。

所以这篇文章我围绕这条链路拆开来讲:后端怎么生成 EIP-712 数据、前端怎么正确签名、后端回来怎么验,以及在真实项目里特别容易踩的坑。适合正在做 Web3 后端、或者准备给自己的 DApp 加上安全登录功能的开发者阅读。

2. Go 后端生成 EIP-712 数据:先搞清楚 Type、Domain 和 Message 三个部分的关系

EIP-712 的声明结构由三块组成:types(类型定义)、domain(域分隔符)、message(实际数据)。这三者之间的关系,我习惯用寄快递来类比。

  • types相当于快递单的模板,规定了包裹上要填几个字段,每个字段叫什么名字。
  • domain相当于发件人地址,它隔离了不同 DApp 之间的签名,防止 A 应用签的数据被 B 应用拿去用。
  • message就是实际填写的内容,也就是用户真正要确认的数据。

2.1 类型定义:Go 结构体怎么映射到 Solidity 类型

后端首先要把业务数据定义成 Go 结构体。比如我要做一个"用户邮箱绑定授权"的功能,让用户签名确认"把邮箱user@example.com绑定到地址0xabc...",对应的 Go 结构体大概是这样的:

type BindEmailMessage struct { WalletAddress string `json:"walletAddress"` Email string `json:"email"` Timestamp uint64 `json:"timestamp"` }

这里有个特别容易错的地方:EIP-712 的字段名是严格区分大小写的。Go 结构体里的 JSON tag 如果写成walletaddress,前端就会拿到一个和类型定义不符的字段名,最后的哈希对不上。

还有一个关键点是类型映射:钱包地址在 EIP-712 里对应address类型,在 Go 侧我们通常用字符串存,但在哈希时需要处理成 20 字节的地址格式;timestamp对应uint256,Go 侧的uint64会被解码成等值的大整数参与哈希。这些映射关系我在后面的哈希章节详细展开。

2.2 Domain 域分隔:为什么 chainId 写错一个字,签名就永远验不过

domain字段是整个 EIP-712 里最容易被忽略但一旦出错最致命的部分。它的结构如下:

type TypedDataDomain struct { Name string `json:"name"` Version string `json:"version"` ChainId *big.Int `json:"chainId"` VerifyingContract string `json:"verifyingContract,omitempty"` }

以我们实际项目为例,domain 是这样定义的:

domain := TypedDataDomain{ Name: "DappName", Version: "1", ChainId: big.NewInt(11155111), // Sepolia 测试网 VerifyingContract: contractAddress, }

这里的Name必须和前端联调时约定的完全一致,一个字符都不能差。Version一般从 "1" 开始,每次业务规则有重大升级时递增,避免旧签名在新规则下依然有效。

ChainId的作用是防止跨链重放。假如用户在主网签了一个授权,攻击者把同样的签名拿到测试网上重放,因为 chainId 不同,哈希结果也完全不同,这条签名就失效了。所以后端生成时,一定不要硬编码 chainId,而是从 RPC 节点实时获取,避免部署环境切换时忘了改。

2.3 Message 组装:业务数据与钱包展示的关系

Message 部分就是把用户要确认的字段组装成可读的 JSON。这里有个设计原则我想提醒一下:凡是用户应该知道的数据,全部放进 message。因为钱包弹窗展示的就是 message 里的字段。

我见过有团队把nonce、expiredAt这类反重放参数也放进 message。我当时觉得没必要,后来实际测试时想明白了:如果不放,用户就无法确认这个签名是不是会被无限次重放。反过来,把这些字段放进去,用户直接能看到"这个签名三分钟后过期",对安全性的感知会强很多。

在我们项目里,message 长这样:

message := map[string]interface{}{ "walletAddress": userAddr.String(), "email": "user@example.com", "timestamp": time.Now().Unix(), "nonce": randomNonce, }

3. EIP-712 哈希计算的数学细节:从 TypeHash 到最终签名哈希,一步步来

很多教程直接用库生成了事,但我强烈建议至少完整走一遍哈希流程。因为在联调时你一定会遇到"前端签出来东西回传上来,Go 验不过"的情况。如果你不懂哈希过程,就只能瞎试;懂了你就能定位出到底是哪一层字节对不上。

3.1 TypeHash 的构造规则

EIP-712 的哈希分四步。第一步是计算TypeHash:把类型定义按字段顺序拼成一个字符串,然后求 Keccak256。

以我们的BindEmailMessage为例,类型字符串是:

BindEmailMessage(string walletAddress,string email,uint64 timestamp)

注意:字段定义必须按声明顺序排列,字段之间用逗号分隔,括号里是"类型名 字段名"的配对。把这串字符做keccak256,就得到 TypeHash。

Go 侧如果用手写的方式算,大概是这样:

func encodeType(primaryType string, types map[string][]Type) string { var buf bytes.Buffer buf.WriteString(primaryType) buf.WriteString("(") for i, field := range types[primaryType] { if i > 0 { buf.WriteString(",") } buf.WriteString(field.Type) buf.WriteString(" ") buf.WriteString(field.Name) } buf.WriteString(")") return buf.String() }

还有一个更隐蔽的细节:如果 type 里嵌套了自定义结构体,那么需要递归编码所有依赖的类型,按字母序拼接成一个完整的 type 集合。初次接触的人很容易在这里漏掉子类型的定义,导致哈希结果不一致。

3.2 DomainSeparator 的构造与用途

第二步是计算DomainSeparator,公式是:

keccak256( keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)") + keccak256("DappName") + keccak256("1") + uint256(chainId) + address(verifyingContract) )

这段计算如果你手写,最大的坑在于:chainId 是 uint256,不是字符串拼接。很多人在拼字节时直接写了字符串 "11155111",结果字节完全不对。正确做法是先对 11155111 做 32 字节的大端序填充,再拼接到末尾。

我用 Go 的go-ethereum库时,TypedDataDomain的ChainId定义为*big.Int,库内部会自动处理填充,不用手工干预。但如果你自己在别的环境里实现,这块一定要小心。

3.3 完整哈希过程的代码实现

最稳妥的做法是用go-ethereum官方提供的签名工具包。我从项目早期就开始用它,一直到现在,稳定性没有问题。核心代码是这样:

import ( "github.com/ethereum/go-ethereum/signer/core/apitypes" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/crypto" ) typedData := apitypes.TypedData{ Types: apitypes.Types{ "EIP712Domain": []apitypes.Type{ {Name: "name", Type: "string"}, {Name: "version", Type: "string"}, {Name: "chainId", Type: "uint256"}, {Name: "verifyingContract", Type: "address"}, }, "BindEmailMessage": []apitypes.Type{ {Name: "walletAddress", Type: "address"}, {Name: "email", Type: "string"}, {Name: "timestamp", Type: "uint64"}, {Name: "nonce", Type: "uint256"}, }, }, PrimaryType: "BindEmailMessage", Domain: apitypes.TypedDataDomain{ Name: "DappName", Version: "1", ChainId: big.NewInt(11155111), VerifyingContract: contractAddress, }, Message: apitypes.TypedDataMessage{ "walletAddress": userAddr, "email": "user@example.com", "timestamp": fmt.Sprintf("%d", time.Now().Unix()), "nonce": nonce.String(), }, } hash, _ := typedData.HashStruct("BindEmailMessage", typedData.Message) fullHash := typedData.Hash()

这里我把email定义为字符串类型,但在Message赋值时要注意:uint64、address 这类非字符串字段需要转成合适的格式。比如address在 Go 侧直接赋common.Address即可,但uint256要赋字符串形式(如"123456"),否则库内部会解析失败。

我建议你在本地写一个自测函数,把HashStruct的结果打印出来,和一个已经在链上验证过的在线工具对比一下,确保哈希一致后再继续下一步。

4. 前后端数据传递:接口返回 TypedData JSON 而不是只返回 Hash

很多人在这一步犯了个认知错误:以为后端只需要把hash算出来传给前端,让前端对 hash 签名就行了。这样签名确实能成功,但完全失去了 EIP-712 的意义,因为用户看到的就是一串哈希,而不是可读的结构化数据。

正确做法是:后端把完整的 TypedData JSON 结构传给前端,由前端 JavaScript 库计算哈希并在钱包中展示可读内容。用户签名时钱包弹出的内容,是前端根据 TypedData 渲染出来的。

4.1 后端接口设计:一次请求拿到全部签名要素

我们项目里的接口设计大致是这样:前端调用/api/v1/auth/nonce,后端返回一个数据结构,其中包含typedData和nonce两个字段。nonce同时也放在 TypedData 的 message 里。

接口响应体大概是:

{ "code": 0, "data": { "nonce": "9f86d081884c7d65", "expiredAt": 1735689600, "typedData": { "types": { "EIP712Domain": [ { "name": "name", "type": "string" }, { "name": "version", "type": "string" }, { "name": "chainId", "type": "uint256" }, { "name": "verifyingContract", "type": "address" } ], "BindEmailMessage": [ { "name": "walletAddress", "type": "address" }, { "name": "email", "type": "string" }, { "name": "timestamp", "type": "uint64" }, { "name": "nonce", "type": "uint256" } ] }, "primaryType": "BindEmailMessage", "domain": { "name": "DappName", "version": "1", "chainId": 11155111, "verifyingContract": "0x5FbDB2315678afecb367f032d93F642f64180aa3" }, "message": { "walletAddress": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", "email": "user@example.com", "timestamp": 1735689600, "nonce": "9f86d081884c7d65" } } } }

这里有一个微妙的问题:为什么nonce在 response 的最外层出现一次,在 message 里又出现一次?因为最外层的nonce是给前端业务逻辑用的,用来标识"这一次签名请求";message 里的nonce是为了让用户看到并确认,同时把它作为签名的一部分以便后端验证时防重放。两者值相同,只是用途不同。

4.2 前端如何调用钱包完成签名

前端用 ethers.js v6 实现签名非常直接。下面是核心代码:

import { ethers } from "ethers"; async function signLoginData(typedData: any) { const provider = new ethers.BrowserProvider(window.ethereum); const signer = await provider.getSigner(); const signature = await signer.signTypedData( typedData.domain, typedData.types, typedData.message ); return signature; }

注意signTypedData的第三个参数是message,不是整个typedData对象。很多新手直接传整个对象,结果钱包弹窗里看到的字段完全不对。

MetaMask 实际调用的 JSON-RPC 方法名为eth_signTypedData_v4,ethers.js v6 默认使用 v4 标准。这里不需要手动指定版本,但如果你的项目用了 viem 库,signTypedData的参数结构略有不同,联调时稍微留意一下。

4.3 我踩过的一个坑:chainId 必须是 number 类型

有一次联调时,后端返回的 JSON 里domain.chainId是字符串"11155111"。前端 ethers.js 收到后直接报错invalid chainId。

原因是 ethers.js v6 对domain.chainId的类型要求很严格,必须是number或bigint,不能是字符串。而后端 Go 的json.Marshal默认把big.Int序列化成 JSON 数字可能溢出,所以很多人都习惯存字符串,但到了前端就炸了。

解决方案是:后端在返回这一字段前,做一个类型转换,确保它是 JSON 数字。

type DomainResponse struct { ChainId json.Number `json:"chainId"` }

或者更直接一点,在序列化 TypedData 时自定义MarshalJSON,把chainId强制输出为数字。前端拿到手就正常了。

5. Go 后端验证签名:从签名恢复地址到防重放检查,一套闭环

签名传回后端后,验证过程按先后顺序可以拆成四步:解码签名、恢复地址、比对地址、防重放检查。每一步都有细节。

5.1 签名解码与地址恢复

前端返回的签名是 65 字节的十六进制字符串,由r (32字节) + s (32字节) + v (1字节)组成。常见的签名格式还带0x前缀。Go 侧用crypto.SigToPub可以直接恢复公钥,再换算成地址。

先来看代码:

func verifySignature(typedData *apitypes.TypedData, signatureHex string, expectedAddress string) (bool, error) { signature := hexutil.MustDecode(signatureHex) if len(signature) != 65 { return false, fmt.Errorf("invalid signature length: %d", len(signature)) } // 兼容部分钱包返回的 v 值范围问题(0/1 与 27/28) if signature[64] == 0 || signature[64] == 1 { signature[64] += 27 } // 计算完整哈希 digest := typedData.Hash() pubKey, err := crypto.SigToPub(digest, signature) if err != nil { return false, err } recoveredAddr := crypto.PubkeyToAddress(*pubKey).Hex() return strings.EqualFold(recoveredAddr, expectedAddress), nil }

有个经常遇到的问题:某些钱包返回的签名中v是0x00或0x01(旧版本钱包),而 ECDSA 验证要求v为 27 或 28。在调用SigToPub之前需要做一次归一化。上边的代码里已经做了这步处理。如果你不做,SigToPub会直接报错invalid signature recovery id。

5.2 防重放检查:nonce、expiredAt 双保险

签名验证通过不代表业务安全。攻击者可以截获一次合法签名,然后反复提交。所以后端还应做两道防护:

  • 签名过期检查:message 里的timestamp字段和当前时间对比,超过 5 分钟就拒掉。
  • nonce 一次性检查:签名被验证后,该 nonce 立即失效,存入 Redis 或数据库记录,同一个 nonce 第二次提交直接拒绝。

这个流程的顺序也很重要,先验签名本身,再检查 nonce,最后检查过期时间。我见过有团队把 nonce 检查放在最前面,结果攻击者随便用一个随机 nonce 就能让验证流程提前退出,虽然不会造成安全问题,但会干扰日志排查。

5.3 完整验证流程设计

用伪代码描述整个流程:

1. 前端 POST /api/v1/auth/verify body: { signature: "0x...", nonce: "9f86..." } 2. 后端根据 nonce 从缓存中取出对应的 typedData 和 用户声明地址 3. 检查 nonce 是否存在: - 不存在 -> 拒绝 - 存在但已标记为已使用 -> 拒绝 4. 检查签名对应地址是否与用户声明地址一致 - 不一致 -> 拒绝 5. 检查 timestamp 是否在有效期内(如 5 分钟) - 已过期 -> 拒绝并删除 nonce 6. 全部通过 -> 标记 nonce 为已使用,下发登录凭证

这里有一个容易忽略的点:redis 缓存中存的是nonce -> typedData 响应的映射。但 typedData 里已经包含了 message 数据,按理说后端可以完全不用再查缓存,直接根据请求里的 walletAddr 重新构造一份同样结构的 typedData 来验证。

但为什么还要缓存呢?因为如果攻击者篡改了email字段(把它改成自己的邮箱),后端重新构出来的 typedData 会和前端签名的内容不一致,签名验不过。用缓存的原始 typedData 来验,就能保证签名内容没有被篡改。这一点是我在项目中反复权衡后决定保留的。

6. 联调避坑清单:Go 和前端两边哈希对不上的真实原因排行

一路写下来,我发现哈希不一致的问题其实高度集中在几个固定原因。整理成一份清单,联调时按这个顺序排查,能省非常多的时间。

6.1 类型名称不一致

常见错误是后端类型定义里写的是BindEmailMessage,但前端在解析时由于接口文档的错误,把primaryType定义成了bind_email_message或者BindEmail。一旦primaryType变了,TypeHash就完全变了,最终签名的哈希也是错的。

联调时有一个小技巧:让后端打印出TypeHash的十六进制值,前端在浏览器控制台也用相同逻辑打印一次,两边对比。如果 TypeHash 不一致,问题一定出在类型定义字符串上。

6.2 结构体字段顺序

EIP-712 要求类型字符串中字段按声明顺序排列。go-ethereum库中Types是 map 结构,如果你给Types赋值的顺序和前端定义的不一致,库内部会重新排序吗?

答案是不会完全重新排序。go-ethereum遵循规范中提到的排序规则:对于自定义类型,按字母序排列;但对于主类型内部的字段,必须按你传入的切片顺序排列。所以分两步:

  • 主类型字段顺序:由你定义[]Type的顺序决定,必须和前端一致。
  • 依赖子类型排序:库会自动按字母序处理,不需要担心。

但这里有个非常隐蔽的问题:有些第三方库会强制把主类型字段也按字母序排序。如果你前端用的是某类库,而后端用 go-ethereum,两边可能对同一份 typedData 产生不同的 TypeHash。实测下来,尽量保证前后端用同一套"类型字段顺序"是最稳妥的。

6.3 chainId 的类型与格式

我们前面已提到 chainId 必须是 number 类型。这里补充一个更深层的坑:如果后端配置在测试网,而前端用户钱包连接的是主网,那么钱包签名时会警告 "Domain does not match the current chain"。

这是钱包的自我保护机制。如果用户在确认弹窗里硬着头皮签了,得到的签名在后端验证时依然能通过(因为签名本身不依赖钱包当前网络),但它对应的 domain 是测试网的。这种情况下,如果你后端没有显式校验钱包当前网络和 domain 是否一致,就可能发生"测试网签名在主网业务里被接受"的逻辑漏洞。

解决方案:前端在调用signTypedData之前显式检查provider.getNetwork()的结果是否等于typedData.domain.chainId,不一致就直接拦截并提示用户切换网络。

6.4 空字节与枯字节编码细节

EIP-712 规范对编码有个特别要求:bytes类型需要按字节严格处理,但string类型则需要先 UTF-8 编码再哈希。Go 侧的string默认是 UTF-8 编码的,一般不会出问题。但如果你处理的是包含中文或其他 Unicode 字符的string字段,记得前端和后端都必须按 UTF-8 编码,不要随意做其他编码转换。

我在一次联调中还遇到过这样一个情况:某个用户邮箱尾部带了一个不可见字符(U+200B 零宽空格),前端打开钱包后这个字符完全看不到,但后端接收到的字符串长度比预期多 3 个字节,哈希随之变化。联调时完全找不出问题,最后用字节对比工具才发现。

6.5 structure 中 address 字段是否做了大小写校验

EIP-55 要求地址在展示时做大小写校验(checksum),但在 EIP-712 哈希时,address类型会用 20 字节原始二进制地址参与编码,和展示形式无关。

Go 里如果你把common.Address直接赋值,没问题;但如果你把地址以字符串形式存入 Message,然后库内部再解析,就必须确保字符串是合法的 EIP-55 地址格式,否则解析会报错。调试时可以先用common.IsHexAddress校验一遍。

7. 实战细节补充:如何用本地自测避免反复联调

最后分享一个我常用的自测方式。在前后端还没有真正联调时,我通常用两段独立的本地脚本来验证哈希一致性。后端直接用 Go 测试代码打印typedData.Hash()和typedData.HashStruct()的值;前端用 Node.js 跑一段脚本,解析同一份 TypedData JSON 后调用ethers.TypedDataEncoder.hash(),对比两者结果。

这个自测办法能过滤掉大约八成典型的联调错误。等两边哈希完全一致后,再进真实浏览器环境,剩下的问题就只集中在钱包本身的行为上(比如网络切换、权限拒绝等),排查范围就小很多。

这里是前端 Node.js 自测脚本的核心部分:

import { TypedDataEncoder } from "ethers"; const typedData = { ... }; // 和后端返回的一致 const hash = TypedDataEncoder.hash( typedData.domain, typedData.types, typedData.message ); console.log(hash);

对比完成后再进入真实联调。真实环境里遇到签名验不过时,先看三件事:拿签名去当前链上自己恢复一遍地址;对比domainSeparator是否一致;检查 memory 中的 byte 长度是不是 65。按这条路走下来,几十分钟内基本都能定位出问题。

我在项目里实测下来,这套方案的稳定性非常高。从上线到现在,没有出现过一次因为签名哈希不一致导致用户无法登录的情况。唯一一次故障是 Redis 意外的 key 冲突导致了 nonce 误判,和签名流程本身无关。

如果你也在做 EIP-712 相关的 Go 后端服务,建议先花半小时把哈希过程的每一步在本地跑通,再开始写业务代码。这个过程对你的联调速度帮助非常大,远大于直接调库带来的即时满足感。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询