用 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] });
};
这段代码在本地跑没问题,但一上线就报错。我排查了三个问题:
- 签名数据里的
chainId写死了:用户切换网络后,签名还是用主网的 chainId,合约验证失败。 useContractWrite在 App Router 下会报useSyncExternalStore错误:因为 wagmi 的 Provider 在服务端渲染时没有正确挂载。- 签名后的
signature是0x开头字符串,但合约需要的是bytes类型 :wagmi v2 返回的是 hex string,而合约listItem的signature参数是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 和当前链一致。我一开始用了 useNetwork 的 chain.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 里加了 seller 和 nftContract 字段。这是为了防止重放攻击------如果签名里没有卖家地址和 NFT 合约地址,攻击者可以把你的签名用在另一个 NFT 上。合约端也要对应验证这些字段。
4. 用 useContractWrite 发送上架交易
拿到签名后,调用合约的 listItem 函数。这里我遇到了 wagmi v2 的一个变化:useContractWrite 的 write 函数不再接受 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>
);
}
踩坑记录
-
useSyncExternalStore报错 :在 Next.js App Router 下,wagmi hooks 在服务端渲染时会报useSyncExternalStore错误。解决方法是把所有 wagmi 相关组件标记为'use client',并用useEffect判断客户端渲染状态。 -
签名 domain 缺少
verifyingContract:导致合约验证签名失败,交易一直 revert。花了半天才 debug 出来------合约端在ecrecover时检查了verifyingContract,但前端没传。 -
useContractWrite的write函数不是异步的 :我一开始await write(...),结果拿到了undefined。正确做法是通过onSuccess回调获取交易 hash。 -
跨链时 chainId 没动态更新 :用户从以太坊切换到 Polygon 后,
useChainId返回了新的链 ID,但我的签名 domain 里还缓存了旧的 chainId。解决方法是每次签名前重新获取chainId。 -
verifyTypedData需要传入config:wagmi v2 的 action 函数(如verifyTypedData、signTypedData)都需要传入config对象,否则会报No wagmi config found错误。确保你导入了初始化的config。
小结
跨链 NFT 交易市场的前端开发,核心难点在于签名数据的正确性和跨链兼容性。通过动态获取 chainId、在 domain 中加入 verifyingContract、以及用 verifyTypedData 做签名验证,我最终解决了这个问题。如果你也在做类似项目,建议先写一个纯前端的签名验证测试,确保合约端和前端的数据格式完全一致。接下来可以进一步优化:比如用 useWaitForTransactionReceipt 显示交易进度,或者用 useSimulateContract 提前检查交易是否可行。