用 wagmi v2 + Next.js App Router 踩坑三天,我终于搞定了 NFT 交易市场的跨链签名与上架逻辑

用 wagmi v2 + Next.js App Router 踩坑三天,我终于搞定了 NFT 交易市场的跨链签名与上架逻辑

摘要

我在做一个跨链 NFT 交易市场前端时,被 wagmi v2 的 useSignTypedData 和 Next.js App Router 的服务端渲染坑了整整三天。这篇文章记录了我从"签名上架"到"跨链订单匹配"的全过程,包含完整的 React 组件代码、踩坑记录和最终可运行的方案,帮你避开我走过的弯路。

背景

上个月,我们团队决定做一个跨链 NFT 交易市场,目标是把以太坊主网和 Polygon 上的 NFT 统一到一个平台上交易。我负责前端开发,技术栈选的是 Next.js 14 App Router + wagmi v2 + RainbowKit。项目一开始很顺利,直到我碰到 NFT 上架(listing)功能------用户需要签名授权市场合约操作自己的 NFT,然后创建一个包含价格、到期时间、链 ID 的订单。

我最初的想法很简单:用 wagmi 的 useSignTypedData 把订单数据签名,然后通过 useContractWrite 调用市场合约的 listItem 函数。但实际跑起来后,问题接踵而至:签名数据格式不对、跨链时 chainId 没传对、Next.js 服务端渲染导致 window.ethereum 未定义......我花了三天才把所有坑填平。

问题分析

我的第一版代码长这样:

tsx 复制代码
// ❌ 错误的第一版
const { signTypedData } = useSignTypedData();
const { write } = useContractWrite({
  address: MARKET_ADDRESS,
  abi: MARKET_ABI,
  functionName: 'listItem',
});

const handleList = async (tokenId: number, price: bigint) => {
  const signature = await signTypedData({
    domain: { name: 'NFTMarket', version: '1', chainId: 1 },
    types: { Order: [{ name: 'tokenId', type: 'uint256' }, { name: 'price', type: 'uint256' }, { name: 'expiry', type: 'uint256' }] },
    primaryType: 'Order',
    message: { tokenId, price, expiry: BigInt(Math.floor(Date.now() / 1000) + 86400) },
  });
  write({ args: [tokenId, price, expiry, signature] });
};

这段代码在本地跑没问题,但一上线就报错。我排查了三个问题:

  1. 签名数据里的 chainId 写死了:用户切换网络后,签名还是用主网的 chainId,合约验证失败。
  2. useContractWrite 在 App Router 下会报 useSyncExternalStore 错误:因为 wagmi 的 Provider 在服务端渲染时没有正确挂载。
  3. 签名后的 signature0x 开头字符串,但合约需要的是 bytes 类型 :wagmi v2 返回的是 hex string,而合约 listItemsignature 参数是 bytes,直接传没问题,但有个坑------如果签名内容包含 expiry 字段,用户钱包可能会拒绝签名。

核心实现

1. 用 useEffect 包裹 wagmi hooks,避免 SSR 问题

第一个要解决的是 Next.js App Router 的服务端渲染。wagmi 的 hooks 依赖浏览器环境(比如 window.ethereum),在服务端会报 useSyncExternalStore 错误。我的方案是:把所有 wagmi hooks 放在一个客户端组件里,并用 useEffect 确保只在客户端执行

tsx 复制代码
'use client'; // 必须标记为客户端组件

import { useSignTypedData, useContractWrite, useAccount, useChainId } from 'wagmi';
import { useEffect, useState } from 'react';

export function ListNFTForm({ tokenId, nftContract }: { tokenId: number; nftContract: `0x${string}` }) {
  const { address, isConnected } = useAccount();
  const chainId = useChainId(); // 动态获取当前链 ID
  const [isClient, setIsClient] = useState(false);

  useEffect(() => {
    setIsClient(true); // 标记客户端渲染完成
  }, []);

  if (!isClient) {
    return <div>Loading wallet...</div>; // 服务端渲染时显示占位
  }
  // ... 后续逻辑
}

这里有个坑useChainId 在 wagmi v2 里返回的是 number 类型,但如果你在 RainbowKit 里配置了多个链,用户切换时 chainId 会变,你必须确保签名时的 chainId 和当前链一致。我一开始用了 useNetworkchain.id,但 wagmi v2 已经废弃了 useNetwork,改用 useChainId 了。

2. 动态构建签名 domain,确保跨链兼容

签名 domain 里的 chainId 必须从当前链获取,不能写死。我定义了一个函数来生成 domain:

tsx 复制代码
const buildDomain = (chainId: number) => ({
  name: 'NFTMarket',
  version: '1',
  chainId, // 动态传入
  verifyingContract: MARKET_ADDRESS as `0x${string}`, // 市场合约地址
});

注意 verifyingContract 字段------很多教程会忽略它,但合约端验证签名时,通常会检查 verifyingContract 是否等于市场合约地址。如果你不传,钱包会报"Domain missing verifyingContract"警告,但签名仍能完成。然而合约验证会失败,因为合约端硬编码了 verifyingContract

我当时就踩了这个坑 :签名成功,交易也发送了,但 listItem 函数一直 revert,错误信息是 InvalidSignature。我花了大半天 debug,才发现是 domain 里少了 verifyingContract

3. 使用 useSignTypedData 并处理签名返回

useSignTypedData 返回一个 signTypedDataAsync 函数,可以直接 await 拿到签名。但有个细节:签名数据里的 expiry 必须是 bigint 类型,而 price 也要从 string 转成 bigint

tsx 复制代码
const { signTypedDataAsync } = useSignTypedData();

const handleSignAndList = async (priceInWei: string) => {
  if (!address) return;

  const price = BigInt(priceInWei);
  const expiry = BigInt(Math.floor(Date.now() / 1000) + 86400); // 24小时后过期

  const domain = buildDomain(chainId);
  const types = {
    Order: [
      { name: 'tokenId', type: 'uint256' },
      { name: 'price', type: 'uint256' },
      { name: 'expiry', type: 'uint256' },
      { name: 'seller', type: 'address' }, // 加上卖家地址,防止重放攻击
      { name: 'nftContract', type: 'address' }, // 加上 NFT 合约地址
    ],
  };
  const message = {
    tokenId: BigInt(tokenId),
    price,
    expiry,
    seller: address,
    nftContract: nftContract,
  };

  try {
    const signature = await signTypedDataAsync({
      domain,
      types,
      primaryType: 'Order',
      message,
    });
    // signature 是 `0x...` 格式的 hex string
    return { signature, price, expiry };
  } catch (err) {
    console.error('Signing failed:', err);
    // 用户可能在钱包里拒绝了签名
  }
};

注意这个细节 :我在 types 里加了 sellernftContract 字段。这是为了防止重放攻击------如果签名里没有卖家地址和 NFT 合约地址,攻击者可以把你的签名用在另一个 NFT 上。合约端也要对应验证这些字段。

4. 用 useContractWrite 发送上架交易

拿到签名后,调用合约的 listItem 函数。这里我遇到了 wagmi v2 的一个变化:useContractWritewrite 函数不再接受 args 作为参数,而是通过 request 对象传递。

tsx 复制代码
const { write } = useContractWrite({
  address: MARKET_ADDRESS,
  abi: MARKET_ABI,
  functionName: 'listItem',
});

const handleSubmit = async (priceInWei: string) => {
  const result = await handleSignAndList(priceInWei);
  if (!result) return;

  const { signature, price, expiry } = result;
  write({
    args: [
      nftContract, // NFT 合约地址
      BigInt(tokenId), // tokenId
      price, // 价格
      expiry, // 过期时间
      signature, // 签名
    ],
  });
};

这里有个坑write 函数返回的是一个 WriteContractReturnType,它不会立即返回交易 hash。你需要监听 onSuccess 回调或者使用 useWaitForTransactionReceipt 来获取交易状态。我一开始以为 write 是异步的,直接 await write(...),结果拿到了 undefined。正确做法是:

tsx 复制代码
const { write, data, isSuccess } = useContractWrite({
  address: MARKET_ADDRESS,
  abi: MARKET_ABI,
  functionName: 'listItem',
  onSuccess(data) {
    console.log('Transaction hash:', data.hash);
    // 可以跳转到区块浏览器
  },
  onError(error) {
    console.error('Transaction failed:', error);
  },
});

5. 跨链场景下的订单匹配

我们的交易市场支持跨链------用户在以太坊上签名上架 NFT,买家可以在 Polygon 上购买。这要求订单数据(包括签名)存储在链下数据库,然后跨链传递。前端需要做的是:

  • 上架时:在当前链签名,把签名和订单数据发送到后端 API。
  • 购买时:从后端获取订单数据,验证签名(在前端用 verifyTypedData),然后调用目标链的 buyItem 函数。

我在前端加了一个 verifySignature 函数,用 wagmi 的 verifyTypedData 来验证签名是否有效:

tsx 复制代码
import { verifyTypedData } from 'wagmi/actions';
import { config } from '@/wagmi'; // wagmi 配置

const isValid = await verifyTypedData(config, {
  domain: buildDomain(chainId),
  types: {
    Order: [
      { name: 'tokenId', type: 'uint256' },
      { name: 'price', type: 'uint256' },
      { name: 'expiry', type: 'uint256' },
      { name: 'seller', type: 'address' },
      { name: 'nftContract', type: 'address' },
    ],
  },
  primaryType: 'Order',
  message: {
    tokenId: BigInt(order.tokenId),
    price: BigInt(order.price),
    expiry: BigInt(order.expiry),
    seller: order.seller,
    nftContract: order.nftContract,
  },
  signature: order.signature,
});

注意verifyTypedData 是 wagmi v2 新增的一个 action,需要传入 config 对象。这个 config 是你初始化 wagmi 时创建的(比如 createConfig 返回的)。如果你用的是 RainbowKit,通常会在 providers 里创建它。

完整代码

下面是一个可运行的完整组件,包含了上架和签名验证逻辑。假设你已经配置好了 wagmi 和 RainbowKit。

tsx 复制代码
'use client';

import { useSignTypedData, useContractWrite, useAccount, useChainId } from 'wagmi';
import { verifyTypedData } from 'wagmi/actions';
import { useState, useEffect } from 'react';
import { config } from '@/wagmi'; // 你的 wagmi 配置文件

const MARKET_ADDRESS = '0xYourMarketContractAddress';
const MARKET_ABI = [
  // 你的市场合约 ABI,至少包含 listItem 和 buyItem
];

const buildDomain = (chainId: number) => ({
  name: 'NFTMarket',
  version: '1',
  chainId,
  verifyingContract: MARKET_ADDRESS,
});

export function ListNFTForm({ tokenId, nftContract }: { tokenId: number; nftContract: `0x${string}` }) {
  const { address, isConnected } = useAccount();
  const chainId = useChainId();
  const [isClient, setIsClient] = useState(false);
  const [price, setPrice] = useState('');

  useEffect(() => {
    setIsClient(true);
  }, []);

  const { signTypedDataAsync } = useSignTypedData();
  const { write, isSuccess } = useContractWrite({
    address: MARKET_ADDRESS,
    abi: MARKET_ABI,
    functionName: 'listItem',
    onSuccess(data) {
      console.log('Listed! Tx hash:', data.hash);
    },
    onError(error) {
      console.error('Listing failed:', error);
    },
  });

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

    const priceInWei = BigInt(price);
    const expiry = BigInt(Math.floor(Date.now() / 1000) + 86400 * 7); // 7天后过期

    const domain = buildDomain(chainId);
    const types = {
      Order: [
        { name: 'tokenId', type: 'uint256' },
        { name: 'price', type: 'uint256' },
        { name: 'expiry', type: 'uint256' },
        { name: 'seller', type: 'address' },
        { name: 'nftContract', type: 'address' },
      ],
    };
    const message = {
      tokenId: BigInt(tokenId),
      price: priceInWei,
      expiry,
      seller: address,
      nftContract,
    };

    try {
      const signature = await signTypedDataAsync({
        domain,
        types,
        primaryType: 'Order',
        message,
      });

      // 可选:验证签名是否正确
      const isValid = await verifyTypedData(config, {
        domain,
        types,
        primaryType: 'Order',
        message,
        signature,
      });
      if (!isValid) {
        console.error('Signature verification failed');
        return;
      }

      // 发送上架交易
      write({
        args: [nftContract, BigInt(tokenId), priceInWei, expiry, signature],
      });
    } catch (err) {
      console.error('Error:', err);
    }
  };

  if (!isClient) return <div>Loading...</div>;
  if (!isConnected) return <div>Please connect wallet</div>;

  return (
    <div>
      <h2>List NFT #{tokenId}</h2>
      <input
        type="text"
        placeholder="Price in wei"
        value={price}
        onChange={(e) => setPrice(e.target.value)}
      />
      <button onClick={handleList}>List for Sale</button>
      {isSuccess && <p>NFT listed successfully!</p>}
    </div>
  );
}

踩坑记录

  1. useSyncExternalStore 报错 :在 Next.js App Router 下,wagmi hooks 在服务端渲染时会报 useSyncExternalStore 错误。解决方法是把所有 wagmi 相关组件标记为 'use client',并用 useEffect 判断客户端渲染状态。

  2. 签名 domain 缺少 verifyingContract :导致合约验证签名失败,交易一直 revert。花了半天才 debug 出来------合约端在 ecrecover 时检查了 verifyingContract,但前端没传。

  3. useContractWritewrite 函数不是异步的 :我一开始 await write(...),结果拿到了 undefined。正确做法是通过 onSuccess 回调获取交易 hash。

  4. 跨链时 chainId 没动态更新 :用户从以太坊切换到 Polygon 后,useChainId 返回了新的链 ID,但我的签名 domain 里还缓存了旧的 chainId。解决方法是每次签名前重新获取 chainId

  5. verifyTypedData 需要传入 config :wagmi v2 的 action 函数(如 verifyTypedDatasignTypedData)都需要传入 config 对象,否则会报 No wagmi config found 错误。确保你导入了初始化的 config

小结

跨链 NFT 交易市场的前端开发,核心难点在于签名数据的正确性和跨链兼容性。通过动态获取 chainId、在 domain 中加入 verifyingContract、以及用 verifyTypedData 做签名验证,我最终解决了这个问题。如果你也在做类似项目,建议先写一个纯前端的签名验证测试,确保合约端和前端的数据格式完全一致。接下来可以进一步优化:比如用 useWaitForTransactionReceipt 显示交易进度,或者用 useSimulateContract 提前检查交易是否可行。

相关推荐
明月_清风11 小时前
全面了解 Vercel:前端开发者的高效武器库与实战指南
前端·next.js
倾颜3 天前
AI 应用里的第一个 Agent:我如何做一个可控的 Tasklist Agent
langchain·agent·next.js
Patrick_Wilson4 天前
IDE 升级重启后 Next.js dev 起不来?kill 无效的真正原因
node.js·next.js·前端工程化
竹林8184 天前
用 wagmi v2 + Next.js 14 搞 NFT 交易市场前端:从合约调用失败到顺利上架,我踩了哪些坑
javascript·next.js
Xinghongia5 天前
手把手教你搭建一个基于 Next.js 16 + FastAPI 构建的高颜值前后端分离个人博客
next.js
四六的六6 天前
我用什么技术做了TLDR Scholar——AI论文速读产品完整技术栈拆解
大模型·个人开发·ai编程·next.js·技术干货·独立开发·ai工具
行者-全栈开发8 天前
【前端安全】CVE-2026-44578:Next.js SSRF 漏洞深度解析与修复实战指南
websocket·云原生·next.js·安全防护·vercel·cve-2026-44578·中间件绕过
轻口味11 天前
AI 时代全栈开发破局:TypeScript 生态实战,从入门到部署一站式通关
前端·mongodb·docker·ai·typescript·react·next.js