背景
上个月,团队接了一个 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,导致合约认为我没有授权。
当时我就踩了这个坑:没有做交易等待和状态检查。
排查过程
我花了半天时间,把交易流程拆成两步:
- 先调用 ERC721 的
approve方法,授权市场合约管理这个 NFT。 - 等
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 结构体。前端需要构建 domain、types、value,然后用 wagmi 的 signTypedData 来签名。
这里有个坑 :wagmi v2 的 signTypedData 返回的是 0x 开头的签名,而合约那边期望的是 bytes 类型。如果你直接用 signTypedData 的结果,合约会校验失败,因为签名格式不对。
我排查了半天,发现是因为 wagmi v2 默认使用了 viem 的 signTypedData,它返回的是 0x 前缀的 hex 字符串。但合约接收的是 bytes,在 Solidity 里 bytes 和 string 是不同的。正确的做法是:不要对签名做任何处理,直接传 0x 开头的字符串给合约 ,因为 viem 的 encodeFunctionData 会自动把它转成 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。
正确的做法 :用 isFetching 和 isFetched 来判断状态。
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
踩坑记录
-
wagmi v2 的
useWaitForTransactionReceipt返回结构变了 :v1 返回{ data: receipt },v2 返回{ data: receipt, ... },但data字段已废弃,应该用receipt变量。我一开始没看文档,直接写data.transactionHash报错。 -
EIP-712 签名格式问题 :wagmi v2 的
signTypedData返回的签名是0x开头的 hex 字符串,合约接收bytes类型。最初我尝试用viem的hexToBytes转换,结果合约校验失败。后来发现直接传0x字符串给合约的bytes参数即可,viem内部会自动处理。 -
Next.js 服务端渲染与 wagmi 不兼容 :所有用到 wagmi hooks 的组件都必须加
'use client',否则会报hooks can only be called inside a component错误。我一开始没注意,把useReadContract放在了服务端组件里,导致页面直接白屏。 -
useWriteContract不携带value:购买 NFT 时需要发送 ETH,但useWriteContract默认不传value。我花了半小时排查为什么交易一直失败,最后发现合约要求msg.value等于价格,而我没传value参数。 -
用户取消签名时的错误处理 :
signTypedDataAsync如果用户取消,会抛出UserRejectedRequestError,如果不捕获,整个流程会中断。我用try/catch捕获后,把失败项记录下来,让用户选择重试。
小结
这次踩坑的核心收获是:wagmi v2 的 API 变化很多,一定要看最新文档;EIP-712 签名要特别注意类型定义和格式;Next.js 14 的 App Router 强制要求所有客户端组件加 'use client'。
如果你想继续深挖,可以研究一下 wagmi v2 的 useSimulateContract ,它可以在调用前模拟交易,提前发现错误,避免用户浪费 gas。另外,批量签名和批量交易 也是 NFT 市场常见的需求,可以看看合约是否支持 multicall。
希望这篇文章能帮你少走一些弯路。如果你也在做 NFT 市场,欢迎留言交流。