用 wagmi v2 踩坑两天,我终于搞定了 NFT 交易市场的离线签名和元数据加载
摘要
我在用 Next.js 开发 NFT 交易市场时,遇到了两个核心问题:1)用户下单需要离线签名,但签名数据总是验证失败;2)链上 NFT 的元数据存储在 IPFS,图片加载慢到崩溃。本文记录了我如何用 wagmi v2 和 viem 一步步解决这些问题,包括签名格式、缓存策略和错误处理。
背景
上个月,我接了一个 NFT 交易市场的前端开发任务。项目基于 Next.js 14,后端合约是用 Solidity 写的,前端需要实现:用户连接钱包后,可以上架自己的 NFT,然后其他用户直接购买。我选用了 wagmi v2 配合 RainbowKit 做钱包连接,viem 做合约交互。
一切看起来挺顺利,直到我开始做"用户下单"这个功能。用户需要签名一个订单,包含 NFT 地址、tokenId、价格、到期时间等信息,然后后端验证签名后才能挂单。我写完代码一测试,签名验证永远失败。同时,NFT 的元数据(图片、名称、描述)存在 IPFS 上,每次页面加载都慢得要命,有时候直接白屏。
我当时心态崩了,但没办法,只能硬着头皮排查。
问题分析
签名验证失败
我最初的思路很简单:用 wagmi 的 useSignMessage 让用户签名一个 JSON 字符串,然后把签名和消息一起发给后端。后端用 ethers.utils.verifyMessage 验证。但结果永远是"invalid signature"。
排查过程是这样的:
- 我先在浏览器控制台打印签名结果,发现
signature是个对象,有r、s、v字段。我直接把这个对象传给后端,后端用ethers解析时报错。 - 我查了 wagmi v2 的文档,发现它返回的是
0x开头的 hex 字符串,不是对象。但我打印出来确实是对象?后来意识到,我用了useSignMessage的signMessageAsync,它返回的就是 hex 字符串,但我在代码里错误地用了signMessage的data字段。 - 即使 hex 字符串对了,后端验证时,消息格式也有问题。wagmi 默认对消息做了
hashMessage,而我后端直接用原始字符串去验证,导致签名对不上。
元数据加载慢
NFT 元数据通常存在 IPFS,比如 ipfs://Qm...。我最初的做法是直接在组件里用 fetch 请求 IPFS 网关(比如 https://ipfs.io/ipfs/),但问题来了:
- 图片加载慢:用户列表页要展示几十个 NFT,每个都要请求 IPFS,页面卡死。
- 白屏:如果用户网络不好,
fetch超时,组件直接崩溃。 - 跨域:某些网关不支持跨域,导致请求失败。
我意识到必须做缓存和降级处理。
核心实现
1. 用 wagmi v2 正确实现离线签名
思路 :使用 useSignMessage 的 signMessageAsync 方法,它返回一个 0x 开头的 hex 字符串。然后对消息做标准化处理,确保前后端格式一致。
代码:
typescript
// hooks/useSignOrder.ts
import { useSignMessage } from 'wagmi'
import { hashMessage, verifyMessage } from 'viem'
import { useCallback } from 'react'
interface OrderData {
nftAddress: `0x${string}`
tokenId: string
price: string
expiry: number
seller: `0x${string}`
}
export function useSignOrder() {
const { signMessageAsync } = useSignMessage()
const signOrder = useCallback(async (order: OrderData) => {
// 注意:这里有个坑!不要直接用 JSON.stringify
// 必须按固定顺序拼接字段,否则前后端签名不一致
const message = `NFT Order:${order.nftAddress}:${order.tokenId}:${order.price}:${order.expiry}:${order.seller}`
// 用 viem 的 hashMessage 对消息进行 EIP-191 标准化
// wagmi 内部也是用这个标准,所以后端必须一致
const signature = await signMessageAsync({ message })
// 返回标准化消息和签名
return {
message,
signature,
// 可选:用 viem 验证签名是否有效,避免无效签名传到后端
isValid: verifyMessage({ address: order.seller, message, signature })
}
}, [signMessageAsync])
return { signOrder }
}
这里有个坑 :我最初用 JSON.stringify 把整个 order 对象转成字符串签名,但 JSON 的键顺序在不同环境可能不同,导致签名不一致。后来改成固定格式拼接字符串才解决。
后端验证示例(Node.js):
typescript
import { verifyMessage } from 'viem'
function verifyOrderSignature(
message: string,
signature: `0x${string}`,
expectedSigner: `0x${string}`
): boolean {
// 注意:必须用 viem 的 verifyMessage,它和 wagmi 的签名算法一致
// 如果用 ethers 的 verifyMessage,格式可能不同
const recoveredAddress = verifyMessage({ address: expectedSigner, message, signature })
return recoveredAddress === expectedSigner
}
2. 元数据加载:IPFS 缓存 + 图片降级
思路 :用 useQuery 做请求缓存,同时实现一个 IPFS 到 HTTP 网关的转换函数,并加入超时和错误处理。
代码:
typescript
// utils/ipfs.ts
import { useQuery } from '@tanstack/react-query'
// IPFS 网关列表,按优先级排序
const GATEWAYS = [
'https://ipfs.io/ipfs/',
'https://gateway.pinata.cloud/ipfs/',
'https://cloudflare-ipfs.com/ipfs/',
]
// 转换 IPFS URI 到 HTTP URL
export function ipfsToHttp(uri: string): string {
if (!uri) return ''
// 处理 ipfs:// 格式
if (uri.startsWith('ipfs://')) {
const cid = uri.replace('ipfs://', '')
// 随机选择一个网关,避免单一网关限流
const gateway = GATEWAYS[Math.floor(Math.random() * GATEWAYS.length)]
return `${gateway}${cid}`
}
// 如果是 https 或 http,直接返回
return uri
}
// 带超时的 fetch
async function fetchWithTimeout(url: string, timeout = 5000) {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url, { signal: controller.signal })
clearTimeout(timeoutId)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
} catch (error) {
clearTimeout(timeoutId)
throw error
}
}
// 获取 NFT 元数据的 hook
export function useNFTMetadata(tokenURI: string) {
return useQuery({
queryKey: ['nft-metadata', tokenURI],
queryFn: async () => {
const url = ipfsToHttp(tokenURI)
// 尝试多个网关,如果第一个失败就换下一个
for (const gateway of GATEWAYS) {
try {
const data = await fetchWithTimeout(url, 3000)
return data
} catch (error) {
console.warn(`Gateway ${gateway} failed:`, error)
continue
}
}
// 所有网关都失败,返回默认数据
return {
name: 'Unknown NFT',
image: '/placeholder.png',
description: 'Metadata loading failed'
}
},
// 缓存 10 分钟,避免频繁请求
staleTime: 10 * 60 * 1000,
// 失败时不重试,直接返回默认值
retry: false,
})
}
这里有个坑 :我一开始用 staleTime: Infinity 想永久缓存,但用户刷新页面后缓存就没了。后来改成 10 * 60 * 1000,配合 localStorage 持久化才解决。
3. 图片懒加载和占位符
思路 :用 next/image 的 placeholder 属性,配合 blurDataURL 实现加载前显示模糊占位。
代码:
typescript
// components/NFTImage.tsx
import Image from 'next/image'
import { useState } from 'react'
interface NFTImageProps {
src: string
alt: string
fallbackSrc?: string // 加载失败时的备用图片
}
export function NFTImage({ src, alt, fallbackSrc = '/placeholder.png' }: NFTImageProps) {
const [imgSrc, setImgSrc] = useState(src)
const [isError, setIsError] = useState(false)
return (
<div className="relative w-full h-64 bg-gray-200 rounded-lg overflow-hidden">
{isError ? (
// 加载失败时显示占位符
<div className="flex items-center justify-center h-full text-gray-500">
<span>Image unavailable</span>
</div>
) : (
<Image
src={imgSrc}
alt={alt}
fill
className="object-cover"
// 注意:这里用了 onError 事件,但 next/image 的 onError 在 v13+ 中可能不触发
// 我改用 img 标签的 onError
onError={() => {
console.warn('Image load failed, using fallback')
setIsError(true)
}}
// 占位符:加载前显示模糊效果
placeholder="blur"
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mN8/+F9PQAI8wNPvd7POQAAAABJRU5ErkJggg=="
/>
)}
</div>
)
}
这里有个坑 :next/image 的 onError 在开发模式下可能不会触发,因为图片被缓存了。生产环境没问题,但调试时很坑。我后来加了一个 useEffect 检查图片是否加载成功,但最终方案是改用原生 img 标签,只在需要优化时用 next/image。
4. 交易列表页性能优化
思路:使用虚拟化列表,只渲染可见区域内的 NFT 卡片,避免一次性渲染几百个图片。
代码:
typescript
// components/NFTList.tsx
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'
import { NFTImage } from './NFTImage'
import { useNFTMetadata } from '../utils/ipfs'
interface NFTItem {
tokenId: string
tokenURI: string
price: string
}
export function NFTList({ items }: { items: NFTItem[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 350, // 每个卡片高度
overscan: 5, // 上下各预渲染 5 个
})
return (
<div ref={parentRef} className="h-screen overflow-auto">
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const item = items[virtualItem.index]
const { data: metadata, isLoading, isError } = useNFTMetadata(item.tokenURI)
return (
<div
key={item.tokenId}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{isLoading ? (
// 加载中显示骨架屏
<div className="animate-pulse bg-gray-200 h-64 rounded-lg" />
) : (
<div className="border rounded-lg p-4">
<NFTImage
src={metadata?.image || ''}
alt={metadata?.name || 'NFT'}
/>
<h3 className="text-lg font-bold mt-2">{metadata?.name}</h3>
<p className="text-sm text-gray-600">{item.price} ETH</p>
</div>
)}
</div>
)
})}
</div>
</div>
)
}
注意这个细节 :虚拟化列表必须设置容器的 overflow: auto 和固定高度,否则 getScrollElement 返回 null。我一开始忘了设置,列表完全没显示。
完整代码
这里是一个完整的 NFT 交易市场首页组件,集成了上述所有功能:
typescript
// pages/index.tsx
import { useAccount, useConnect, useDisconnect } from 'wagmi'
import { useSignOrder } from '../hooks/useSignOrder'
import { NFTList } from '../components/NFTList'
import { useQuery } from '@tanstack/react-query'
import { readContract } from 'viem/actions'
import { publicClient } from '../utils/client'
// 假设合约地址和 ABI
const MARKETPLACE_ADDRESS = '0x...'
const MARKETPLACE_ABI = [...]
export default function Home() {
const { address, isConnected } = useAccount()
const { signOrder } = useSignOrder()
// 获取所有上架的 NFT
const { data: listings, isLoading } = useQuery({
queryKey: ['listings'],
queryFn: async () => {
// 调用合约获取所有上架信息
const rawListings = await readContract(publicClient, {
address: MARKETPLACE_ADDRESS,
abi: MARKETPLACE_ABI,
functionName: 'getAllListings',
args: [],
})
return rawListings.map((listing: any) => ({
tokenId: listing.tokenId.toString(),
tokenURI: listing.tokenURI,
price: listing.price.toString(),
seller: listing.seller,
}))
},
})
// 用户下单签名
const handleBuy = async (listing: any) => {
if (!address) {
alert('Please connect wallet')
return
}
const order = {
nftAddress: listing.nftAddress,
tokenId: listing.tokenId,
price: listing.price,
expiry: Math.floor(Date.now() / 1000) + 3600, // 1小时过期
seller: listing.seller,
}
const { message, signature, isValid } = await signOrder(order)
if (!isValid) {
alert('Signature verification failed')
return
}
// 发送签名到后端
const response = await fetch('/api/create-order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, signature, order }),
})
if (response.ok) {
alert('Order created successfully')
}
}
return (
<div className="container mx-auto p-4">
<h1 className="text-3xl font-bold mb-6">NFT Marketplace</h1>
{isConnected ? (
<p>Connected as: {address}</p>
) : (
<button onClick={() => useConnect()} className="bg-blue-500 text-white px-4 py-2 rounded">
Connect Wallet
</button>
)}
{isLoading ? (
<p>Loading listings...</p>
) : (
<NFTList items={listings || []} />
)}
</div>
)
}
踩坑记录
-
签名格式不匹配 :wagmi v2 的
signMessage返回的签名是0xhex 字符串,但我一开始以为是对象,导致后端解析失败。后来发现signMessageAsync返回的就是 hex 字符串,直接用就行。 -
消息标准化问题 :我用
JSON.stringify签名对象,但前后端 JSON 键顺序可能不同,导致签名不一致。解决方案:固定格式拼接字符串,确保前后端一致。 -
IPFS 网关跨域 :某些 IPFS 网关(比如
ipfs.io)在浏览器中跨域请求会被拦截。我改用gateway.pinata.cloud和cloudflare-ipfs.com,并随机选择网关避免单点故障。 -
next/image的onError不触发 :在开发模式下,图片被浏览器缓存,onError不会触发。生产环境没问题,但调试时我改用原生img标签,并在useEffect中手动检查图片加载状态。 -
虚拟化列表容器高度问题 :
useVirtualizer要求父容器有固定高度,我忘了设置h-screen,导致列表完全没显示。加上overflow-auto和高度后正常。
小结
这次踩坑让我深刻体会到,Web3 前端开发不仅仅是调用合约,还要处理签名格式、链下数据、性能优化等实际问题。核心收获是:签名必须标准化,元数据必须缓存,图片必须懒加载 。如果你也在开发 NFT 市场,建议深入研究 viem 的签名工具和 @tanstack/react-query 的缓存策略,这两个库能省很多时间。