用 wagmi v2 踩坑两天,我终于搞懂了 Web3 表单签名验证
摘要
在做一个 DeFi 管理后台时,我需要让用户通过连接钱包签名一条消息来验证身份,代替传统的用户名密码。自己折腾了两天,遇到了签名数据格式不对、验签地址不匹配、前端状态管理混乱等问题。本文完整记录了我的实现方案和踩坑修复过程,代码可直接复用。
背景
上个月我在做一个 DeFi 项目的管理后台,功能是让用户查看自己的质押记录和收益。这个后台需要用户登录才能访问,但我不希望用传统的邮箱+密码,因为 Web3 用户更习惯用钱包来证明身份。我想实现一个"免密登录":用户连接钱包后,后端生成一个随机数(nonce),用户用私钥签名这个 nonce,前端把签名和钱包地址发给后端,后端验签通过后返回 JWT token。
听起来很简单对吧?但实际做起来,我遇到了好几个坑,尤其是前端签名和后端验签的格式不匹配,折腾了我整整两天。这篇文章就是我把整个过程复盘后写下来的,希望能帮到同样在做 Web3 登录的兄弟。
问题分析
我的思路很直接:用户连接钱包 → 前端请求后端获取 nonce → 用户用钱包签名 nonce → 前端把签名和地址发给后端 → 后端验签。
最初我用的是 ethers.js 的 signMessage 方法,前端签名后用 recoverAddress 验签。但问题来了:我用 MetaMask 签名后,后端验签一直返回地址不匹配。我打印了签名和地址,看起来都没问题,但就是验不过。
排查过程很痛苦。我先怀疑是后端验签逻辑错了,但用 ethers 的 verifyMessage 写了个本地测试脚本,同样的签名数据,本地验签能过,后端验签就不过。后来发现是后端用的验签库(比如 web3.js 或 ethers v5)对签名格式的处理和我前端不一致。更坑的是,有的签名会带 0x 前缀,有的不带;有的签名是 vrs 格式,有的是 r,s,v 分开的。这些细节差异直接导致验签失败。
还有一个坑:用户切换钱包后,我之前的签名状态没有清空,导致旧的签名被发到后端,验签当然失败。这些问题让我意识到,Web3 签名验证不是简单的"签名→验签"两步走,中间有很多细节需要处理。
核心实现
第一步:搭建签名上下文(使用 wagmi 管理钱包状态)
我选择用 wagmi v2 来管理钱包连接,因为它提供了 useSignMessage 和 useAccount 等 hooks,比我直接用 ethers.js 方便很多。但注意,wagmi v2 的 API 和 v1 有些不同,我刚升级时踩了坑。
首先安装依赖:
bash
npm install wagmi viem @tanstack/react-query
然后创建一个 Web3Provider 包裹整个应用:
typescript
// providers/Web3Provider.tsx
import { WagmiProvider, createConfig, http } from 'wagmi'
import { mainnet, sepolia } from 'wagmi/chains'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { injected } from 'wagmi/connectors'
const config = createConfig({
chains: [mainnet, sepolia],
connectors: [injected()], // 支持 MetaMask 等注入钱包
transports: {
[mainnet.id]: http(),
[sepolia.id]: http(),
},
})
const queryClient = new QueryClient()
export function Web3Provider({ children }: { children: React.ReactNode }) {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</WagmiProvider>
)
}
这里有个坑:wagmi v2 不再默认包含 @wagmi/core 的 signMessage 方法,而是通过 useSignMessage hook 来调用。如果你没安装 @tanstack/react-query,useSignMessage 会报错。我一开始没装,直接用了 useSignMessage,结果控制台报 No QueryClient set,找了半天才发现。
第二步:获取 nonce 并签名(核心逻辑)
接下来是签名逻辑。我创建了一个 useAuth hook,它封装了从获取 nonce 到签名再到发送验证的完整流程。
typescript
// hooks/useAuth.ts
import { useSignMessage } from 'wagmi'
import { useAccount } from 'wagmi'
import { useState } from 'react'
export function useAuth() {
const { address, isConnected } = useAccount()
const { signMessageAsync } = useSignMessage()
const [nonce, setNonce] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
// 1. 请求后端获取 nonce
const requestNonce = async () => {
if (!address) throw new Error('钱包未连接')
const response = await fetch('/api/auth/nonce', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address }),
})
const data = await response.json()
// 注意:后端返回的 nonce 应该是一个随机字符串,比如 "Sign this message to prove you own 0x123... Nonce: 12345"
setNonce(data.nonce)
return data.nonce
}
// 2. 签名 nonce
const signNonce = async (nonceToSign: string) => {
if (!isConnected || !address) {
throw new Error('钱包未连接')
}
// 这里有个坑:wagmi 的 signMessageAsync 默认对消息进行 EIP-191 格式化,
// 但如果你直接传原始字符串,它会自动加上 "\x19Ethereum Signed Message:\n" 前缀。
// 后端验签时也需要用同样的方式处理,否则会验签失败。
const signature = await signMessageAsync({ message: nonceToSign })
return signature
}
// 3. 登录:组合 nonce 获取、签名、发送验证
const login = async () => {
if (!address) throw new Error('钱包未连接')
setIsLoading(true)
try {
const nonce = await requestNonce()
const signature = await signNonce(nonce)
// 发送签名和地址到后端验证
const response = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
address,
signature,
nonce, // 后端需要 nonce 来重新计算签名
}),
})
if (!response.ok) {
throw new Error('验签失败')
}
const { token } = await response.json()
// 保存 token 到 localStorage 或 cookie
localStorage.setItem('auth_token', token)
return token
} catch (error) {
console.error('登录失败:', error)
throw error
} finally {
setIsLoading(false)
}
}
return { login, isLoading, isConnected }
}
注意这个细节 :signMessageAsync 默认会对消息进行 EIP-191 格式化(加上 \x19Ethereum Signed Message:\n 前缀)。如果你在后端用 ethers 的 verifyMessage 验签,它会自动处理这个前缀,所以没问题。但如果你在后端自己实现验签逻辑(比如用 eth_sign 的原始方式),就需要手动处理前缀。我当时后端用了 web3.js 的 eth.accounts.recover,它也需要前缀,但默认不自动加,导致前后端不一致。最后我统一用 ethers 的 verifyMessage 解决了。
第三步:后端验签(Node.js 示例)
为了让文章完整,我写一个简单的后端验签逻辑,使用 ethers v6:
typescript
// server/verify.ts (Node.js + Express)
import { ethers } from 'ethers'
// 验签中间件
export function verifySignature(req, res, next) {
const { address, signature, nonce } = req.body
if (!address || !signature || !nonce) {
return res.status(400).json({ error: '缺少参数' })
}
try {
// ethers v6 的 verifyMessage 会自动处理 EIP-191 前缀
const recoveredAddress = ethers.verifyMessage(nonce, signature)
if (recoveredAddress.toLowerCase() !== address.toLowerCase()) {
return res.status(401).json({ error: '验签失败,地址不匹配' })
}
// 验签通过,生成 JWT
const token = generateJWT({ address })
res.json({ token })
} catch (error) {
console.error('验签错误:', error)
res.status(500).json({ error: '验签异常' })
}
}
这里有个容易忽略的点:地址比较时一定要 toLowerCase(),因为 MetaMask 返回的地址可能大小写混用(checksum 格式),而 recoverAddress 返回的是全小写。我一开始没做大小写转换,导致明明签名正确,但地址不匹配,排查了好久。
第四步:前端 UI 组件(React 实现)
最后是前端的登录按钮组件,使用上面定义的 useAuth hook:
typescript
// components/LoginButton.tsx
import { useAccount, useConnect, useDisconnect } from 'wagmi'
import { injected } from 'wagmi/connectors'
import { useAuth } from '../hooks/useAuth'
export function LoginButton() {
const { address, isConnected } = useAccount()
const { connect } = useConnect()
const { disconnect } = useDisconnect()
const { login, isLoading } = useAuth()
const handleLogin = async () => {
if (!isConnected) {
// 如果未连接钱包,先连接
connect({ connector: injected() })
return
}
try {
await login()
alert('登录成功!')
} catch (error) {
alert('登录失败,请重试')
}
}
return (
<div>
{isConnected ? (
<div>
<p>已连接钱包: {address?.slice(0, 6)}...{address?.slice(-4)}</p>
<button onClick={handleLogin} disabled={isLoading}>
{isLoading ? '登录中...' : '签名登录'}
</button>
<button onClick={() => disconnect()}>断开钱包</button>
</div>
) : (
<button onClick={handleLogin}>连接钱包</button>
)}
</div>
)
}
完整代码(可直接运行的示例)
为了方便你直接测试,我把前后端核心代码整合成一个最小可运行示例。前端使用 Vite + React + wagmi,后端使用 Node.js + Express。
前端完整代码
typescript
// App.tsx
import { WagmiProvider, createConfig, http, useAccount, useConnect, useDisconnect, useSignMessage } from 'wagmi'
import { mainnet } from 'wagmi/chains'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { injected } from 'wagmi/connectors'
import { useState } from 'react'
const config = createConfig({
chains: [mainnet],
connectors: [injected()],
transports: { [mainnet.id]: http() },
})
const queryClient = new QueryClient()
function AuthComponent() {
const { address, isConnected } = useAccount()
const { connect } = useConnect()
const { disconnect } = useDisconnect()
const { signMessageAsync } = useSignMessage()
const [isLoading, setIsLoading] = useState(false)
// 模拟后端 nonce 获取(实际项目中应请求真实后端)
const getNonce = async () => {
// 这里模拟后端返回的 nonce
return `Sign this message to prove you own ${address}. Nonce: ${Date.now()}`
}
const handleSignAndLogin = async () => {
if (!address) return
setIsLoading(true)
try {
// 1. 获取 nonce
const nonce = await getNonce()
// 2. 签名
const signature = await signMessageAsync({ message: nonce })
// 3. 发送到后端验证(模拟)
console.log('发送到后端验证:', { address, signature, nonce })
// 实际请求:await fetch('/api/auth/verify', { method: 'POST', body: JSON.stringify({ address, signature, nonce }) })
alert('签名成功,请查看控制台日志')
} catch (error) {
console.error('签名失败:', error)
} finally {
setIsLoading(false)
}
}
return (
<div style={{ padding: '20px' }}>
<h1>Web3 签名登录演示</h1>
{isConnected ? (
<>
<p>钱包地址: {address}</p>
<button onClick={handleSignAndLogin} disabled={isLoading}>
{isLoading ? '签名中...' : '签名登录'}
</button>
<button onClick={() => disconnect()}>断开连接</button>
</>
) : (
<button onClick={() => connect({ connector: injected() })}>连接 MetaMask</button>
)}
</div>
)
}
export default function App() {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<AuthComponent />
</QueryClientProvider>
</WagmiProvider>
)
}
后端完整代码
typescript
// server/index.js (Node.js + Express)
const express = require('express')
const { ethers } = require('ethers')
const app = express()
app.use(express.json())
// 存储已使用的 nonce(防止重放攻击)
const usedNonces = new Set()
app.post('/api/auth/verify', (req, res) => {
const { address, signature, nonce } = req.body
if (!address || !signature || !nonce) {
return res.status(400).json({ error: '缺少参数' })
}
// 检查 nonce 是否已被使用
if (usedNonces.has(nonce)) {
return res.status(401).json({ error: 'nonce 已过期' })
}
try {
const recoveredAddress = ethers.verifyMessage(nonce, signature)
if (recoveredAddress.toLowerCase() !== address.toLowerCase()) {
return res.status(401).json({ error: '验签失败,地址不匹配' })
}
// 标记 nonce 为已使用
usedNonces.add(nonce)
// 生成 JWT(简化版,生产环境应使用 jsonwebtoken 库)
const token = `jwt_${Date.now()}_${address}`
res.json({ token })
} catch (error) {
console.error('验签错误:', error)
res.status(500).json({ error: '验签异常' })
}
})
app.listen(3001, () => console.log('后端服务运行在 http://localhost:3001'))
踩坑记录
-
签名格式不匹配 :前端用 wagmi 的
signMessageAsync签名,后端用 web3.js 的eth.accounts.recover验签,一直失败。后来发现 web3.js 的recover方法默认不处理 EIP-191 前缀,需要手动加上\x19Ethereum Signed Message:\n。我最终统一用 ethers 的verifyMessage解决了。 -
地址大小写问题 :MetaMask 返回的地址是 checksum 格式(如
0xAbCd...),而ethers.verifyMessage返回的是全小写。直接比较===会失败。必须toLowerCase()后再比较。 -
nonce 重放攻击 :刚开始我没做 nonce 去重,同一个 nonce 可以反复使用。后来在服务端加了
usedNoncesSet 来记录已使用的 nonce,并设置过期时间(比如 5 分钟后自动清除)。 -
钱包切换后状态未重置 :用户连接钱包 A 签名后,切换钱包 B 再点击登录,会发送钱包 A 的签名和钱包 B 的地址。解决方案是在
useAccount的address变化时清空签名状态,或者在登录前检查address是否与签名时的地址一致。
小结
Web3 签名验证的核心就是"前端签名、后端验签",但细节决定成败:签名格式、地址大小写、nonce 防重放、状态管理。如果你也遇到类似问题,建议先统一前后端使用的验签库(推荐 ethers),然后严格按 EIP-191 标准处理签名。如果想深入,可以研究 EIP-712 结构化签名和 ERC-1271 合约验签,适合更复杂的场景。