Next.js + wagmi v2 踩坑实录:开发 NFT 交易市场时,我如何处理离线签名和链下元数据

用 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"。

排查过程是这样的:

  1. 我先在浏览器控制台打印签名结果,发现 signature 是个对象,有 r、s、v 字段。我直接把这个对象传给后端,后端用 ethers 解析时报错。
  2. 我查了 wagmi v2 的文档,发现它返回的是 0x 开头的 hex 字符串,不是对象。但我打印出来确实是对象?后来意识到,我用了 useSignMessage 的 signMessageAsync,它返回的就是 hex 字符串,但我在代码里错误地用了 signMessage 的 data 字段。
  3. 即使 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>
  )
}

踩坑记录

  1. 签名格式不匹配 :wagmi v2 的 signMessage 返回的签名是 0x hex 字符串,但我一开始以为是对象,导致后端解析失败。后来发现 signMessageAsync 返回的就是 hex 字符串,直接用就行。

  2. 消息标准化问题 :我用 JSON.stringify 签名对象,但前后端 JSON 键顺序可能不同,导致签名不一致。解决方案:固定格式拼接字符串,确保前后端一致。

  3. IPFS 网关跨域 :某些 IPFS 网关(比如 ipfs.io)在浏览器中跨域请求会被拦截。我改用 gateway.pinata.cloud 和 cloudflare-ipfs.com,并随机选择网关避免单点故障。

  4. next/image 的 onError 不触发 :在开发模式下,图片被浏览器缓存,onError 不会触发。生产环境没问题,但调试时我改用原生 img 标签,并在 useEffect 中手动检查图片加载状态。

  5. 虚拟化列表容器高度问题 :useVirtualizer 要求父容器有固定高度,我忘了设置 h-screen,导致列表完全没显示。加上 overflow-auto 和高度后正常。

小结

这次踩坑让我深刻体会到,Web3 前端开发不仅仅是调用合约,还要处理签名格式、链下数据、性能优化等实际问题。核心收获是:签名必须标准化,元数据必须缓存,图片必须懒加载 。如果你也在开发 NFT 市场,建议深入研究 viem 的签名工具和 @tanstack/react-query 的缓存策略,这两个库能省很多时间。

相关推荐
程序员Sunday6 小时前
JavaScript 事件循环面试题,宏任务与微任务怎么执行|Sunday面试指南
开发语言·javascript·面试·校招·事件循环·程序员sunday
默_笙7 小时前
🌊 向量库和 ES 都查不出"关系",我只好给奶茶建了一张人脉网
前端·javascript
思无邪668 小时前
用 AI 做 JS 逆向:从抓包到复现的完整方法论
开发语言·javascript·人工智能
福兮说10 小时前
浏览器里把 iPhone 的 HEIC 转成 JPG:Chrome 解不开、转完大了七成、拍摄时间全丢,六个坑实测
前端·javascript·图像处理·chrome·ios·iphone·heic
可乐鸡翅yeah_11 小时前
video.js 集成 hls.js 开发 M3U8 播放器,新手高频踩坑
开发语言·前端·javascript·后端·ecmascript·m3u8·音视频在线播放
用户1179536547812 小时前
TRAE vs Claude Code 性能对比:从代码生成、多文件重构到底层吞吐,谁更适合你的日常开发?
javascript
kill52213 小时前
useRef
前端·javascript·react.js
米多科技14 小时前
我做了一个 3D 虚拟世界基底,在上面再搭 3D 世界或元宇宙,事半功倍
javascript·人工智能·github
霍营_托尼14 小时前
纯前端播 40GB 本地视频?我把浏览器改造成了「磁盘流式」播放器
前端·javascript·音视频开发
西柚小萌新14 小时前
【LLM&&AI应用开发 八股文】--4.2.Agent智能体(中)
前端·javascript·react.js