用 wagmi v2 和 Next.js 14 硬扛 NFT 市场前端:从合约调用失败到批量上架,我踩了这些坑

背景

上个月,团队接了一个 NFT 市场的前端单子,要求用 Next.js 14 的 App Router 搭,后端合约是 Solidity 写的,已经部署到 Sepolia 测试网。我的任务就是实现用户连接钱包、查看自己持有的 NFT、选择上架(挂单)、以及购买别人挂单的 NFT。

听起来很常规对吧?我当时也觉得,wagmi v2 都出了,RainbowKit 也有现成的组件,应该很快能搞定。结果我一头扎进去,整整两天时间都耗在了"合约调用失败"和"签名不通过"这两个坑里。后来发现,问题出在 wagmi v2 的 API 变化、EIP-712 签名的处理方式,以及 Next.js 服务端渲染时对 Web3 库的兼容性上。

这篇文章,我就把自己踩过的坑、用的笨办法、最终怎么跑通的,全部写出来。如果你也在用 wagmi v2 做 NFT 市场,或者准备上 Next.js 14,希望能帮你少走弯路。

问题分析

最初的思路

我一开始的想法很简单:用 RainbowKit 做钱包连接,用 wagmi 的 useWriteContract 直接调合约的 listItem 方法,把 NFT 上架。合约那边我已经拿到了 ABI,上架函数签名是 listItem(address nftAddress, uint256 tokenId, uint256 price)

代码大概长这样:

typescript 复制代码
// 最初的错误写法
const { writeContract } = useWriteContract()

const handleListItem = async () => {
  writeContract({
    address: MARKETPLACE_ADDRESS,
    abi: marketplaceABI,
    functionName: 'listItem',
    args: [nftAddress, tokenId, price],
  })
}

看起来没问题吧?但点击按钮后,钱包弹出了 MetaMask 的交易确认,我点了确认,然后...交易一直 pending,最后直接 revert 了。

为什么行不通

我打开浏览器的控制台,发现 wagmi 抛了一个错误:

vbnet 复制代码
ContractFunctionExecutionError: The contract function "listItem" reverted with the following reason: "ERC721: transfer caller is not owner nor approved"

这个错误很经典:合约在执行 safeTransferFrom 的时候,发现调用者(也就是我当前的钱包地址)并没有被授权转移这个 NFT。我的合约里确实需要先 approve 市场合约,然后才能 listItem

但问题在于:wagmi v2 的 useWriteContract 默认会把当前连接的 account 作为 from 地址,而 listItem 内部会检查 msg.sender 是否拥有该 NFT 的转移权限。我虽然在前端调用了 approve,但因为交易是异步的,approve 还没被确认,我就立刻调了 listItem,导致合约认为我没有授权。

当时我就踩了这个坑:没有做交易等待和状态检查

排查过程

我花了半天时间,把交易流程拆成两步:

  1. 先调用 ERC721 的 approve 方法,授权市场合约管理这个 NFT。
  2. approve 交易被确认后,再调用市场合约的 listItem

但这样又带来一个新问题:用户需要签两次 MetaMask 交易,体验很差。而且如果用户在第一步授权后、第二步上架前刷新了页面,授权就白做了。

后来我想到可以用 useWaitForTransactionReceipt 来监听交易状态,但 wagmi v2 的 API 变了,useWaitForTransactionReceipt 返回的是 data 而不是 receipt。我一开始没看文档,直接按 v1 的写法来,结果一直拿不到交易哈希。

typescript 复制代码
// 错误的写法(v1 风格)
const { data } = useWaitForTransactionReceipt({
  hash: txHash, // v2 里这个参数叫 hash,但返回结构变了
})

正确的做法是:

typescript 复制代码
// wagmi v2 的正确写法
const { data: receipt, isLoading, isError } = useWaitForTransactionReceipt({
  hash: txHash,
})

receipt 才是交易收据对象,data 在 v2 里已经被废弃了。这个细节让我多花了两个小时。

核心实现

1. 搭建基本项目结构和钱包连接

我用的技术栈是 Next.js 14 + wagmi v2 + RainbowKit。首先创建项目:

bash 复制代码
npx create-next-app@latest nft-marketplace --typescript --tailwind --app
cd nft-marketplace
npm install wagmi viem @rainbow-me/rainbowkit

然后在 app/providers.tsx 里配置 wagmi 和 RainbowKit:

typescript 复制代码
'use client'

import { WagmiProvider, createConfig, http } from 'wagmi'
import { sepolia } from 'wagmi/chains'
import { RainbowKitProvider, getDefaultConfig } from '@rainbow-me/rainbowkit'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import '@rainbow-me/rainbowkit/styles.css'

const config = getDefaultConfig({
  appName: 'NFT Marketplace',
  projectId: process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID!, // 需要去 WalletConnect 申请
  chains: [sepolia],
  transports: {
    [sepolia.id]: http('https://sepolia.infura.io/v3/YOUR_INFURA_KEY'),
  },
})

const queryClient = new QueryClient()

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider>
          {children}
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  )
}

注意这个细节'use client' 是必须的,因为 wagmi 和 RainbowKit 都是客户端组件,不能在服务端渲染。Next.js 14 的 App Router 默认是服务端组件,所以必须用 'use client' 包裹。

然后在 app/layout.tsx 里引入:

typescript 复制代码
import { Providers } from './providers'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

2. 实现 NFT 上架功能(含 EIP-712 签名)

我后来决定改用 EIP-712 离线签名 的方式来实现上架,这样用户只需要签一条消息(不需要 gas),然后由后端或者前端直接提交交易。这能避免前面说的"先授权再上架"的糟糕体验。

合约那边支持了 EIP-712 的 createListing 函数,接受一个签名和一个 Listing 结构体。前端需要构建 domaintypesvalue,然后用 wagmi 的 signTypedData 来签名。

这里有个坑 :wagmi v2 的 signTypedData 返回的是 0x 开头的签名,而合约那边期望的是 bytes 类型。如果你直接用 signTypedData 的结果,合约会校验失败,因为签名格式不对。

我排查了半天,发现是因为 wagmi v2 默认使用了 viemsignTypedData,它返回的是 0x 前缀的 hex 字符串。但合约接收的是 bytes,在 Solidity 里 bytesstring 是不同的。正确的做法是:不要对签名做任何处理,直接传 0x 开头的字符串给合约 ,因为 viemencodeFunctionData 会自动把它转成 bytes

完整的上架逻辑如下:

typescript 复制代码
// app/components/ListItem.tsx
'use client'

import { useAccount, useSignTypedData, useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
import { useState } from 'react'
import { marketplaceABI, MARKETPLACE_ADDRESS } from '@/lib/contract'
import { parseEther } from 'viem'

export function ListItem({ nftAddress, tokenId }: { nftAddress: string; tokenId: string }) {
  const { address } = useAccount()
  const [price, setPrice] = useState('')
  const [txHash, setTxHash] = useState<`0x${string}` | undefined>(undefined)

  const { signTypedDataAsync } = useSignTypedData()
  const { writeContractAsync } = useWriteContract()

  // 监听交易确认
  const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
    hash: txHash,
  })

  const handleList = async () => {
    if (!address || !price) return

    // 1. 构建 EIP-712 签名数据
    const domain = {
      name: 'NFTMarketplace',
      version: '1',
      chainId: 11155111, // Sepolia
      verifyingContract: MARKETPLACE_ADDRESS,
    }

    const types = {
      Listing: [
        { name: 'seller', type: 'address' },
        { name: 'nftAddress', type: 'address' },
        { name: 'tokenId', type: 'uint256' },
        { name: 'price', type: 'uint256' },
        { name: 'deadline', type: 'uint256' },
      ],
    }

    const deadline = Math.floor(Date.now() / 1000) + 3600 // 1小时后过期
    const value = {
      seller: address,
      nftAddress: nftAddress as `0x${string}`,
      tokenId: BigInt(tokenId),
      price: parseEther(price),
      deadline: BigInt(deadline),
    }

    // 2. 用户签名(不需要 gas)
    const signature = await signTypedDataAsync({
      domain,
      types,
      primaryType: 'Listing',
      message: value,
    })

    // 3. 调用合约的 createListing
    const hash = await writeContractAsync({
      address: MARKETPLACE_ADDRESS,
      abi: marketplaceABI,
      functionName: 'createListing',
      args: [value, signature],
    })

    setTxHash(hash)
  }

  return (
    <div>
      <input
        type="text"
        value={price}
        onChange={(e) => setPrice(e.target.value)}
        placeholder="输入价格 (ETH)"
      />
      <button onClick={handleList} disabled={isConfirming}>
        {isConfirming ? '上架中...' : '上架 NFT'}
      </button>
      {isSuccess && <p>上架成功!交易哈希:{txHash}</p>}
    </div>
  )
}

3. 实现购买 NFT 功能

购买逻辑相对简单,因为用户只需要调用市场合约的 buyItem 函数,并附上 ETH(如果合约要求支付)。但这里也有一个坑:wagmi v2 的 useWriteContract 默认不携带 value ,如果你需要发送 ETH,必须显式设置 value 参数。

typescript 复制代码
// app/components/BuyNFT.tsx
'use client'

import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
import { marketplaceABI, MARKETPLACE_ADDRESS } from '@/lib/contract'
import { parseEther } from 'viem'
import { useState } from 'react'

interface Listing {
  listingId: string
  price: string
  seller: string
}

export function BuyNFT({ listing }: { listing: Listing }) {
  const [txHash, setTxHash] = useState<`0x${string}` | undefined>(undefined)

  const { writeContractAsync } = useWriteContract()
  const { isLoading, isSuccess } = useWaitForTransactionReceipt({
    hash: txHash,
  })

  const handleBuy = async () => {
    const hash = await writeContractAsync({
      address: MARKETPLACE_ADDRESS,
      abi: marketplaceABI,
      functionName: 'buyItem',
      args: [BigInt(listing.listingId)],
      value: parseEther(listing.price), // 这里必须传 value
    })
    setTxHash(hash)
  }

  return (
    <div>
      <p>卖家:{listing.seller.slice(0, 6)}...{listing.seller.slice(-4)}</p>
      <p>价格:{listing.price} ETH</p>
      <button onClick={handleBuy} disabled={isLoading}>
        {isLoading ? '购买中...' : '立即购买'}
      </button>
      {isSuccess && <p>购买成功!</p>}
    </div>
  )
}

注意这个细节value 的单位是 wei,所以要用 parseEther 把 ETH 字符串转成 BigInt。如果你直接传字符串,合约会报错说 msg.value 不足。

4. 查询并展示所有挂单

为了展示市场上的 NFT 列表,我需要从合约读取事件或者调用 getAllListings 函数。这里我选择用 wagmi 的 useReadContract 来读取合约状态。

但有个问题:useReadContract 是同步的(在 React 里是异步的,但返回值是固定的),你不能在它返回之前做条件渲染。我一开始用 if (!data) return <Loading />,结果页面一直 loading,因为 useReadContract 在服务端渲染时会返回 undefined

正确的做法 :用 isFetchingisFetched 来判断状态。

typescript 复制代码
// app/components/Listings.tsx
'use client'

import { useReadContract } from 'wagmi'
import { marketplaceABI, MARKETPLACE_ADDRESS } from '@/lib/contract'
import { BuyNFT } from './BuyNFT'

interface Listing {
  listingId: bigint
  seller: string
  nftAddress: string
  tokenId: bigint
  price: bigint
  isActive: boolean
}

export function Listings() {
  const { data, isFetching, isFetched, error } = useReadContract({
    address: MARKETPLACE_ADDRESS,
    abi: marketplaceABI,
    functionName: 'getAllListings',
    args: [],
  })

  if (isFetching) return <p>加载中...</p>
  if (error) return <p>加载失败:{error.message}</p>
  if (!isFetched || !data) return <p>暂无数据</p>

  const listings = data as Listing[]

  return (
    <div>
      {listings
        .filter((l) => l.isActive)
        .map((listing) => (
          <div key={listing.listingId.toString()}>
            <p>NFT 地址:{listing.nftAddress}</p>
            <p>Token ID:{listing.tokenId.toString()}</p>
            <p>价格:{listing.price.toString()} wei</p>
            <BuyNFT
              listing={{
                listingId: listing.listingId.toString(),
                price: listing.price.toString(),
                seller: listing.seller,
              }}
            />
          </div>
        ))}
    </div>
  )
}

5. 处理批量上架

用户可能想一次上架多个 NFT,但合约只支持单个 createListing。我的方案是:Promise.all 并行签名,然后逐个提交交易 。但这里要注意,signTypedDataAsync 每次调用都会弹 MetaMask 签名窗口,用户必须点很多次确认。

更好的做法 :让用户只签一次,把多个 listing 打包成一个数组签名。但这需要合约支持批量签名,如果合约不支持,就只能用 Promise.allSettled 来处理部分失败的情况。

typescript 复制代码
// 批量上架(逐个签名,逐个提交)
const handleBatchList = async (items: { nftAddress: string; tokenId: string; price: string }[]) => {
  const results = []
  for (const item of items) {
    try {
      const value = { seller: address, nftAddress: item.nftAddress, tokenId: BigInt(item.tokenId), price: parseEther(item.price), deadline: BigInt(deadline) }
      const signature = await signTypedDataAsync({ domain, types, primaryType: 'Listing', message: value })
      const hash = await writeContractAsync({ address: MARKETPLACE_ADDRESS, abi: marketplaceABI, functionName: 'createListing', args: [value, signature] })
      results.push({ tokenId: item.tokenId, hash, status: 'pending' })
    } catch (err) {
      results.push({ tokenId: item.tokenId, error: err, status: 'failed' })
    }
  }
  return results
}

这里有个坑 :如果用户中途取消了签名,signTypedDataAsync 会抛出一个 UserRejectedRequestError,你必须用 try/catch 捕获,否则整个 Promise.allSettled 都会失败。

完整代码

由于篇幅限制,这里只给出核心组件的完整代码。完整的项目结构如下:

go 复制代码
nft-marketplace/
├── app/
│   ├── layout.tsx
│   ├── page.tsx
│   └── providers.tsx
├── components/
│   ├── ListItem.tsx
│   ├── BuyNFT.tsx
│   └── Listings.tsx
├── lib/
│   └── contract.ts
└── package.json

lib/contract.ts 内容:

typescript 复制代码
import { Abi } from 'viem'

export const MARKETPLACE_ADDRESS = '0xYourMarketplaceContractAddress'

export const marketplaceABI: Abi = [
  // 这里放合约 ABI,我直接从 Hardhat 编译产物复制过来
  {
    type: 'function',
    name: 'createListing',
    inputs: [
      { name: 'listing', type: 'tuple', components: [
        { name: 'seller', type: 'address' },
        { name: 'nftAddress', type: 'address' },
        { name: 'tokenId', type: 'uint256' },
        { name: 'price', type: 'uint256' },
        { name: 'deadline', type: 'uint256' },
      ]},
      { name: 'signature', type: 'bytes' },
    ],
    outputs: [],
    stateMutability: 'nonpayable',
  },
  {
    type: 'function',
    name: 'buyItem',
    inputs: [{ name: 'listingId', type: 'uint256' }],
    outputs: [],
    stateMutability: 'payable',
  },
  {
    type: 'function',
    name: 'getAllListings',
    inputs: [],
    outputs: [{ name: '', type: 'tuple[]', components: [
      { name: 'listingId', type: 'uint256' },
      { name: 'seller', type: 'address' },
      { name: 'nftAddress', type: 'address' },
      { name: 'tokenId', type: 'uint256' },
      { name: 'price', type: 'uint256' },
      { name: 'isActive', type: 'bool' },
    ]}],
    stateMutability: 'view',
  },
] as const

踩坑记录

  1. wagmi v2 的 useWaitForTransactionReceipt 返回结构变了 :v1 返回 { data: receipt },v2 返回 { data: receipt, ... },但 data 字段已废弃,应该用 receipt 变量。我一开始没看文档,直接写 data.transactionHash 报错。

  2. EIP-712 签名格式问题 :wagmi v2 的 signTypedData 返回的签名是 0x 开头的 hex 字符串,合约接收 bytes 类型。最初我尝试用 viemhexToBytes 转换,结果合约校验失败。后来发现直接传 0x 字符串给合约的 bytes 参数即可,viem 内部会自动处理。

  3. Next.js 服务端渲染与 wagmi 不兼容 :所有用到 wagmi hooks 的组件都必须加 'use client',否则会报 hooks can only be called inside a component 错误。我一开始没注意,把 useReadContract 放在了服务端组件里,导致页面直接白屏。

  4. useWriteContract 不携带 value :购买 NFT 时需要发送 ETH,但 useWriteContract 默认不传 value。我花了半小时排查为什么交易一直失败,最后发现合约要求 msg.value 等于价格,而我没传 value 参数。

  5. 用户取消签名时的错误处理signTypedDataAsync 如果用户取消,会抛出 UserRejectedRequestError,如果不捕获,整个流程会中断。我用 try/catch 捕获后,把失败项记录下来,让用户选择重试。

小结

这次踩坑的核心收获是:wagmi v2 的 API 变化很多,一定要看最新文档;EIP-712 签名要特别注意类型定义和格式;Next.js 14 的 App Router 强制要求所有客户端组件加 'use client'

如果你想继续深挖,可以研究一下 wagmi v2 的 useSimulateContract ,它可以在调用前模拟交易,提前发现错误,避免用户浪费 gas。另外,批量签名和批量交易 也是 NFT 市场常见的需求,可以看看合约是否支持 multicall

希望这篇文章能帮你少走一些弯路。如果你也在做 NFT 市场,欢迎留言交流。

相关推荐
「已注销」2 小时前
面试分享:二本靠7轮面试成功拿下大厂P6
前端·javascript·面试
walking9573 小时前
重新学习前端之设计模式与架构
前端·javascript·面试
walking9573 小时前
重新学习前端之TypeScript
前端·javascript·面试
Hello--_--World4 小时前
Vue指令:v-if vs v-show、v-if 与 v-for 的优先级冲突、自定义指令
前端·javascript·vue.js
神の愛4 小时前
ReactHooks
前端·javascript·react.js
开源情报局5 小时前
从小红书评论区挖需求:我准备用 opencode 写一个 Chrome 插件
前端·javascript·chrome
小李子呢02115 小时前
前端八股JS---Map / Set / WeakMap / WeakSet
开发语言·前端·javascript
冴羽6 小时前
3 招让你的 Shadcn 出海应用性能提升 40 倍
前端·javascript·next.js
Hello--_--World7 小时前
Vue:虚拟Dom
前端·javascript·vue.js