☰
Go后端生成EIP-712数据:钱包签名流程与验签实现
2026/10/5 3:23:43 网站建设 项目流程

项目标题说得很直白:Go 后端生成 EIP-712 数据,前端钱包签完名之后,再由后端把这笔签名验证掉。第一次接手这个需求时我差点把它拆成两段单独做——前端用 ethers.js 签,后端用 go-ethereum 恢复地址,两边各干各的。结果联调时前端怎么都能签,后端验证却隔三差五失败,最后才发现问题出在两边对 EIP-712 结构的理解不一致。

这事的核心其实不是“签名”本身,而是“同一份结构化数据,后端怎么生成、前端怎么还原、验签时怎么对齐”。我这次就用一个很常见的业务场景——钱包地址绑定内部账号——把 Go 后端生成、前端签名、后端验签的完整链路拆开讲一遍,包括 domain 参数怎么定、字段顺序为什么不能乱、v 值到底要不要减 27 这类细节。正在做钱包登录、绑卡、授权确认、盲签校验的同学,看完基本可以少踩一半的坑。

1. 为什么后端要“抢”这份签名原文

1.1 前端“自由拼装”带来的信任空洞

最早我给的方案很简单:后端只负责生成一个一次性 nonce,前端拿这个 nonce 拼一个 JSON 对象,让 MetaMask 签名,再把签名结果丢给后端。听起来没什么问题,前后端分离嘛,谁拼数据不都一样?但仔细想一下信任边界就发现了:后端真正想确认的是“某个钱包地址同意了一个确定的请求”,这个“确定的请求”必须由后端说了算。

如果让前端自由拼装,用户或者一个被篡改的前端页面完全可以改掉 message 里的字段。比如后端想让用户确认“账号 test@example.com 绑定钱包 0xabc”,前端拼出来的可能是“账号 attacker@example.com 绑定钱包 0xabc”,用户可能也没细看照常签名。后端验签时能恢复出钱包地址,却无法证明这个钱包认可的是不是你后端原本想让它认可的内容。

这个道理就像超市搞优惠活动,促销员拿着空白单据让顾客签字,顾客签完发现上面金额被填了十倍。要让签字有约束力,单据必须在柜台按标准模板打出来,顾客只负责看和签。后端生成 EIP-712 结构化数据,就是把这个“标准模板”固定住。

1.2 一条完整流程:先取参、再签名、后校验

我最终落实的流程是四步闭环:

  1. 前端先向后端发起“绑定/授权请求”,携带当前登录账号等上下文。
  2. 后端校验账号合法性后,生成一个一次性 nonce 并缓存,然后构造完整的 EIP-712 结构化数据(domain、types、message),通过接口返回给前端。
  3. 前端原样把这份数据结构喂给钱包,用户确认签名,前端把签名值回传后端。
  4. 后端拿到签名值后,用同样的结构化数据重新计算摘要,恢复签名地址,比对 message 里的 wallet 是否一致,同时校验 nonce 是否有效、是否过期。全部通过后才落库。

这四步里,前端不拼装业务核心数据,只做一个“展示和签名”的动作。后端持有最终的“事实版本”,前端即便被改造,也无法改变签名原文,因为只要改了任何字段,钱包里展示的内容会变,后端恢复出来的地址也会对不上。

1.3 后端只下发 nonce,还是下发完整 payload

有人会问:nonce 由后端给,account、wallet 这种常规字段让前端自己填不行吗?我的建议是不要。这里不仅仅是信任问题,还有联调成本问题。

如果前后端各维护一份拼接逻辑,两边对字段顺序、类型定义、大小写规则的理解很容易漂移。今天我后端改了字段名,明天前端忘了同步,线上就炸。干脆后端把完整的 typedData 结构一次性下发,前端照着渲染,连拼装代码都不需要写。后续增加字段,只改后端一个地方,前端只要支持signTypedData(domain, types, value)这个通用入口即可。

我在实际项目里就是这么做的。接口返回的数据基本是这个形式:

{ "nonce": "8f3c1a9e", "expireAt": 1735000000, "typedData": { "domain": { "name": "WalletBindApp", "version": "1", "chainId": 1, "verifyingContract": "0x..." }, "types": { "BindAccount": [ { "name": "wallet", "type": "address" }, ... ] }, "primaryType": "BindAccount", "message": { "wallet": "0x...", "account": "user@example.com", "nonce": "8f3c1a9e" } } }

前端只需要把typedData整个传进签名方法,别的什么都不用想。

2. EIP-712 的编码规则,理解不透后面全是坑

2.1 结构化数据签名与传统签名的差异

EIP-712 的全称是以太坊类型化数据签名,解决的是传统personal_sign那种“签一个不明不白的字符串”的问题。传统签名就是对一个十六进制字符串做 Keccak-256,钱包弹出来就是一串乱码,用户根本不知道自己签的是什么。EIP-712 则要求先定义一套类型描述,钱包会在弹窗里把字段名、字段值、域名信息全部展示出来,用户看得懂再签。

这个特性对后端来说既是好事也是约束。好事是用户能看清楚签名内容,沟通成本低;约束是后端必须严格按照规范编码,任何一个字节对不上,恢复出来的地址就不是用户地址。很多人觉得“EIP-712 就是让 MetaMask 弹个小框”,其实真正的难点在编码对齐。

2.2 Domain、Types、Message 三个关键盒子

一个完整的 EIP-712 签名原文由四部分构成:

  1. Types:类型定义,描述有哪些自定义结构体字段。比如BindAccount包含wallet(address)、account(string)、nonce(string)。
  2. Domain:域名信息,至少包含name、version、chainId、verifyingContract等字段。它相当于给这份数据绑定了一个“上下文环境”,防止 A 应用的数据跑到 B 应用里面被复用。
  3. Message:具体的数据内容,也就是 user 要确认的那份真实请求。
  4. PrimaryType:声明本次签名的主类型名。MetaMask 会根据这个类型名去types里找到对应的字段列表。

Domain 是我最容易忽略的部分。它存在的意义是防止重放攻击:同一个 message 如果拿到其他 DApp 或者其他链上去签名,由于 domain 不同,最终摘要也会不同,原有签名就不起作用。类比就是同一句话“我同意”,填在“租房合同”和“借款合同”上,法律含义完全不同,必须先确定是在哪份合同里才谈得上签字同意。

2.3 从 message 到最终摘要的三个哈希步骤

EIP-712 最后要签的摘要不是直接对 message JSON 做哈希,而是经过一套固定编码流程。拆开看是三步:

第一步,计算类型哈希。把主类型名称和字段类型拼成一个字符串:

BindAccount(address wallet,string account,string nonce)

对这个字符串取 Keccak-256,得到typeHash。

第二步,计算 domain separator。EIP712Domain本身也是一个类型,先对它的类型定义算typeHash,再和实际的 name、version、chainId、verifyingContract 一起做 ABI 编码后哈希。这个结果就是domainSeparator。

第三步,计算最终摘要。对 message 里的字段按照类型定义的顺序做编码,其中字符串和字节数组这类动态类型要先用 Keccak-256 求哈希再参与编码,最终得到structHash。然后拼接起来:

digest = keccak256("\x19\x01" ++ domainSeparator ++ structHash)

钱包对这个digest做 ECDSA 签名。私钥持有者是谁,验签后就能恢复出谁的钱包地址。

这里最容易错的两点:一是EIP712Domain字段在 domain 里有没有出现,如果 domain 里少传了某个字段,但 types 里还定义着,编码顺序就对不上;二是字符串字段在编码时不是直接拼原始字符串,而是先算keccak256(message[field]),参与哈希的是哈希值。有同学在这个地方手工写死字符串,导致越看越晕。

2.4 字段顺序为什么不能乱

EIP-712 编码时,字段顺序依赖types中定义时的顺序,不是依赖 field 名字排序。比如:

[ { "name": "wallet", "type": "address" }, { "name": "account", "type": "string" }, { "name": "nonce", "type": "string" } ]

那就必须按wallet、account、nonce的顺序编码。如果前端改成account、nonce、wallet,后端按照原顺序重建,两边编码后的字节完全不一样,签名恢复地址必然失败。

更隐蔽的是 JSON 对象在不同语言里的遍历顺序。Go 的map[string]interface{}遍历是无序的,所以在构造 TypedData 时,types不能用普通 map 随后再迭代生成编码,必须使用有序 slice 结构或直接按 go-ethereum 提供的[]apitypes.Type定义。前端 JavaScript 的普通对象键顺序大概率按插入顺序,只要和后端定义一致即可,但如果后端把字段类型定义放在 map 里再 for-range,一旦顺序错位就很难查。

3. Go 后端生成 EIP-712 数据并完成验签

3.1 工程依赖与包路径变化

我用的库是github.com/ethereum/go-ethereum,也就是 Geth 的官方 Go SDK。新版本里 EIP-712 相关的类型主要在signer/core/apitypes这个子包下;旧版本有些用signer/core,如果你手头代码是旧写法,记得留意包路径调整。

实际引入的核心依赖大概是这样:

import ( "math/big" "fmt" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/signer/core/apitypes" )

crypto包主要负责 Keccak-256 和 ECDSA 恢复,common包负责地址类型转换,apitypes包负责构建 TypedData。当然,如果你不想引入整棵 go-ethereum 依赖树,也可以只引入github.com/ethereum/go-ethereum/crypto这类子包,或者自己用golang.org/x/crypto/sha3实现。但从可维护性和规范一致性来看,直接用官方实现更稳。

3.2 组装 TypedData 的代码细节

以“钱包绑定账号”为例,构造 TypedData 的核心代码如下:

const verifyingContract = "0x5B38Da6a701c568545dCfcB03FcB875f56beddC4" type BindMessage struct { Wallet string `json:"wallet"` Account string `json:"account"` Nonce string `json:"nonce"` } func BuildBindTypedData(wallet common.Address, account, nonce string) (*apitypes.TypedData, error) { 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"}, }, "BindAccount": []apitypes.Type{ {Name: "wallet", Type: "address"}, {Name: "account", Type: "string"}, {Name: "nonce", Type: "string"}, }, }, PrimaryType: "BindAccount", Domain: apitypes.TypedDataDomain{ Name: "WalletBindApp", Version: "1", ChainId: big.NewInt(1), VerifyingContract: verifyingContract, // 注意必须是合法 0x 地址 }, Message: apitypes.TypedDataMessage{ "wallet": wallet.Hex(), // 统一转成小写 hex "account": account, "nonce": nonce, }, } return typedData, nil }

这里有三个必须强调的细节:

第一,PrimaryType的值必须等于Types里某个 key。不要写了一个PrimaryType: "Bind",却在 types 里定义的是BindAccount,MetaMask 会直接报找不到主类型。

第二,Domain.ChainId必须是*big.Int而不是 int 或 string。如果前端给的是string,两边编码时 chainId 的字节表示可能不同,最终摘要也不一致。

第三,verifyingContract必须是带0x前缀的以太坊地址。如果项目只做线下测试不想真有合约,可以写一个合法的空地址,但别随便写"0x123"这种长度不对的值,很多钱包会拒绝展示。

3.3 计算摘要、恢复签名地址

TypedData 构造好之后,我们要算最终摘要并恢复签名地址。go-ethereum 的apitypes.TypedData里带有Hash()方法,可以直接得到最终 digest。恢复签名的代码如下:

func RecoverBindSigner(td *apitypes.TypedData, signature []byte) (common.Address, error) { digest, err := td.Hash() if err != nil { return common.Address{}, fmt.Errorf("calculate eip712 hash: %w", err) } if len(signature) != 65 { return common.Address{}, fmt.Errorf("signature length must be 65 bytes, got %d", len(signature)) } // MetaMask/ethers 返回的 v 通常是 27/28,而 SigToPub 预期 v 是 0/1 if signature[64] >= 27 { signature[64] -= 27 } pubKey, err := crypto.SigToPub(digest.Bytes(), signature) if err != nil { return common.Address{}, fmt.Errorf("recover public key: %w", err) } return crypto.PubkeyToAddress(*pubKey), nil }

这里有个很典型的坑:前端 ethers 返回的签名是0x开头的 65 字节 hex,其中最后一个字节是 v。不同钱包可能返回v=27/28,也可能返回v=0/1。如果不统一做一次归约,直接丢给crypto.SigToPub,几乎所有签名都会恢复失败。

还有一个更隐蔽的问题:signature是从 HTTP 请求里解析出来的,可能在底层解析成[]byte时被复制或者截断。我建议在进入恢复流程前先做一次长度和格式校验,必要时用common.FromHex(signatureHex)显式转换,不要依赖前端“已经转好了”的假定。

3.4 业务校验:nonce 一次有效加过期窗口

签名恢复只是第一步,业务校验才是防止重放的关键。我后端定义了一个简单的 nonce 表,关键字段如下:

字段说明示例
nonce一次性随机串,使用后立即标记8f3c1a9e
account目标账号标识user@example.com
wallet期望绑定的钱包地址0xabc...
expire_at过期时间戳1700000000
status0未使用、1已使用、2已过期0

接收到签名后,我的校验顺序是:

  1. 根据请求里的nonce查库。
  2. 查不到直接拒绝;状态不是“未使用”直接拒绝;expire_at小于当前时间直接拒绝。
  3. 重建 TypedData,恢复签名地址。
  4. 比对恢复地址与请求里的wallet,以及库里存的wallet,三者必须一致。
  5. 全部通过后,把 nonce 状态更新为“已使用”,再写入业务绑定关系。

一次有效的 nonce 即使签名本身是合法的,也不能被反复使用。否则攻击者抓包拿到一次合法签名后,可以无限次重放,即使不改任何字段也能刷爆绑定记录。

4. 前端签名:照着后端模板走,别自己重新拼

4.1 前端拿到 payload 后怎么传给钱包

前端这层我的习惯是只做三件事:拉取模板、弹出签名、回传签名。以 Vue3 + ethers.js v5 为例,关键代码如下:

import { ethers } from 'ethers'; async function bindAccount(provider, account) { const resp = await fetch('/api/bind/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ account }) }); const payload = await resp.json(); const { domain, types, primaryType, message } = payload.typedData; const signer = provider.getSigner(); const signature = await signer.signTypedData(domain, types, message, { primaryType }); const verifyResp = await fetch('/api/bind/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ account, wallet: message.wallet, nonce: message.nonce, signature }) }); return await verifyResp.json(); }

重点在于types和message都来自后端接口,前端不要自己定义一份。这样前后端结构天然一致,不会出现“后端存的是account,前端定义的是address”这种低级错位。

MetaMask 在用户签名前会展示一张结构化数据面板,用户会看到“WalletBindApp 希望您签署以下数据”“wallet: 0x…”“account: user@example.com”“nonce: 8f3c1a9e”等字段。这个过程对用户是透明的,正好起到“看得见再签”的作用。

4.2 primaryType 要不要单独传

很多 ethers 的signTypedData方法签名是signTypedData(domain, types, value),它默认从 types 里找第一个非 EIP712Domain 的类型作为主类型。如果项目里只有一个自定义类型,比如BindAccount,不传 primaryType 也没问题。但如果 types 里同时定义了多个自定义类型,最好显式指定,避免钱包和库猜错。

我建议后端在下发 payload 时还是把primaryType字段一起返回,前端把它作为第四参数传给 ethers。ethers v5 的Signer.signTypedData(domain, types, value)其实没暴露 primaryType,但底层TypedDataEncoder.getPrimaryType(types)会自动取第一个非 domain 类型。因此类型定义的顺序也有讲究:自定义类型中,被PrimaryType指向的应该放在其他依赖类型之前。

这里不展开太多,但你在后端定义types时就要思考:谁是被签的主结构,谁是被主结构引用的子结构。主结构放在第一个,子结构放后面,MetaMask 渲染时才会展示主结构字段,而不是弹出一堆嵌套结构。

4.3 签名回传的格式与长度检查

前端回传的 signature 是0x开头的 hex 字符串。不算0x前缀应该是 130 个十六进制字符,对应 65 字节;算上前缀一共 132 个字符。后端做校验时直接先做长度检查,不要盲目尝试恢复。

我在后端写了一个独立的小函数:

func parseSignature(hexSignature string) ([]byte, error) { if len(hexSignature) != 132 { return nil, fmt.Errorf("signature hex length must be 132, got %d", len(hexSignature)) } sig := common.FromHex(hexSignature) if len(sig) != 65 { return nil, fmt.Errorf("signature bytes length must be 65, got %d", len(sig)) } return sig, nil }

这样能在验签前就把“长度不对”这类问题直接暴露出来。项目中经常有人把signature字符串顺手塞进 JSON 的 string 字段,但前面有空格或大小写不一致,common.FromHex解析出来长度不对,恢复自然失败。

4.4 后端跨域与 Vue3 访问接口的碎事

前后端分离开发时,Go 后端接口默认不支持跨域,前端在 localhost:5173 访问 localhost:8080 会被浏览器拦掉。我项目里用了一个轻量 CORS 中间件,允许指定来源并处理OPTIONS预检。

func corsMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "http://localhost:5173") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } next.ServeHTTP(w, r) }) }

生产环境下不要用通配符*,尤其是涉及签名和钱包授权的接口,CORS 策略要收紧到固定域名,否则第三方页面可以偷偷帮用户触发请求诱使签名。这是前端安全里很容易漏的一环。

5. 高频问题排查与避坑记录

5.1 前后端 hash 对不上怎么办

这是联调第一周出现频率最高的问题。排查方式不是靠肉眼看代码,而是把中间哈希值直接打出来对比。

后端调试时打印domainSeparator、structHash和digest:

digest, _ := td.Hash() fmt.Println("digest:", digest.Hex())

前端可以用 ethers 的TypedDataEncoder做同样计算:

const digest = ethers.utils.TypedDataEncoder.hash(domain, types, message); console.log('frontend digest:', digest);

两边 digest 一致,说明编码规则一致,签名恢复不会出问题。如果不一致,优先检查这几个维度:

检查项错误表现对策
primaryType 与 types key 不一致MetaMask 直接报错统一为相同字符串
字段顺序不同digest 不同以 types 的类型定义顺序为准
chainId 类型不同digest 不同后端用big.Int,前端用数字
string 字段被手动拼成字符串digest 不同不要手动编码,交给库
多传/少传 domain 字段MetaMask 弹框字段异常domain 和 EIP712Domain 定义保持一致

5.2 恢复地址不对的四个原因

明明前后端 digest 已经一致,但恢复出来的地址还是和钱包地址对不上,我遇到过的情况集中在四种:

第一,v 值未归一化。前端签名最后字节是 27/28,后端crypto.SigToPub要求 0/1,忘记减 27 会直接得到错误地址。

第二,签名解析时用了错误的编码。比如前端把 Buffer 转 base64 传回来,后端按 hex 解析,当然不对。统一走0xhex 字符串。

第三,message.wallet和实际签名地址不是同一个。用户 MetaMask 里可能切换了两个账户,前端用signer.getAddress()展示和签名,但 interface 返回的wallet是另一个地址。签名本身合法,但地址不匹配。这种情况要提示用户切换到同一账户再试。

第四,domain 里 verifyingContract 尾部的字节问题。EIP-712 对address类型有 padding 处理,但 go-ethereum 会处理,前端 ethers 也会处理,问题通常出在手工拼接时把地址字符串当成了string类型,而不是address类型。

5.3 MetaMask 弹框显示异常或直接拒绝

MetaMask 对 EIP-712 支持度高,但也不是什么类型都能展示。bytes32[]、address[]这类数组在某些版本里展示不友好,用户看到乱糟糟的结构可能直接拒绝。

我的经验是,在业务流程允许的情况下,尽量把 message 字段设计成扁平结构:用string、address、uint256、bytes32这些基础类型,避免嵌套结构体。嵌套结构体虽然 EIP-712 支持,但前端展示和后端编码都比较折腾,签名一旦出错排查成本大幅上升。

另外,如果 domain 里chainId是 1,而你实际连接的是测试网 11155111,MetaMask 有时候会提示“Domain chainId 不匹配当前网络”。这是故意的安全提示,后端生成 payload 时必须根据真实链 ID 去构造,不要写死一个主网 ID。

5.4 nonce 过期和重放攻击的实战处理

nonce 设计第一原则是不可预测。我见过用递增数字当 nonce 的项目,攻击者可以预测下一个 nonce,然后提前诱导用户签署未来请求。更稳妥的是随机生成 32 字节,转成 hex 字符串当作 nonce。

第二原则是一次性。签名验证成功后立刻更新状态,不能等到前端确认。第三原则是设置过期窗口。5 分钟比较合适,太长容易被复用,太短用户可能还没看清 MetaMask 弹窗就过期了。

如果业务要持久保存签名记录,建议把原始 signer 地址、digest、signature、nonce、expire_at 都存下来,方便后续审计。这也是跟“数字后端”那种大规模系统设计完全不同的风格,Web3 后端数据量不大但安全细节极多。

6. 一次完整的排错实录

这份记录是从我真实项目里摘出来的。某次灰度测试,测试同学反馈:MetaMask 签名成功后,后端一直报“签名验证失败:地址不匹配”。

第一轮排查,我先看后端日志。后端打印了 digest、恢复地址和message.wallet,发现恢复地址并非测试钱包地址,而是一个随机地址。这说明摘要或签名本身有问题。我把前端也打印了 digest,两边对比后惊讶地发现前端 digest 和后端 digest 是一致的,但恢复地址依然不对。

第二轮排查,我怀疑是 v 值问题,于是把 signature 最后一位打印出来,发现是 28,然后signature[64] -= 27后恢复地址正确。问题找到了:旧版本里我在恢复前只做了if v > 1的简单判断,但迭代过程中某次代码改动把这段归约逻辑挪到了签名长度校验之后,导致传入SigToPub时 v 仍然是 28。这个问题在本地测试中很少出现,因为有些前端库返回的 v 是 0/1,恰好测不出来。

修好之后,我又顺手加了测试用例,覆盖v=27、v=28、v=0、v=1四种情况。以后谁再动这段逻辑,单测直接挂。

还有一次是用户反馈“MetaMask 里看到的 nonce 是 123456,签名后却失败”,查了半天发现后端 nonce 列表里有两条记录,一个已过期一个未过期,接口返回的是过期记录,而用户签名时看到的 nonce 和后端验证用的 nonce 不一致。从那以后我在返回 payload 之前额外检查 expire,并且每次都重新生成 nonce,不在缓存里重复取。

7. 最后分享几个实操习惯

这个项目做完后,我形成了一套自己的固定习惯。首先是“所有中间值必须可打印”。后端生成 TypedData 后,先把 domainSeparator、typeHash、digest 一起打到日志里,联调时一句“我把 digest 发给你”就能省掉半小时排查。

其次是“前端永远不要拼 message”。哪怕只是一个字段,也从前端模板里带过去。前端脚本改起来太容易,一旦有多个入口同时拼 message,遗漏一个字段就是签名失败。

最后是“签名验签代码要写单元测试”。go-ethereum 提供了一套相对完整的底层能力,但你不测就不知道自己的归约逻辑对不对。至少要写四组用例:正确签名校验通过、篡改 message 后校验失败、v 值 27/28 都能恢复、nonce 用两次第二次被拒。

如果你也在做钱包绑定、授权确认、免密登录这类前后端分离项目,只需要把上面这套流程落到业务里,大部分 EIP-712 的坑基本都能提前躲开。

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

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

立即咨询