用 wagmi v2 + Next.js 14 搞 NFT 交易市场前端:从合约调用失败到顺利上架,我踩了哪些坑
背景
上周我接了一个 NFT 交易市场的前端开发任务。项目用的是 Next.js 14,钱包连接用的是 wagmi v2 + RainbowKit。需求很简单:用户能连接钱包,查看自己持有的 NFT,把 NFT 上架到市场,设定价格,也能购买别人上架的 NFT,以及取消自己的上架。
听起来是不是很常规?我当时也这么想。之前在别的项目里用过 ethers.js 直接调合约,感觉没什么难度。结果从第一天就开始踩坑------签名失败、交易回滚、gas 估算不准、元数据不显示......整整折腾了两天多才把核心流程跑通。
这篇文章就是把我解决这些问题的过程完整记录下来。如果你也在用 wagmi v2 做 NFT 市场前端,希望你能少走我这些弯路。
问题分析
我的第一版思路很简单:用 wagmi 的 useWriteContract 直接调用合约的 listItem 函数,传入 tokenId 和价格。代码写起来确实很短:
tsx
const { writeContract } = useWriteContract();
writeContract({
address: marketAddress,
abi: marketAbi,
functionName: 'listItem',
args: [nftAddress, tokenId, ethers.parseEther('0.1')],
});
结果一跑,控制台直接报错:User rejected the request。我明明在 MetaMask 里点了确认,怎么还拒绝?后来发现是我没理解 wagmi v2 的 writeContract 签名方式------它默认用的是 eth_sendTransaction,但很多 NFT 市场合约要求先调用 NFT 合约的 approve,然后才能调用 listItem。而且 wagmi v2 的 useWriteContract 返回的是交易哈希,不是回调确认,我之前的处理方式完全不对。
更坑的是,我后来改用 useSimulateContract 做 gas 估算,结果又遇到了 insufficient funds for gas * price + value 的错误。当时我的钱包里确实有 ETH,但 gas 估算值跑飞了。排查了很久才发现,是因为我没有正确设置 account 参数。
核心实现
1. 先调 approve,再调 listItem:两笔交易的顺序坑
NFT 交易市场的标准流程是:用户先调用 NFT 合约的 approve 把某个 tokenId 授权给市场合约,然后市场合约才能把 NFT 转移走。所以前端必须发两笔交易。
我当时的第一反应是:用户点一次"上架"按钮,先发 approve,等 approve 确认后再发 listItem。但在 wagmi v2 里,useWriteContract 是异步的,而且没有内置的等待确认回调。我试了用 waitForTransactionReceipt 来等确认,但发现这样会导致 UI 状态混乱------用户可能以为已经完成了,但实际上第二笔交易还没发。
我的解决方案是:用一个状态机来控制流程。status 有四种状态:idle、approving、approveDone、listing。用户点击上架后,先发 approve,等交易确认后自动切换到 listing 状态,再发 listItem。
tsx
// 状态枚举
type ListStatus = 'idle' | 'approving' | 'approveDone' | 'listing';
const [status, setStatus] = useState<ListStatus>('idle');
// 第一步:调用 approve
const { writeContract: approveWrite } = useWriteContract();
const { data: approveHash } = useWaitForTransactionReceipt({
hash: status === 'approving' ? pendingHash : undefined,
});
const handleApprove = async (tokenId: bigint) => {
setStatus('approving');
approveWrite({
address: nftAddress as `0x${string}`,
abi: erc721Abi,
functionName: 'approve',
args: [marketAddress, tokenId],
});
};
// 监听 approve 确认
useEffect(() => {
if (approveHash && status === 'approving') {
setStatus('approveDone');
}
}, [approveHash, status]);
这里有个坑 :wagmi v2 的 useWaitForTransactionReceipt 必须传入一个 hash 参数,而且这个 hash 必须是 useWriteContract 返回的 data。但 useWriteContract 的 data 是在交易提交后才有的,所以一开始 hash 是 undefined。我一开始没处理这个初始状态,导致 useWaitForTransactionReceipt 永远不触发。后来加了个条件判断,只有 status === 'approving' 时才传入 hash,才正常。
2. 处理 listItem 的 gas 估算问题
approve 成功后,接下来调用 listItem。但我在这一步又遇到了 gas 估算不准的问题。
wagmi v2 提供了 useSimulateContract 来做 gas 估算,但我发现它返回的 request 有时候会报错 insufficient funds。排查后发现,是因为 useSimulateContract 默认使用当前连接的钱包地址作为 account,但如果用户的钱包在另一个链上,或者合约地址写错了,就会导致估算失败。
我的做法是:先检查链 ID 是否匹配,然后用 useSimulateContract 的返回值来构造交易,最后用 useWriteContract 发送。如果估算失败,就 fallback 到默认 gas limit。
tsx
const { chain } = useAccount();
const marketChainId = 11155111; // Sepolia
// 检查链是否匹配
const isCorrectChain = chain?.id === marketChainId;
// 估算 listItem 的 gas
const { data: simulateData, error: simulateError } = useSimulateContract({
address: marketAddress as `0x${string}`,
abi: marketAbi,
functionName: 'listItem',
args: [nftAddress, tokenId, price],
query: {
enabled: isCorrectChain && status === 'approveDone',
},
});
// 发送 listItem 交易
const { writeContract: listWrite } = useWriteContract();
const handleList = () => {
if (simulateError) {
// 如果估算失败,用默认 gas limit
listWrite({
address: marketAddress as `0x${string}`,
abi: marketAbi,
functionName: 'listItem',
args: [nftAddress, tokenId, price],
gas: 200000n, // 硬编码一个安全值
});
} else if (simulateData?.request) {
listWrite(simulateData.request);
}
};
注意这个细节 :useSimulateContract 的 query.enabled 很重要。如果链不对或者状态不对,就不要去估算,否则会一直报错。而且 simulateError 不一定是 gas 问题,也可能是参数格式不对。我遇到过一次 args 里的 price 忘记用 parseEther 转换,导致合约报错 revert。
3. 读取已上架的 NFT 列表:处理 BigInt 和元数据
上架成功后,用户需要在市场页面上看到所有已上架的 NFT。这里我用 wagmi 的 useReadContract 来读取合约的 getListedItems 函数。
但读出来的数据全是 bigint 类型,包括 tokenId 和 price。直接显示在 UI 上会变成 12345678901234567890n 这种形式。而且每个 NFT 的元数据(图片、名称、描述)需要从 NFT 合约的 tokenURI 获取,这是一个异步的 HTTP 请求。
我的做法是:把 useReadContract 的结果映射成一个数组,然后对每个 item 调用 useReadContract 读取 tokenURI,再用 useEffect 去 fetch 元数据 JSON。
tsx
// 读取所有上架的 NFT
const { data: listedItems, isLoading: itemsLoading } = useReadContract({
address: marketAddress as `0x${string}`,
abi: marketAbi,
functionName: 'getListedItems',
});
// 对每个 item 读取 tokenURI
const itemsWithMetadata = listedItems?.map((item: any) => {
const { data: tokenUri } = useReadContract({
address: item.nftAddress as `0x${string}`,
abi: erc721Abi,
functionName: 'tokenURI',
args: [item.tokenId],
});
// 这里有个坑:useReadContract 不能在 map 里用,因为 hooks 数量必须固定
// 正确做法:用另一个组件或者用 useContractReads 批量读取
});
我踩的这个坑特别大 :useReadContract 是 React Hook,不能在循环或条件语句里调用。我一开始在 map 里直接调用,结果报错 Rendered more hooks than during the previous render。后来改用 useContractReads 批量读取所有 tokenURI,但 useContractReads 在 wagmi v2 里改成了 useReadContracts,参数格式也不一样。
最终方案:用 useReadContracts 一次性读取所有 NFT 的 tokenURI:
tsx
const { data: tokenUris } = useReadContracts({
contracts: listedItems?.map((item: any) => ({
address: item.nftAddress as `0x${string}`,
abi: erc721Abi,
functionName: 'tokenURI',
args: [item.tokenId],
})) || [],
});
// 然后 fetch 每个 URI 获取元数据
const [metadataList, setMetadataList] = useState<any[]>([]);
useEffect(() => {
if (!tokenUris) return;
const fetchAll = async () => {
const results = await Promise.all(
tokenUris.map(async (result: any) => {
if (result.status === 'success') {
const response = await fetch(result.result);
return response.json();
}
return null;
})
);
setMetadataList(results);
};
fetchAll();
}, [tokenUris]);
注意这个细节 :tokenURI 返回的可能是 IPFS 地址(如 ipfs://xxx),前端直接 fetch 会失败。需要先解析成 HTTP 网关地址。我用了 ipfs-utils 库,或者简单替换 ipfs:// 为 https://ipfs.io/ipfs/。
4. 购买功能的实现:处理 ETH 转账和回调
购买功能的逻辑更简单:用户点击"购买"按钮,调用市场合约的 buyItem 函数,同时发送 ETH(价格)。但这里有两个坑:
buyItem通常需要payable,所以writeContract要带上value参数。- 购买成功后需要刷新列表,但 wagmi v2 没有内置的 refetch 机制。
我的做法是:用 useWriteContract 发送交易,然后用 useWaitForTransactionReceipt 监听确认,确认后手动调用 refetch 刷新列表。
tsx
const { writeContract: buyWrite, data: buyHash } = useWriteContract();
const { isSuccess: buySuccess } = useWaitForTransactionReceipt({
hash: buyHash,
});
const handleBuy = (item: ListedItem) => {
buyWrite({
address: marketAddress as `0x${string}`,
abi: marketAbi,
functionName: 'buyItem',
args: [item.nftAddress, item.tokenId],
value: item.price, // 发送 ETH
});
};
// 购买成功后刷新列表
useEffect(() => {
if (buySuccess) {
refetchListedItems(); // 假设这个函数是 useReadContract 返回的 refetch
toast.success('购买成功!');
}
}, [buySuccess]);
这里有个坑 :value 的单位是 wei,而 item.price 从合约读出来就是 bigint 类型的 wei 值,所以直接传就行。但如果你从 UI 输入框获取价格,记得用 parseEther 转换。我当时就是忘了转换,导致发送了 0.000000000000000001 ETH,合约直接 revert。
完整代码
下面是一个可运行的完整示例(基于 Next.js 14 + wagmi v2 + RainbowKit):
tsx
// app/components/NFTMarket.tsx
'use client';
import { useState, useEffect } from 'react';
import { useAccount, useWriteContract, useWaitForTransactionReceipt, useReadContract, useReadContracts } from 'wagmi';
import { parseEther, formatEther } from 'viem';
import { erc721Abi } from './abis/erc721Abi';
import { marketAbi } from './abis/marketAbi';
type ListStatus = 'idle' | 'approving' | 'approveDone' | 'listing';
type ListedItem = {
seller: string;
nftAddress: string;
tokenId: bigint;
price: bigint;
};
export default function NFTMarket() {
const { address, chain } = useAccount();
const [status, setStatus] = useState<ListStatus>('idle');
const [selectedTokenId, setSelectedTokenId] = useState<bigint>(0n);
const [price, setPrice] = useState<string>('0.1');
const [listedItems, setListedItems] = useState<ListedItem[]>([]);
// 合约地址(请替换为实际部署的地址)
const marketAddress = '0xYourMarketAddress' as `0x${string}`;
const nftAddress = '0xYourNFTAddress' as `0x${string}`;
// 读取所有上架的 NFT
const { data: rawItems, refetch: refetchItems } = useReadContract({
address: marketAddress,
abi: marketAbi,
functionName: 'getListedItems',
});
// 批量读取 tokenURI
const { data: tokenUris } = useReadContracts({
contracts: (rawItems as ListedItem[] || []).map((item) => ({
address: item.nftAddress as `0x${string}`,
abi: erc721Abi,
functionName: 'tokenURI',
args: [item.tokenId],
})),
});
// 获取元数据
const [metadataList, setMetadataList] = useState<any[]>([]);
useEffect(() => {
if (!tokenUris) return;
const fetchAll = async () => {
const results = await Promise.all(
tokenUris.map(async (result: any) => {
if (result.status === 'success') {
const uri = result.result.replace('ipfs://', 'https://ipfs.io/ipfs/');
try {
const res = await fetch(uri);
return res.json();
} catch {
return null;
}
}
return null;
})
);
setMetadataList(results);
};
fetchAll();
}, [tokenUris]);
// approve 交易
const { writeContract: approveWrite, data: approveHash } = useWriteContract();
const { isSuccess: approveSuccess } = useWaitForTransactionReceipt({
hash: approveHash,
});
// approve 成功后自动进入 listing 状态
useEffect(() => {
if (approveSuccess && status === 'approving') {
setStatus('approveDone');
}
}, [approveSuccess, status]);
// listItem 交易
const { writeContract: listWrite, data: listHash } = useWriteContract();
const { isSuccess: listSuccess } = useWaitForTransactionReceipt({
hash: listHash,
});
// 上架成功后刷新
useEffect(() => {
if (listSuccess) {
setStatus('idle');
refetchItems();
alert('上架成功!');
}
}, [listSuccess, refetchItems]);
// 处理上架按钮点击
const handleList = async () => {
if (!address) return;
const tokenId = selectedTokenId;
const priceWei = parseEther(price);
// 第一步:approve
setStatus('approving');
approveWrite({
address: nftAddress,
abi: erc721Abi,
functionName: 'approve',
args: [marketAddress, tokenId],
});
};
// 当 status 变为 approveDone 时,自动调用 listItem
useEffect(() => {
if (status === 'approveDone') {
const priceWei = parseEther(price);
listWrite({
address: marketAddress,
abi: marketAbi,
functionName: 'listItem',
args: [nftAddress, selectedTokenId, priceWei],
});
}
}, [status, price, selectedTokenId, marketAddress, nftAddress, listWrite]);
// 购买功能
const handleBuy = (item: ListedItem) => {
buyWrite({
address: marketAddress,
abi: marketAbi,
functionName: 'buyItem',
args: [item.nftAddress, item.tokenId],
value: item.price,
});
};
const { writeContract: buyWrite, data: buyHash } = useWriteContract();
const { isSuccess: buySuccess } = useWaitForTransactionReceipt({
hash: buyHash,
});
useEffect(() => {
if (buySuccess) {
refetchItems();
alert('购买成功!');
}
}, [buySuccess, refetchItems]);
return (
<div className="p-4">
<h1 className="text-2xl font-bold mb-4">NFT 交易市场</h1>
{/* 上架区域 */}
<div className="border p-4 rounded mb-4">
<h2 className="text-lg font-semibold mb-2">上架 NFT</h2>
<input
type="number"
placeholder="Token ID"
value={selectedTokenId.toString()}
onChange={(e) => setSelectedTokenId(BigInt(e.target.value || '0'))}
className="border p-2 mr-2"
/>
<input
type="text"
placeholder="价格 (ETH)"
value={price}
onChange={(e) => setPrice(e.target.value)}
className="border p-2 mr-2"
/>
<button
onClick={handleList}
disabled={status !== 'idle'}
className="bg-blue-500 text-white p-2 rounded disabled:opacity-50"
>
{status === 'approving' ? '授权中...' : status === 'listing' ? '上架中...' : '上架'}
</button>
</div>
{/* 已上架列表 */}
<div className="grid grid-cols-3 gap-4">
{(rawItems as ListedItem[] || []).map((item, index) => (
<div key={index} className="border p-4 rounded">
{metadataList[index] && (
<img src={metadataList[index].image} alt={metadataList[index].name} className="w-full h-48 object-cover mb-2" />
)}
<p>Token ID: {item.tokenId.toString()}</p>
<p>价格: {formatEther(item.price)} ETH</p>
<p>卖家: {item.seller.slice(0, 6)}...{item.seller.slice(-4)}</p>
{item.seller !== address && (
<button onClick={() => handleBuy(item)} className="bg-green-500 text-white p-2 rounded mt-2">
购买
</button>
)}
</div>
))}
</div>
</div>
);
}
踩坑记录
-
useReadContract在 map 里调用导致 hooks 数量不固定 :这个错误特别隐蔽,因为编译不报错,运行才报。后来查文档才知道 wagmi v2 的 hooks 必须遵守 React 规则。解决方案是改用useReadContracts批量读取。 -
useSimulateContract的query.enabled没设置导致无限估算 :一开始没加enabled条件,结果页面一加载就估算,链不对时一直报错。加了isCorrectChain判断后就好了。 -
tokenURI返回 IPFS 地址,前端直接 fetch 失败 :这个问题在测试网上很常见。我一开始没处理,以为合约返回的是 HTTP 地址。后来加了个replace把ipfs://换成网关地址。 -
购买时
value忘记用parseEther转换 :UI 输入的是0.1,但合约需要 wei。我直接把字符串传进去了,结果交易一直 revert。后来用parseEther转换才正常。
小结
这次做 NFT 交易市场前端,最大的感悟是:用 wagmi v2 开发时,一定要搞清楚 hooks 的规则和参数格式。特别是 useWriteContract 和 useReadContract 的调用方式,跟 ethers.js 差别很大。另外,处理 IPFS 地址和 BigInt 显示也是经常被忽略的细节。如果你也想深入,可以继续研究 wagmi v2 的 useAccountEffect 来做链切换监听,或者用 useSendTransaction 处理更复杂的交易。