【Web3后端 | DApp开发】如何验证用户的链下签名(如用 EIP-712 签名的交易)?
本文详细介绍了如何验证链下EIP-712签名,确保DApp用户身份和交易合法性。核心流程包括前端构造EIP-712结构化数据并获取用户签名,后端通过Golang解析签名、生成数据哈希并验证签名者地址。关键点涉及防重放攻击(chainId和nonce机制)、数据完整性校验及安全注意事项,提供了完整的JavaScript和Golang代码示例。验证过程需严格匹配签名者地址、检查链ID,并防范签名篡改,
·
验证用户的链下签名(尤其是遵循EIP-712标准的签名)是DApp后端确保用户身份、确保交易合法性的核心环节。以下是具体实现流程、原理和Golang代码示例:
一、EIP-712签名验证的核心原理
EIP-712定义了结构化数据的签名规范,解决了传统eth_sign签名中用户无法直观识别签名内容的问题。验证流程的核心是:
- 还原签名者地址:通过签名和原始数据,反向计算出签名者的公钥和地址。
- 校验签名有效性:确保签名对应的原始数据未被篡改,且签名者是预期用户。
- 防重放攻击:通过签名中包含的
chainId,限制签名仅在指定链上有效(遵循EIP-155)。
二、验证步骤(分前端和后端)
1. 前端准备(用户签名)
用户通过钱包(如MetaMask)对EIP-712结构化数据签名,前端需将以下信息传递给后端:
- 签名者地址(
signerAddress):用户钱包地址(预期的签名者)。 - 原始结构化数据(
typedData):符合EIP-712格式的数据(包含domain、types、message等)。 - 签名结果(
signature):钱包返回的65字节签名(r + s + v)。
前端示例(JavaScript):
// 1. 构造EIP-712结构化数据
const typedData = {
domain: {
name: "MyDApp", // DApp名称
version: "1.0", // 版本
chainId: 11155111, // Sepolia测试网链ID
verifyingContract: "0x123...", // 可选:关联的合约地址
},
types: {
Transaction: [
{ name: "to", type: "address" },
{ name: "amount", type: "uint256" },
{ name: "nonce", type: "uint256" }, // 防重放的随机数
],
},
primaryType: "Transaction",
message: {
to: "0x456...",
amount: "1000000000000000000", // 1 ETH(wei单位)
nonce: "123456", // 随机数,避免同数据重复签名
},
};
// 2. 请求用户签名
const signature = await window.ethereum.request({
method: "eth_signTypedData_v4",
params: [userAddress, JSON.stringify(typedData)],
});
// 3. 发送到后端验证
fetch("/api/verify-signature", {
method: "POST",
body: JSON.stringify({
signerAddress: userAddress,
typedData: typedData,
signature: signature,
}),
});
2. 后端验证(Golang实现)
后端需完成3个关键步骤:解析签名、生成数据哈希、验证签名者地址。
核心依赖:
go-ethereum:提供签名解析、公钥恢复等工具。go-ethereum/signer/core/apitypes:处理EIP-712结构化数据哈希。
package main
import (
"encoding/json"
"fmt"
"math/big"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/signer/core/apitypes"
)
// 验证EIP-712签名的主函数
func verifyEIP712Signature(
signerAddress string, // 预期的签名者地址(前端传递)
typedData apitypes.TypedData, // EIP-712结构化数据
signature string, // 前端传递的签名(hex格式)
chainID *big.Int, // 当前链ID(用于防重放验证)
) (bool, error) {
// 1. 校验输入地址格式
expectedAddr := common.HexToAddress(signerAddress)
if expectedAddr == common.Address{} {
return false, fmt.Errorf("无效的签名者地址")
}
// 2. 解析签名为字节数组
sigBytes, err := hexutil.Decode(signature)
if err != nil || len(sigBytes) != 65 {
return false, fmt.Errorf("无效的签名格式(必须是65字节)")
}
// 3. 处理EIP-155的v值(恢复原始v)
// 签名中的v包含链ID偏移,需还原为0或1(原始ECC签名的v值)
// 公式:v = sigBytes[64] - 27 - 2*chainID(EIP-155定义)
// 验证时只需确保还原后v为0或1,即可确认链ID匹配
v := sigBytes[64]
if v < 27 {
return false, fmt.Errorf("签名v值不符合EIP-155规范")
}
originalV := int(v - 27)
if originalV != 0 && originalV != 1 {
return false, fmt.Errorf("签名链ID不匹配当前网络")
}
// 4. 生成EIP-712结构化数据的哈希
// 哈希 = keccak256("\x19\x01" + domainSeparator + hashStruct(message))
hash, err := apitypes.TypedDataHash(typedData)
if err != nil {
return false, fmt.Errorf("生成数据哈希失败: %v", err)
}
// 5. 从签名和哈希中恢复公钥
// ECDSA签名验证:recover公钥 = ecrecover(hash, v, r, s)
pubKey, err := crypto.Ecrecover(hash.Bytes(), sigBytes)
if err != nil {
return false, fmt.Errorf("恢复公钥失败: %v", err)
}
// 6. 将公钥转换为地址并验证
recoveredAddr := crypto.PubkeyToAddress(*crypto.ToECDSAPub(pubKey))
if recoveredAddr != expectedAddr {
return false, fmt.Errorf("签名者不匹配(预期: %s, 实际: %s)", expectedAddr, recoveredAddr)
}
return true, nil
}
// 示例:从HTTP请求中解析参数并验证
func main() {
// 模拟前端传递的参数
signerAddress := "0x71c7656ec7ab88b098defb751b7401b5f6dbfaf"
signature := "0x...65字节签名..." // 实际签名需替换
chainID := big.NewInt(11155111) // Sepolia链ID
// 解析前端传递的typedData(JSON字符串转结构体)
typedDataJSON := `{
"domain": {
"name": "MyDApp",
"version": "1.0",
"chainId": 11155111,
"verifyingContract": "0x123..."
},
"types": {
"Transaction": [
{"name": "to", "type": "address"},
{"name": "amount", "type": "uint256"},
{"name": "nonce", "type": "uint256"}
]
},
"primaryType": "Transaction",
"message": {
"to": "0x456...",
"amount": "1000000000000000000",
"nonce": "123456"
}
}`
var typedData apitypes.TypedData
if err := json.Unmarshal([]byte(typedDataJSON), &typedData); err != nil {
panic(fmt.Sprintf("解析typedData失败: %v", err))
}
// 执行验证
valid, err := verifyEIP712Signature(signerAddress, typedData, signature, chainID)
if err != nil {
fmt.Printf("验证失败: %v\n", err)
return
}
if valid {
fmt.Println("签名验证成功,用户身份合法")
// 验证通过后:执行后续业务(如创建订单、发起链上交易)
} else {
fmt.Println("签名验证失败,拒绝操作")
}
}
三、关键细节与安全注意事项
-
防重放攻击强化:
- 除了EIP-155的
chainId,建议在message中加入nonce(随机数)或timestamp(时间戳),避免同一结构化数据被重复使用。 - 后端需记录已使用的
nonce,防止重复提交。
- 除了EIP-155的
-
数据完整性校验:
- 验证前需校验
typedData中的domain信息(如name、version),确保签名针对当前DApp,避免跨应用重放。
- 验证前需校验
-
错误处理:
- 签名无效的常见原因:签名被篡改、
chainId不匹配、typedData与签名时不一致。 - 后端应返回具体错误信息(如“签名者不匹配”“链ID错误”),方便前端提示用户。
- 签名无效的常见原因:签名被篡改、
-
性能优化:
- 高频验证场景(如批量订单)可缓存
TypedDataHash的计算结果,减少重复哈希运算。
- 高频验证场景(如批量订单)可缓存
四、应用场景
- 用户身份验证:替代传统密码登录,通过签名证明“用户拥有该钱包私钥”。
- 交易授权:用户签名确认链下订单(如DEX挂单),后端验证后再发起链上交易。
- 权限管理:签名授权第三方操作资产(如授权NFT marketplace转移NFT)。
通过上述流程,后端可安全地验证用户的链下签名,确保DApp操作的合法性和用户身份的真实性。
更多推荐


所有评论(0)