用 wagmi v2 踩坑两天,我终于搞懂了多链钱包切换

11 阅读1分钟

背景

上个月接了一个 NFT 交易市场的活,需求很明确:用户能连接钱包,在以太坊主网和 Polygon 之间切换,然后浏览和购买不同链上的 NFT。项目用的是 Next.js 14 + TypeScript,钱包接入我选了 wagmi v2,因为 v1 我用了大半年,觉得 API 挺顺手。

但没想到,v1 到 v2 的升级比我预想的要"伤筋动骨"得多。我拿着 v1 的思维去写 v2 的代码,结果就是各种类型报错、链切换后状态不同步、签名消息在 Polygon 上直接失败。那两天我基本就是"写代码 → 报错 → 查文档 → 改代码 → 新报错"的死循环。

这篇文章就是把我这两天的排查过程完整记录下来,特别是几个关键问题的解决思路和最终跑通的代码。如果你也在用 wagmi v2 做多链 DApp,希望我的踩坑记录能帮你省下至少一天时间。

问题分析

我最初的思路很简单:在 main.tsx 里配置好 http() 的 transport,然后每个页面组件里直接用 useAccountuseSwitchChain 这些 hooks。v1 里就是这么干的,所以我以为 v2 也差不多。

结果第一个报错就给我来了个下马威——TypeError: Cannot read properties of undefined (reading 'chains')。我一看,这报错出现在 createConfig 里。查了文档才发现,v2 的 createConfigviem 包移到了 @wagmi/core,而且 chains 数组的传入方式变了,必须用 viem/chains 里导出的链对象,不能自己随便定义。

我一开始是这么写的:

// ❌ 错误写法
import { createConfig } from 'wagmi'
import { mainnet, polygon } from 'wagmi/chains'

但 v2 里 wagmi/chains 已经不存在了,需要从 viem/chains 导入。而且 createConfigmultiInjectedProviderDiscovery 选项在 v2 里默认就是 true,不需要显式声明。

第二个让我头疼的问题是链切换。我在组件里用 useSwitchChain 切换网络后,useAccount 返回的 chainId 确实变了,但页面上显示的 NFT 列表还是旧链的数据。这个问题的根源在于我用了 useEffect 去监听 chainId 变化再重新拉数据,但 useEffect 的依赖数组写错了,导致它根本没重新执行。

核心实现

第一步:正确的多链配置

先说结论,v2 的 createConfig 必须这样写:

// config/wagmi.ts
import { http, createConfig } from '@wagmi/core'
import { mainnet, polygon } from 'viem/chains'
import { metaMask } from 'wagmi/connectors'

export const wagmiConfig = createConfig({
  chains: [mainnet, polygon],
  connectors: [
    metaMask(),
    // 如果你还需要 walletConnect,可以加,但注意需要 projectId
    // walletConnect({ projectId: process.env.NEXT_PUBLIC_WC_PROJECT_ID! })
  ],
  transports: {
    [mainnet.id]: http(),
    [polygon.id]: http(),
  },
})

这里有个坑transports 的 key 必须是链的 id 数字,不能是链名。我第一次写成了 'mainnet': http(),结果 wagmi 内部完全找不到对应的 transport,导致连接钱包后所有 RPC 请求都超时。

然后在项目的根组件里:

// app/providers.tsx
'use client'

import { WagmiProvider } from 'wagmi'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { wagmiConfig } from '@/config/wagmi'

const queryClient = new QueryClient()

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={wagmiConfig}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </WagmiProvider>
  )
}

注意 v2 的 WagmiProvider 必须搭配 @tanstack/react-queryQueryClientProvider,否则 useReadContract 这类 hooks 会直接报错。这是我踩的第二个坑,后面细说。

第二步:链切换和状态同步

链切换的核心逻辑在 useSwitchChain 这个 hook 上。但我在实际项目中发现,切换链之后,页面上其他组件(比如 NFT 列表、价格显示)不会自动更新,因为 chainId 变化并没有触发所有相关组件的重新渲染。

我的解决方案是:在顶层组件里监听链变化,然后通过 Context 把当前链信息传下去。

// hooks/useActiveChain.ts
'use client'

import { useAccount, useSwitchChain } from 'wagmi'
import { createContext, useContext, useEffect, useState } from 'react'

interface ChainContextValue {
  chainId: number | undefined
  chainName: string
  switchChain: (id: number) => Promise<void>
  isSwitching: boolean
}

const ChainContext = createContext<ChainContextValue>({
  chainId: undefined,
  chainName: '',
  switchChain: async () => {},
  isSwitching: false,
})

export function ChainProvider({ children }: { children: React.ReactNode }) {
  const { chainId, isConnected } = useAccount()
  const { switchChainAsync, isPending } = useSwitchChain()
  const [activeChainId, setActiveChainId] = useState<number | undefined>(chainId)

  // 关键:同步 chainId 到本地 state
  useEffect(() => {
    if (chainId) {
      setActiveChainId(chainId)
      // 同时把 chainId 存到 localStorage,刷新页面后还能保持
      localStorage.setItem('activeChainId', String(chainId))
    }
  }, [chainId])

  const switchChain = async (id: number) => {
    try {
      await switchChainAsync({ chainId: id })
      // switchChainAsync 成功后,chainId 会自动更新,useEffect 会同步
    } catch (error) {
      console.error('切换链失败:', error)
    }
  }

  const chainName = activeChainId === 1 ? 'Ethereum' : activeChainId === 137 ? 'Polygon' : 'Unknown'

  return (
    <ChainContext.Provider value={{
      chainId: activeChainId,
      chainName,
      switchChain,
      isSwitching: isPending,
    }}>
      {children}
    </ChainContext.Provider>
  )
}

export const useActiveChain = () => useContext(ChainContext)

这里有个坑useSwitchChain 返回的是 switchChainAsync 而不是 v1 里的 switchChain。v1 的 switchChain 是同步的,v2 的 switchChainAsync 是异步的,必须 await。我当时没注意,直接调 switchChain({ chainId: 137 }),结果链根本没切换成功,页面也没反应。

第三步:读取 NFT 数据时的多链处理

链切换搞定了,接下来是 NFT 列表的读取。我用的合约是 ERC-721,需要根据当前链的地址去调合约。

// components/NFTList.tsx
'use client'

import { useReadContract } from 'wagmi'
import { useActiveChain } from '@/hooks/useActiveChain'
import { nftABI } from '@/contracts/nftABI'

// 不同链上的 NFT 合约地址
const CONTRACT_ADDRESSES: Record<number, `0x${string}`> = {
  1: '0xYourMainnetContract',  // 以太坊主网合约
  137: '0xYourPolygonContract', // Polygon 合约
}

export function NFTList() {
  const { chainId } = useActiveChain()
  const contractAddress = chainId ? CONTRACT_ADDRESSES[chainId] : undefined

  const { data: totalSupply, refetch } = useReadContract({
    address: contractAddress,
    abi: nftABI,
    functionName: 'totalSupply',
  })

  // 链切换后,需要手动触发 refetch
  useEffect(() => {
    refetch()
  }, [chainId, refetch])

  if (!contractAddress) {
    return <div>请先连接钱包</div>
  }

  return (
    <div>
      <p>当前链上有 {totalSupply?.toString() ?? '...'} 个 NFT</p>
      {/* 这里渲染 NFT 列表 */}
    </div>
  )
}

注意这个细节useReadContractaddress 参数如果是 undefined,wagmi 会直接跳过执行,不会报错。但如果你把 address 写死成某个链的地址,然后在另一条链上调用,合约调用会直接失败,因为合约不存在。我一开始就犯了这个问题,在 Polygon 上调用主网合约地址,useReadContract 返回的 errorContractFunctionExecutionError,排查了半天。

第四步:签名消息的链上校验

交易市场还有一个核心功能:用户对 NFT 出价时,需要签名一条消息。但问题是,用户在 Polygon 上签名的消息,和以太坊主网上签名的消息,格式应该不一样。我当时没注意,直接把主网的签名逻辑搬到了 Polygon 上,结果用户签名后,后端验签一直失败。

后来我查了 EIP-712 的规范,发现 domain 里的 chainId 必须和当前链一致。我的代码里写死了 chainId: 1,所以在 Polygon 上签名,验签肯定过不了。

// utils/signBid.ts
import { useSignTypedData } from 'wagmi'
import { useActiveChain } from '@/hooks/useActiveChain'

export function useSignBid() {
  const { chainId } = useActiveChain()
  const { signTypedDataAsync } = useSignTypedData()

  const signBid = async (nftId: number, price: string, nonce: number) => {
    // 注意:domain 里的 chainId 必须动态获取
    const domain = {
      name: 'NFTMarketplace',
      version: '1',
      chainId: chainId,  // 这里不能写死
      verifyingContract: '0xYourMarketplaceContract',
    }

    const types = {
      Bid: [
        { name: 'nftId', type: 'uint256' },
        { name: 'price', type: 'uint256' },
        { name: 'nonce', type: 'uint256' },
      ],
    }

    const message = {
      nftId,
      price,
      nonce,
    }

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

    return signature
  }

  return { signBid }
}

这个坑让我浪费了整整一个下午。如果你也在做需要签名验证的功能,一定记得把 chainId 动态传入,不要写死。

完整代码

这里是一个最小可运行的完整示例,包含多链配置、链切换、NFT 读取三个功能:

// app/page.tsx
'use client'

import { useAccount, useConnect, useDisconnect } from 'wagmi'
import { useActiveChain, ChainProvider } from '@/hooks/useActiveChain'
import { NFTList } from '@/components/NFTList'

function WalletConnector() {
  const { address, isConnected } = useAccount()
  const { connect, connectors } = useConnect()
  const { disconnect } = useDisconnect()
  const { chainId, chainName, switchChain, isSwitching } = useActiveChain()

  if (!isConnected) {
    return (
      <div>
        {connectors.map((connector) => (
          <button
            key={connector.uid}
            onClick={() => connect({ connector })}
            className="px-4 py-2 bg-blue-500 text-white rounded"
          >
            连接 {connector.name}
          </button>
        ))}
      </div>
    )
  }

  return (
    <div>
      <p>地址: {address}</p>
      <p>当前链: {chainName} (ID: {chainId})</p>
      <button
        onClick={() => switchChain(1)}
        disabled={isSwitching}
        className="mr-2 px-4 py-2 bg-green-500 text-white rounded"
      >
        切换到以太坊
      </button>
      <button
        onClick={() => switchChain(137)}
        disabled={isSwitching}
        className="mr-2 px-4 py-2 bg-purple-500 text-white rounded"
      >
        切换到 Polygon
      </button>
      <button onClick={() => disconnect()} className="px-4 py-2 bg-red-500 text-white rounded">
        断开连接
      </button>
      <NFTList />
    </div>
  )
}

export default function Home() {
  return (
    <ChainProvider>
      <WalletConnector />
    </ChainProvider>
  )
}

记得在 app/layout.tsx 里引入 Providers

// app/layout.tsx
import { Providers } from './providers'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh-CN">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

踩坑记录

1. Cannot read properties of undefined (reading 'chains') 原因:createConfigwagmi 导入,但 v2 里应该从 @wagmi/core 导入。而且 wagmi/chains 在 v2 中不存在,要从 viem/chains 导入链对象。

2. 切换链后页面数据不更新 原因:useEffect 的依赖数组没写对。我一开始写的是 useEffect(() => { refetch() }, []),空依赖数组导致只在组件挂载时执行一次。改成 [chainId, refetch] 后正常。

3. 在 Polygon 上签名,后端验签失败 原因:EIP-712 的 domain.chainId 写死成了 1。必须动态获取当前链的 ID。这个错误不会在前端报错,但后端验签一定会失败,排查起来很隐蔽。

4. useReadContract 在未连接钱包时返回 undefined,页面报错 原因:我没判断 address 是否为 undefined,直接渲染 totalSupply.toString()。v2 的 useReadContractaddressundefined 时会跳过执行,但返回的 data 也是 undefined,需要加空值判断。

小结

wagmi v2 相比 v1 最大的变化就是 config 的创建方式和链对象的导入来源,以及所有 hooks 都变成了异步。多链切换的核心就是动态管理 chainId,并且所有依赖链的合约调用、签名操作都要基于当前链 ID 来执行。想继续深入的话,可以研究一下 wagmi v2 的 useActionuseMutation 在交易场景中的应用,对交易状态管理会很有帮助。