用 wagmi v2 + Next.js 14 搞 NFT 交易市场前端:从合约调用失败到顺利上架,我踩了哪些坑

用 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 有四种状态:idleapprovingapproveDonelisting。用户点击上架后,先发 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。但 useWriteContractdata 是在交易提交后才有的,所以一开始 hashundefined。我一开始没处理这个初始状态,导致 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);
  }
};

注意这个细节useSimulateContractquery.enabled 很重要。如果链不对或者状态不对,就不要去估算,否则会一直报错。而且 simulateError 不一定是 gas 问题,也可能是参数格式不对。我遇到过一次 args 里的 price 忘记用 parseEther 转换,导致合约报错 revert

3. 读取已上架的 NFT 列表:处理 BigInt 和元数据

上架成功后,用户需要在市场页面上看到所有已上架的 NFT。这里我用 wagmi 的 useReadContract 来读取合约的 getListedItems 函数。

但读出来的数据全是 bigint 类型,包括 tokenIdprice。直接显示在 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(价格)。但这里有两个坑:

  1. buyItem 通常需要 payable,所以 writeContract 要带上 value 参数。
  2. 购买成功后需要刷新列表,但 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>
  );
}

踩坑记录

  1. useReadContract 在 map 里调用导致 hooks 数量不固定 :这个错误特别隐蔽,因为编译不报错,运行才报。后来查文档才知道 wagmi v2 的 hooks 必须遵守 React 规则。解决方案是改用 useReadContracts 批量读取。

  2. useSimulateContractquery.enabled 没设置导致无限估算 :一开始没加 enabled 条件,结果页面一加载就估算,链不对时一直报错。加了 isCorrectChain 判断后就好了。

  3. tokenURI 返回 IPFS 地址,前端直接 fetch 失败 :这个问题在测试网上很常见。我一开始没处理,以为合约返回的是 HTTP 地址。后来加了个 replaceipfs:// 换成网关地址。

  4. 购买时 value 忘记用 parseEther 转换 :UI 输入的是 0.1,但合约需要 wei。我直接把字符串传进去了,结果交易一直 revert。后来用 parseEther 转换才正常。

小结

这次做 NFT 交易市场前端,最大的感悟是:用 wagmi v2 开发时,一定要搞清楚 hooks 的规则和参数格式。特别是 useWriteContractuseReadContract 的调用方式,跟 ethers.js 差别很大。另外,处理 IPFS 地址和 BigInt 显示也是经常被忽略的细节。如果你也想深入,可以继续研究 wagmi v2 的 useAccountEffect 来做链切换监听,或者用 useSendTransaction 处理更复杂的交易。

相关推荐
前端不开发1 小时前
用一个 Bookmarklet(书签脚本),给任意网页挂一个可拖拽悬浮窗
前端·javascript
接着奏乐接着舞1 小时前
【无标题】
开发语言·前端·javascript
雨雨雨雨雨别下啦1 小时前
心理健康AI助手 - 项目总结
前端·javascript·vue.js·人工智能·信息可视化
风之舞_yjf2 小时前
Vue基础(32)_TodoList案例
前端·javascript·vue.js
Amos_Web3 小时前
Rspack 源码解析 (2) —— 从 rspack build 到输出 dist,完整编译链路详解
前端·javascript
张元清4 小时前
Ref 逃生舱:用 React Hook 解决闭包陈旧、回调身份不稳和强制更新
前端·javascript·面试
之歆5 小时前
DAY_13DOM操作完全指南DOM基础API与节点操作(上)
开发语言·前端·javascript·ecmascript
zhoumeina995 小时前
如何保证不同位置切换合成底图的渲染顺序
java·前端·javascript
bot5556665 小时前
企业微信ipad协议的消息引用与回复机制
javascript