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 是个对象,有 rsv 字段。我直接把这个对象传给后端,后端用 ethers 解析时报错。
  2. 我查了 wagmi v2 的文档,发现它返回的是 0x 开头的 hex 字符串,不是对象。但我打印出来确实是对象?后来意识到,我用了 useSignMessagesignMessageAsync,它返回的就是 hex 字符串,但我在代码里错误地用了 signMessagedata 字段。
  3. 即使 hex 字符串对了,后端验证时,消息格式也有问题。wagmi 默认对消息做了 hashMessage,而我后端直接用原始字符串去验证,导致签名对不上。

元数据加载慢

NFT 元数据通常存在 IPFS,比如 ipfs://Qm...。我最初的做法是直接在组件里用 fetch 请求 IPFS 网关(比如 https://ipfs.io/ipfs/),但问题来了:

  • 图片加载慢:用户列表页要展示几十个 NFT,每个都要请求 IPFS,页面卡死。
  • 白屏:如果用户网络不好,fetch 超时,组件直接崩溃。
  • 跨域:某些网关不支持跨域,导致请求失败。

我意识到必须做缓存和降级处理。

核心实现

1. 用 wagmi v2 正确实现离线签名

思路 :使用 useSignMessagesignMessageAsync 方法,它返回一个 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/imageplaceholder 属性,配合 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/imageonError 在开发模式下可能不会触发,因为图片被缓存了。生产环境没问题,但调试时很坑。我后来加了一个 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.cloudcloudflare-ipfs.com,并随机选择网关避免单点故障。

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

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

小结

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

相关推荐
前端Hardy1 小时前
谁还没⽤过shadcn/ui?114k+星标,不装NPM包,前端组件自由终于实现了
前端·javascript·vue.js
猪猪聪明_V2 小时前
前端码农的本地项目启动器
前端·javascript
暗不需求2 小时前
前端性能优化 防抖与节流完全指南:从原理到最佳实践
前端·javascript·面试
@大迁世界2 小时前
45.什么是内联条件表达式(inline conditional expressions)?在事件处理里怎么用?
开发语言·前端·javascript·react.js·ecmascript
我胖虎不答应!!2 小时前
Three.js开发思想笔记
javascript·笔记·three.js
一颗趴菜2 小时前
微信小程序如何去下载PDF呢
前端·javascript
zithern_juejin3 小时前
JS深拷贝与浅拷贝
javascript
前端毕业班4 小时前
前端"枚举"管理指南
前端·javascript
Jx6575 小时前
初学者视角下的JavaScript作用域理解
javascript