普通视图

发现新文章,点击刷新页面。
今天 — 2026年5月3日首页

wagmi v2 多链钱包切换:一个 Uniswap 仿盘项目让我踩了三天坑

作者 竹林818
2026年5月2日 18:00

背景

上个月,我接手了一个"Uniswap 精简版"项目——一个支持 Ethereum、Polygon、Arbitrum 三条链的 DEX 前端。项目用 wagmi v2 + RainbowKit 做钱包连接,React + Vite 开发。需求听起来很简单:用户连接钱包后,能选择任意一条链进行交易,并且钱包会自动切换到对应链。

我当时想,wagmi 不是有 useSwitchChainuseAccount 吗?直接调用就完事了。结果呢?我花了整整三天,经历了无数个"为什么钱包没反应"、"为什么链没切换但页面状态变了"的抓狂时刻。这篇文章,就是把我踩过的坑和最终的解决方案完整记录下来。

问题分析

一开始,我的思路很直接:用 useAccount 获取当前链 ID,用 useSwitchChain 切换链。代码大概长这样:

// 我最初的错误写法
const { chain } = useAccount();
const { switchChain } = useSwitchChain();

const handleChainChange = (targetChainId: number) => {
  if (chain?.id !== targetChainId) {
    switchChain({ chainId: targetChainId });
  }
};

看起来没问题对吧?但实际运行时,问题来了:

问题 1: 在 MetaMask 上切换链后,useAccount 返回的 chain 更新了,但 UI 上的交易对信息没有更新。我明明用了 useEffect 监听 chain 变化,但页面就是不刷新。

问题 2: 切换到一条不支持的链(比如用户自己添加了 BSC)时,useSwitchChain 会报错,但错误信息非常不友好,而且 chain 状态会被污染。

问题 3: 最诡异的是——当用户手动在钱包里切换链,而不是通过我写的按钮切换时,useSwitchChain 根本不会触发,但 useAccountchain 变了。这就导致我的代码里有两套"当前链":一套来自按钮操作,一套来自钱包事件,它们经常不同步。

排查了两天,我翻遍了 wagmi 的文档和 GitHub Issues,终于发现了关键点:wagmi v2 中 useAccountchain 是只读的,它只反映钱包当前连接的链,不会触发 React 组件的重新渲染(至少在特定场景下)。而 useSwitchChain 返回的 isSuccess 状态才是可靠的切换完成标志。

核心实现

1. 重新理解 wagmi v2 的状态管理

我做的第一件事,是抛弃了"用 useAccount 驱动 UI"的思维。wagmi v2 推荐的做法是:useChainId 获取当前链 ID,用 useSwitchChain 处理切换,用 useEffect 监听切换完成事件

这里有个坑:useChainId 返回的是 wagmi 配置中的当前链 ID,而不是钱包实际连接的链 ID。如果用户手动在钱包里切换,useChainId 不会自动更新!所以,我最终决定自己维护一个"同步的链状态"。

我创建了一个自定义 hook useSyncedChain

// hooks/useSyncedChain.ts
import { useChainId, useSwitchChain, useAccount, usePublicClient } from 'wagmi';
import { useEffect, useState, useCallback } from 'react';

export function useSyncedChain() {
  // 从 wagmi 获取基础状态
  const configChainId = useChainId(); // wagmi 配置中的链 ID
  const { chain: accountChain, isConnected } = useAccount(); // 钱包实际连接的链
  const { switchChain, isPending, error } = useSwitchChain();
  const publicClient = usePublicClient(); // 用来做链验证

  // 我们自己的"权威"链 ID
  const [activeChainId, setActiveChainId] = useState<number>(configChainId);

  // 核心逻辑:同步钱包状态和配置状态
  useEffect(() => {
    if (!isConnected || !accountChain) {
      // 未连接时,使用配置默认链
      setActiveChainId(configChainId);
      return;
    }

    // 如果钱包连接的链和配置链不同,说明用户手动切换了
    if (accountChain.id !== configChainId) {
      // 这里有个坑:不要直接 setActiveChainId,因为配置链可能不支持
      // 应该检查 accountChain 是否在我们支持的链列表中
      const supportedChains = [1, 137, 42161]; // Ethereum, Polygon, Arbitrum
      if (supportedChains.includes(accountChain.id)) {
        setActiveChainId(accountChain.id);
      } else {
        // 不支持的话,尝试切回默认链
        switchChain({ chainId: configChainId });
      }
    } else {
      setActiveChainId(configChainId);
    }
  }, [configChainId, accountChain, isConnected, switchChain]);

  // 封装的切换函数
  const switchToChain = useCallback(async (targetChainId: number) => {
    try {
      await switchChain({ chainId: targetChainId });
      // switchChain 成功后,wagmi 会自动更新 configChainId
      // 但为了保险,我们手动更新
      setActiveChainId(targetChainId);
    } catch (err) {
      console.error('切换链失败:', err);
      throw err;
    }
  }, [switchChain]);

  return {
    activeChainId,
    switchToChain,
    isSwitching: isPending,
    error,
  };
}

这个 hook 的核心思路是:不要信任任何一个单一来源,而是用钱包状态、配置状态、用户操作事件三者做交叉验证

2. 处理链切换后的数据刷新

链切换后,我们需要重新获取交易对数据、用户余额等。一开始我用 useEffect 监听 activeChainId,但发现会触发两次:一次是状态更新,一次是钱包实际切换完成。

后来我用了 wagmi 的 useWatchChainId 来做精细控制:

// hooks/useChainDataRefresh.ts
import { useEffect, useRef } from 'react';
import { useChainId } from 'wagmi';

export function useChainDataRefresh(callback: (chainId: number) => void) {
  const chainId = useChainId();
  const prevChainIdRef = useRef(chainId);

  useEffect(() => {
    // 只在链真正变化时触发,避免初始化时的重复调用
    if (prevChainIdRef.current !== chainId) {
      console.log(`链已切换: ${prevChainIdRef.current} -> ${chainId}`);
      callback(chainId);
      prevChainIdRef.current = chainId;
    }
  }, [chainId, callback]);
}

然后在组件中使用:

// 在 Swap 组件中
const { activeChainId, switchToChain, isSwitching } = useSyncedChain();
const { data: pairData, refetch: refetchPair } = useQuery({
  queryKey: ['pair', activeChainId, tokenA, tokenB],
  queryFn: () => fetchPairData(activeChainId, tokenA, tokenB),
  enabled: !!activeChainId && !!tokenA && !!tokenB,
});

useChainDataRefresh((newChainId) => {
  // 链切换后,重新获取数据
  refetchPair();
  // 同时重置用户输入状态
  setTokenA('');
  setTokenB('');
});

3. 处理钱包手动切换和 UI 同步

最头疼的是用户手动在 MetaMask 里切换链。wagmi v2 的 useAccount 会更新,但 useChainId 不会。我之前的 useSyncedChain hook 已经通过 accountChain 处理了这种情况,但还有一个细节:切换完成后,需要等待钱包确认,期间 UI 应该显示加载状态

我添加了一个"切换中"的状态管理:

// 在 useSyncedChain 中增加 pendingChainId
const [pendingChainId, setPendingChainId] = useState<number | null>(null);

const switchToChain = useCallback(async (targetChainId: number) => {
  setPendingChainId(targetChainId);
  try {
    await switchChain({ chainId: targetChainId });
    setPendingChainId(null);
    setActiveChainId(targetChainId);
  } catch (err) {
    setPendingChainId(null);
    throw err;
  }
}, [switchChain]);

// 在 UI 中显示加载
const isLoading = isSwitching || pendingChainId !== null;

4. 最终的多链切换组件

把所有逻辑整合到一个组件中:

// components/ChainSwitcher.tsx
import { useSyncedChain } from '../hooks/useSyncedChain';
import { useChainDataRefresh } from '../hooks/useChainDataRefresh';
import { useQuery } from '@tanstack/react-query';
import { useEffect } from 'react';

const SUPPORTED_CHAINS = [
  { id: 1, name: 'Ethereum', nativeCurrency: 'ETH' },
  { id: 137, name: 'Polygon', nativeCurrency: 'MATIC' },
  { id: 42161, name: 'Arbitrum', nativeCurrency: 'ETH' },
];

export function ChainSwitcher() {
  const { activeChainId, switchToChain, isSwitching, error } = useSyncedChain();

  // 链切换后刷新数据
  useChainDataRefresh((chainId) => {
    console.log('链已切换,刷新数据');
    // 这里可以触发其他数据获取
  });

  const handleChainClick = async (chainId: number) => {
    if (chainId === activeChainId) return;
    try {
      await switchToChain(chainId);
      // 切换成功后,UI 会自动更新,因为 activeChainId 变了
    } catch (err) {
      // 显示错误 toast
      alert(`切换失败: ${(err as Error).message}`);
    }
  };

  return (
    <div>
      <h2>选择链</h2>
      {SUPPORTED_CHAINS.map((chain) => (
        <button
          key={chain.id}
          onClick={() => handleChainClick(chain.id)}
          disabled={isSwitching}
          style={{
            fontWeight: chain.id === activeChainId ? 'bold' : 'normal',
            opacity: isSwitching ? 0.5 : 1,
          }}
        >
          {chain.name} ({chain.nativeCurrency})
          {isSwitching && ' 切换中...'}
        </button>
      ))}
      {error && <p style={{ color: 'red' }}>错误: {error.message}</p>}
    </div>
  );
}

完整代码

我把所有代码整合到一个可运行的示例中。假设你使用 Vite + React + TypeScript,安装依赖:

npm install wagmi viem @tanstack/react-query react
// main.tsx - 入口文件
import { WagmiProvider, createConfig, http } from 'wagmi';
import { mainnet, polygon, arbitrum } from 'wagmi/chains';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RainbowKitProvider, getDefaultConfig } from '@rainbow-me/rainbowkit';
import { ChainSwitcher } from './components/ChainSwitcher';

const config = createConfig({
  chains: [mainnet, polygon, arbitrum],
  transports: {
    [mainnet.id]: http(),
    [polygon.id]: http(),
    [arbitrum.id]: http(),
  },
});

const queryClient = new QueryClient();

function App() {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider>
          <ChainSwitcher />
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

export default App;
// hooks/useSyncedChain.ts - 上面已给出完整代码
// hooks/useChainDataRefresh.ts - 上面已给出完整代码
// components/ChainSwitcher.tsx - 上面已给出完整代码

踩坑记录

坑 1:useAccountchain 在切换后不会立即更新 现象:调用 switchChain 后,useAccount 返回的 chain 还是旧的,导致 UI 显示错误。解决:用 useChainId 配合 useEffect 监听,而不是依赖 useAccountchain

坑 2:useSwitchChainisSuccess 有时为 false 现象:钱包已经切换成功,但 isSuccess 一直是 false。原因:wagmi v2 中 isSuccess 只在第一次成功时为 true,后续切换不会重置。解决:用 errorisPending 做判断,或者自己维护状态。

坑 3:在非浏览器环境(如测试时)调用 switchChain 会报错 现象:在 Node.js 或 React Native 中,window.ethereum 不存在,导致切换失败。解决:用 try-catch 包裹,并在错误时回退到配置默认链。

坑 4:链切换后,之前订阅的事件没有清理 现象:切换到 Polygon 后,Ethereum 上的事件监听还在运行,导致内存泄漏。解决:在 useEffect 中返回清理函数,或者用 wagmi 的 watchContractEvent 自动管理。

小结

多链切换的核心不是调用 switchChain,而是同步钱包状态、配置状态和用户操作状态。wagmi v2 提供了基础工具,但需要自己组合成可靠的解决方案。如果你也遇到类似问题,可以试试我写的 useSyncedChain hook,或者深入看看 wagmi 的源码——里面有很多有趣的细节。

接下来,你可以探索如何用 wagmi 的 watchChainId 做更精细的控制,或者结合 viem 的 publicClient 做链验证。

昨天以前首页

用 wagmi v2 + WebSocket 硬磕 NFT 上架失败:一个前端开发者踩过的实时状态同步坑

作者 竹林818
2026年4月30日 18:01

背景:一个让我抓狂的 NFT 上架问题

去年秋天,我在帮一个 NFT 交易市场做前端重构。项目用的是 Next.js 14 App Router,合约是 OpenSea 兼容的 Seaport 协议,但前端完全不依赖 OpenSea SDK——因为我们自己改了上架逻辑,用了个简化版的 ListOrder 函数。用户上架 NFT 时,先调用合约的 approve,再调用 createOrder,然后前端得立刻显示这个 NFT 已经上架,状态从“可出售”变成“已挂单”。

问题来了:用户用 MetaMask 确认交易后,前端列表死活不更新。我一开始想,那简单,交易确认后重新 fetch 一下用户挂单列表不就行了?但我天真了——用户上架后,合约事件还没被索引服务(比如 The Graph)同步,直接调 RPC 查 userOrders 映射,返回的还是空数组。更糟的是,用户连续上架两个 NFT,第一个刚确认第二个又触发,状态全乱套。

当时项目排期紧,PM 每天问我“上架按钮点了怎么没反应”,我嘴上说“链上确认需要时间”,心里知道这锅不能全甩给区块链。我必须找到一个方案:用户钱包确认交易后,前端能实时监听到 OrderCreated 事件,然后自动刷新列表,而不是靠用户手动刷新页面。

问题分析:轮询为什么不行?

我的第一版方案很简单:用 ethers.jsprovider.on("block") 监听新区块,每出一个块就去查一次 userOrders。代码大概长这样:

// 第一版:轮询方案(已废弃)
const provider = new ethers.providers.Web3Provider(window.ethereum);
const contract = new ethers.Contract(MARKET_ADDRESS, MARKET_ABI, provider);

useEffect(() => {
  const handleBlock = async (blockNumber: number) => {
    const orders = await contract.getUserOrders(userAddress);
    setOrders(orders);
  };
  provider.on("block", handleBlock);
  return () => provider.off("block", handleBlock);
}, [userAddress]);

这个方案有几个致命问题:

  1. 延迟太高:以太坊平均出块时间 12 秒,用户上架后要等 12 秒才能看到变化。如果用户连着上架两个,第一个还没被索引第二个就触发了,列表会回滚到中间状态。
  2. RPC 调用次数爆炸:每个区块都调 RPC,如果用户页面开着不动,一小时调几百次,Infura 直接限流。我们项目用的 Alchemy 免费层,没几天就超量了。
  3. React 18 严格模式useEffect 在开发环境会执行两次,导致事件监听注册两次,然后清理函数只执行一次,最后监听器泄漏。我当时在控制台看到一堆 Event listener added 警告,查了半天才发现是严格模式的锅。

后来我试过用 setInterval 每 5 秒轮询,但用户体验更差——列表明明没变化,却在不停闪烁。而且用户钱包切换链时,旧的 provider 没清理,监听还在老链上跑,数据全错。

核心实现:从轮询到事件驱动的迁移

第一步:用 wagmi 的 useWatchContractEvent 替换轮询

我决定彻底放弃 ethers.js 的轮询方案,改用 wagmi v2 的事件监听。wagmi 的 useWatchContractEvent 本质上是对 eth_subscribe 的封装,通过 WebSocket 直接监听合约事件,不用自己管理 provider 和清理逻辑。

先安装 wagmi v2 和 viem:

npm install wagmi viem @tanstack/react-query

然后配置 wagmi 客户端。这里有个坑:wagmi v2 默认用 HTTP 传输,要启用 WebSocket 监听事件,必须显式指定 transports 为 WebSocket 地址。

// lib/wagmi.ts
import { createConfig, http, webSocket } from 'wagmi';
import { mainnet, sepolia } from 'wagmi/chains';

export const config = createConfig({
  chains: [mainnet, sepolia],
  transports: {
    [mainnet.id]: webSocket('wss://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY'),
    [sepolia.id]: webSocket('wss://eth-sepolia.g.alchemy.com/v2/YOUR_API_KEY'),
  },
});

注意这个细节:如果你同时需要 HTTP 请求(比如读合约状态),可以在 transports 里用 fallback

transports: {
  [mainnet.id]: fallback([
    webSocket('wss://...'),
    http('https://...'),
  ]),
}

这样 wagmi 会优先用 WebSocket,如果断开自动降级到 HTTP。我当时没加这个,结果 WebSocket 偶尔断连后,所有事件监听都失效了,页面直接卡死。

第二步:实现上架表单 + 实时监听

核心组件 ListNFTForm:用户选择 NFT,输入价格,点击上架。上架成功后,自动监听 OrderCreated 事件更新列表。

// components/ListNFTForm.tsx
'use client';
import { useState } from 'react';
import { useAccount, useWriteContract, useWatchContractEvent } from 'wagmi';
import { parseEther } from 'viem';
import { MARKET_ABI, MARKET_ADDRESS } from '@/lib/contract';

export default function ListNFTForm() {
  const { address } = useAccount();
  const [tokenId, setTokenId] = useState('');
  const [price, setPrice] = useState('');
  const [isPending, setIsPending] = useState(false);

  // 1. 写合约:上架 NFT
  const { writeContractAsync } = useWriteContract();

  const handleList = async () => {
    if (!tokenId || !price) return;
    setIsPending(true);
    try {
      const tx = await writeContractAsync({
        address: MARKET_ADDRESS,
        abi: MARKET_ABI,
        functionName: 'createOrder',
        args: [BigInt(tokenId), parseEther(price)],
      });
      // 这里不立刻刷新列表,等事件回调
      console.log('交易已发送:', tx);
    } catch (err) {
      console.error('上架失败:', err);
    } finally {
      setIsPending(false);
    }
  };

  // 2. 实时监听 OrderCreated 事件
  const [recentOrders, setRecentOrders] = useState<bigint[]>([]);

  useWatchContractEvent({
    address: MARKET_ADDRESS,
    abi: MARKET_ABI,
    eventName: 'OrderCreated',
    // 只监听当前用户的事件
    args: { seller: address },
    onLogs(logs) {
      // 这里有个坑:logs 可能包含多个事件,需要过滤
      const newTokenIds = logs
        .filter(log => log.args.seller === address)
        .map(log => log.args.tokenId);
      setRecentOrders(prev => [...new Set([...newTokenIds, ...prev])]);
    },
  });

  return (
    <div>
      <input value={tokenId} onChange={e => setTokenId(e.target.value)} placeholder="Token ID" />
      <input value={price} onChange={e => setPrice(e.target.value)} placeholder="价格 (ETH)" />
      <button onClick={handleList} disabled={isPending}>
        {isPending ? '上架中...' : '上架 NFT'}
      </button>
      <h3>最近上架的 NFT (ID):</h3>
      <ul>
        {recentOrders.map(id => <li key={id}>{id.toString()}</li>)}
      </ul>
    </div>
  );
}

这里有个坑useWatchContractEventargs 过滤参数,在 wagmi v2 中只支持精确匹配,不支持 undefined 表示“不过滤”。如果你写成 args: { seller: undefined },它会直接报错。所以要么不传 args(监听所有事件),要么传具体的地址。我当时传了 seller: address,但用户刚连接钱包时 addressundefined,导致监听器注册失败。后来我加了个条件:

const { address } = useAccount();
// 只有 address 存在时才注册监听
const enabled = !!address;
useWatchContractEvent({
  address: MARKET_ADDRESS,
  abi: MARKET_ABI,
  eventName: 'OrderCreated',
  args: enabled ? { seller: address } : undefined,
  onLogs(logs) { /* ... */ },
  enabled, // wagmi v2 支持这个选项
});

第三步:处理跨链和多钱包切换

NFT 市场通常支持多链。用户如果在 Sepolia 上架,然后切换到 Ethereum Mainnet,之前的事件监听必须清理。wagmi 的 useWatchContractEvent 会自动根据当前链切换,但有个问题:它不会清理旧链上的订阅。

我踩了个坑:用户在 Sepolia 上架了一个 NFT,然后切换到 Mainnet,Mainnet 上也监听到了 OrderCreated 事件——因为 WebSocket 连接还在老链上跑。后来发现是 wagmi 的 webSocket 传输在切换链时不会自动断开旧连接。

解决方案:在组件卸载或链切换时手动清理。但 wagmi v2 没有暴露 unwatch 方法,我只好用 useEffect 的清理函数配合 useChainId

import { useChainId } from 'wagmi';

export default function ListNFTForm() {
  const chainId = useChainId();

  // 每次链变化时,重新挂载组件
  useEffect(() => {
    // 清理旧状态
    setRecentOrders([]);
  }, [chainId]);

  // ... 其余代码
}

这个方案不完美,但至少能保证链切换后列表清空,不会显示错误的数据。更优雅的方案是用 wagmi 的 useSyncExternalStore 自己写一个订阅管理器,但对我们项目来说够用了。

第四步:处理交易确认和事件延迟

用户上架后,writeContractAsync 返回的是交易哈希,不是确认状态。useWatchContractEvent 在交易被打包进区块时就触发,但此时交易可能还没被最终确认(比如 L2 的即时确认 vs L1 的 12 秒)。如果列表在交易刚打包时就更新,用户看到 NFT 已经上架,但几秒后区块重组,事件消失,列表又变回未上架状态。

我当时的解决方案:在 onLogs 回调里不直接更新状态,而是先存到本地,等交易确认后再正式更新。但这样太复杂,而且 wagmi 的 useWaitForTransactionReceipt 可以解决这个问题。

最终方案:把事件监听和交易确认分开。用户上架后,用 useWaitForTransactionReceipt 等待交易确认,确认后再主动查一次列表。同时事件监听作为辅助,提前展示“上架中”的占位状态。

// 等待交易确认
const { data: receipt } = useWaitForTransactionReceipt({
  hash: txHash, // 从 writeContractAsync 返回的哈希
});

useEffect(() => {
  if (receipt) {
    // 交易已确认,重新查询列表
    refetchOrders();
  }
}, [receipt]);

这样用户体验更好:上架后立刻看到占位,交易确认后列表自动刷新,事件监听作为实时更新的补充。

完整代码:一个可运行的 NFT 上架模块

我把上述逻辑整合成一个完整的组件,可以直接复制到 Next.js 14 项目中运行。前提是你已经配好了 wagmi 客户端(参考前面的 lib/wagmi.ts)。

// components/NFTMarketplace.tsx
'use client';
import { useState, useEffect } from 'react';
import { useAccount, useChainId, useWriteContract, useWatchContractEvent, useWaitForTransactionReceipt } from 'wagmi';
import { parseEther } from 'viem';
import { MARKET_ABI, MARKET_ADDRESS } from '@/lib/contract';

export default function NFTMarketplace() {
  const { address, isConnected } = useAccount();
  const chainId = useChainId();
  
  // 上架表单状态
  const [tokenId, setTokenId] = useState('');
  const [price, setPrice] = useState('');
  const [txHash, setTxHash] = useState<`0x${string}` | undefined>(undefined);
  
  // 订单列表状态
  const [orders, setOrders] = useState<Array<{ tokenId: bigint; price: bigint; seller: string }>>([]);
  
  // 写合约
  const { writeContractAsync } = useWriteContract();
  
  // 等待交易确认
  const { isLoading: isConfirming, isSuccess: isConfirmed } = useWaitForTransactionReceipt({
    hash: txHash,
  });
  
  // 交易确认后刷新列表
  useEffect(() => {
    if (isConfirmed) {
      setTxHash(undefined);
      // 这里可以调用 RPC 重新查询列表
      // 但为了演示,我们直接用事件数据
    }
  }, [isConfirmed]);
  
  // 实时监听 OrderCreated 事件
  const enabled = !!address && !!isConnected;
  useWatchContractEvent({
    address: MARKET_ADDRESS,
    abi: MARKET_ABI,
    eventName: 'OrderCreated',
    args: enabled ? { seller: address } : undefined,
    onLogs(logs) {
      const newOrders = logs.map(log => ({
        tokenId: log.args.tokenId as bigint,
        price: log.args.price as bigint,
        seller: log.args.seller as string,
      }));
      setOrders(prev => [...newOrders, ...prev]);
    },
    enabled,
  });
  
  // 上架处理
  const handleList = async () => {
    if (!tokenId || !price || !address) return;
    try {
      const hash = await writeContractAsync({
        address: MARKET_ADDRESS,
        abi: MARKET_ABI,
        functionName: 'createOrder',
        args: [BigInt(tokenId), parseEther(price)],
      });
      setTxHash(hash);
    } catch (err) {
      console.error('上架失败:', err);
    }
  };
  
  // 链切换时清空列表
  useEffect(() => {
    setOrders([]);
  }, [chainId]);
  
  if (!isConnected) return <div>请连接钱包</div>;
  
  return (
    <div style={{ padding: '20px' }}>
      <h2>NFT 上架</h2>
      <div>
        <input
          value={tokenId}
          onChange={e => setTokenId(e.target.value)}
          placeholder="Token ID"
          style={{ marginRight: '8px' }}
        />
        <input
          value={price}
          onChange={e => setPrice(e.target.value)}
          placeholder="价格 (ETH)"
          style={{ marginRight: '8px' }}
        />
        <button onClick={handleList} disabled={isConfirming}>
          {isConfirming ? '确认中...' : '上架'}
        </button>
      </div>
      
      <h3 style={{ marginTop: '30px' }}>我的挂单</h3>
      {orders.length === 0 ? (
        <p>暂无挂单</p>
      ) : (
        <ul>
          {orders.map((order, i) => (
            <li key={i}>
              Token #{order.tokenId.toString()} - {order.price.toString()} wei
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

这个组件可以直接放到 Next.js 14 的 app/page.tsx 里运行,前提是你已经配置好 wagmi provider 和合约地址。

踩坑记录

  1. WebSocket 连接泄漏:wagmi v2 的 useWatchContractEvent 在组件卸载时不会自动断开 WebSocket。我在开发环境频繁热更新,控制台看到 WebSocket connection to 'wss://...' failed。解决方式是在 lib/wagmi.ts 里用 fallback 确保 HTTP 降级,同时在组件里用 enabled 控制只在需要时监听。

  2. React 18 严格模式导致事件重复useWatchContractEvent 在严格模式下会注册两次监听,但 wagmi 内部做了去重,所以不会触发两次回调。但 onLogs 回调里的状态更新会触发两次渲染。我在 setOrders 里用了函数式更新 prev => [...newOrders, ...prev],避免了重复添加。

  3. 事件参数类型不匹配:合约事件定义 OrderCreated(uint256 tokenId, uint256 price, address seller),但 wagmi 的 log.args 返回的是 unknown 类型。我需要手动断言 log.args.tokenId as bigint。后来发现用 viemdecodeEventLog 可以更安全地解析。

  4. 链切换后事件监听不更新:用户从 Sepolia 切到 Mainnet,useWatchContractEvent 会重新注册,但 wagmi 的 webSocket 传输不会自动断开旧链的连接。我加了个 useEffect 监听 chainId,变化时清空列表,但更好的做法是用 wagmi 的 useDisconnect 手动清理。

小结

从 ethers.js 轮询到 wagmi 事件驱动,核心收获是:Web3 前端的状态同步,不要靠定时器,要用合约事件驱动。wagmi v2 的 useWatchContractEvent 封装了 WebSocket 订阅和清理逻辑,但要注意链切换、严格模式和类型断言这些细节。如果你想继续深挖,可以研究 wagmi 的 useSyncExternalStoreviemcreateEventFilter,实现更精细的事件过滤和批量处理。

Web3表单签名验证:我如何用 wagmi 和 siwe 让用户“无密码”登录

作者 竹林818
2026年4月25日 10:00

背景:用户提交地址,后端凭什么相信?

几个月前,我在做一个 DeFi 策略管理平台的前端。用户可以在上面创建“自动复投”策略,然后通过我们的合约执行。流程很简单:前端收集用户输入的策略参数(比如目标池地址、复投频率),然后调用合约。

但问题出在“用户身份”上。后端需要记录每个用户创建了哪些策略,但用户并没有注册流程,也没有密码。他们只是连接了钱包(MetaMask 或 WalletConnect),然后直接操作。后端收到的请求里,用户传一个 userAddress 字段,比如 0x1234...

我当时就想:这太不安全了。如果某个恶意用户伪造一个请求,把 userAddress 改成别人的地址,后端怎么知道这个地址真的是当前操作者?更糟的是,我们的后端还依赖这个地址来查询用户的历史数据,如果地址被篡改,数据就全乱了。

我需要一种方法:让后端能够验证“当前请求确实来自某个地址的持有者”,而且这个过程不能依赖密码,必须完全基于区块链钱包的签名机制。

问题分析:为什么简单的签名不行?

我最初的想法很简单:让前端用 ethers.js 对一段固定字符串签名,然后把签名和地址一起发给后端,后端用 ethers.utils.verifyMessage 验证。

// 最初的错误思路
const message = "I am the owner of this address";
const signature = await signer.signMessage(message);
// 然后发 { address, signature } 给后端

这看起来没问题,但实际跑起来就发现一堆坑:

  1. 重放攻击:如果签名被截获,攻击者可以重复使用这个签名来冒充用户。因为消息是固定的,签名永远有效。
  2. 过期问题:没有时间戳,后端不知道这个签名是什么时候签的。如果用户忘记断开连接,别人拿到这个签名可以一直用。
  3. 跨域问题:如果用户在不同 dApp 上签名了同样的消息,攻击者可以拿到签名后在我们的后端使用。

我当时就踩了这个坑:上线第一天,团队安全审计就说“这个方案不能上线,太脆弱了”。后来我才知道,社区早就有一个标准解决方案——EIP-4361,也就是“Sign-In with Ethereum”(SIWE)。

核心实现:用 siwe 构造防重放签名

SIWE 的核心思想是:把签名消息变成一个结构化的对象,包含 domain(域名)、uri(当前页面)、nonce(随机数)、issuedAt(签发时间)等字段。这样每个签名都是唯一的、有时效的、绑定到特定网站的。

我选择了 siwe 这个 npm 包,配合 wagmi v2 的 useSignMessage hook 来实现。

第一步:前端生成 nonce 并让用户签名

这里有个关键点:nonce 必须由后端生成,否则前端自己生成的 nonce 没有意义。所以我先向后端请求一个 nonce。

// 1. 从后端获取 nonce
const getNonce = async (): Promise<string> => {
  const res = await fetch('/api/auth/nonce');
  const data = await res.json();
  return data.nonce;
};

// 2. 构造 SIWE 消息
import { SiweMessage } from 'siwe';
import { useSignMessage, useAccount } from 'wagmi';

function LoginButton() {
  const { address, chainId } = useAccount();
  const { signMessageAsync } = useSignMessage();

  const handleLogin = async () => {
    if (!address || !chainId) return;

    // 注意:domain 必须和你的前端域名一致,否则验证会失败
    const domain = window.location.host;
    const origin = window.location.origin;

    const nonce = await getNonce();

    const siweMessage = new SiweMessage({
      domain,
      address,
      statement: 'Sign in to DeFi Dashboard to manage your strategies.',
      uri: origin,
      version: '1',
      chainId,
      nonce,
      issuedAt: new Date().toISOString(),
    });

    const message = siweMessage.prepareMessage();
    const signature = await signMessageAsync({ message });

    // 发送给后端验证
    const verifyRes = await fetch('/api/auth/verify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ message, signature }),
    });

    if (verifyRes.ok) {
      // 登录成功,后端返回一个 session token
      const { token } = await verifyRes.json();
      localStorage.setItem('auth_token', token);
    }
  };

  return <button onClick={handleLogin}>Sign in with Ethereum</button>;
}

这里有个坑domain 字段必须精确匹配。我当时在本地开发时用的是 localhost:3000,但部署后域名变成了 app.example.com,结果生产环境一直报 Domain mismatch。后来我把 domain 从 window.location.host 获取,问题解决。

第二步:后端验证签名并创建 session

后端我用 Node.js + Express 实现,使用 siwe 包进行验证。验证通过后,我生成一个 JWT token 返回给前端,后续的 API 请求都带上这个 token。

// 后端:验证签名
import { SiweMessage } from 'siwe';
import express from 'express';

const app = express();
app.use(express.json());

// 存储 nonce(生产环境应该用 Redis)
const nonceStore: Set<string> = new Set();

// 生成 nonce 接口
app.get('/api/auth/nonce', (req, res) => {
  const nonce = generateRandomNonce(); // 使用 crypto.randomBytes 生成
  nonceStore.add(nonce);
  // 设置过期时间,比如 5 分钟
  setTimeout(() => nonceStore.delete(nonce), 5 * 60 * 1000);
  res.json({ nonce });
});

// 验证签名接口
app.post('/api/auth/verify', async (req, res) => {
  const { message, signature } = req.body;

  try {
    const siweMessage = new SiweMessage(message);
    const fields = await siweMessage.verify({
      signature,
      // 这里传入 nonce 是为了验证 nonce 是否有效
      nonce: siweMessage.nonce,
      // 这里传入 domain 是为了验证域名
      domain: siweMessage.domain,
    });

    // 验证成功后,从存储中删除 nonce,防止重放
    nonceStore.delete(siweMessage.nonce);

    // 生成 JWT token
    const token = jwt.sign(
      { address: fields.data.address, chainId: fields.data.chainId },
      process.env.JWT_SECRET,
      { expiresIn: '24h' }
    );

    res.json({ token });
  } catch (error) {
    console.error('Verification failed:', error);
    res.status(401).json({ error: 'Invalid signature' });
  }
});

注意这个细节siweMessage.verify 方法内部会自动检查 nonce、domain、过期时间等。如果 nonce 已经被使用过(比如重放攻击),就会抛出异常。我一开始没理解这个机制,以为需要手动检查,后来发现包已经帮我做了。

第三步:session 持久化与自动登录

用户每次刷新页面都要重新签名,体验很差。所以我用 JWT token 做 session 持久化。前端在初始化时检查 localStorage 中是否有 token,如果有就自动恢复登录状态。

// 封装一个 hook 来管理认证状态
import { useAccount, useDisconnect } from 'wagmi';
import { useState, useEffect } from 'react';

export function useAuth() {
  const { address, isConnected } = useAccount();
  const { disconnect } = useDisconnect();
  const [isAuthenticated, setIsAuthenticated] = useState(false);

  // 检查是否有有效的 token
  useEffect(() => {
    const token = localStorage.getItem('auth_token');
    if (token && address) {
      // 可以验证 token 是否过期(简单做法:解码 payload 检查 exp)
      setIsAuthenticated(true);
    }
  }, [address]);

  // 登出
  const logout = () => {
    localStorage.removeItem('auth_token');
    setIsAuthenticated(false);
    disconnect();
  };

  return { isAuthenticated, logout };
}

这里有个坑:JWT token 过期后,用户需要重新签名。我最初没有处理 token 过期的情况,结果用户操作到一半突然报 401 错误,体验非常糟糕。后来我加了一个“静默刷新”机制:在 API 请求拦截器中检查 token 是否即将过期,如果是,就弹出一个轻提示让用户重新签名。

完整代码:一个可运行的 React 组件

下面是一个完整的登录组件,包含签名验证和 session 管理。假设你已经配置好了 wagmi 的 provider。

// LoginWithSiwe.tsx
import { useState } from 'react';
import { useAccount, useSignMessage, useDisconnect } from 'wagmi';
import { SiweMessage } from 'siwe';
import { useAuth } from './useAuth';

export default function LoginWithSiwe() {
  const { address, isConnected, chainId } = useAccount();
  const { signMessageAsync } = useSignMessage();
  const { disconnect } = useDisconnect();
  const { isAuthenticated, logout } = useAuth();
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');

  const handleLogin = async () => {
    if (!address || !chainId) {
      setError('Please connect your wallet first');
      return;
    }

    setLoading(true);
    setError('');

    try {
      // 1. 获取 nonce
      const nonceRes = await fetch('/api/auth/nonce');
      const { nonce } = await nonceRes.json();

      // 2. 构造 SIWE 消息
      const message = new SiweMessage({
        domain: window.location.host,
        address,
        statement: 'Sign in to access your dashboard.',
        uri: window.location.origin,
        version: '1',
        chainId,
        nonce,
        issuedAt: new Date().toISOString(),
      });

      // 3. 签名
      const signature = await signMessageAsync({
        message: message.prepareMessage(),
      });

      // 4. 发送给后端验证
      const verifyRes = await fetch('/api/auth/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          message: message.prepareMessage(),
          signature,
        }),
      });

      if (!verifyRes.ok) {
        throw new Error('Verification failed');
      }

      const { token } = await verifyRes.json();
      localStorage.setItem('auth_token', token);
      // 触发状态更新
      window.location.reload();
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Login failed');
    } finally {
      setLoading(false);
    }
  };

  if (isAuthenticated) {
    return (
      <div>
        <p>Logged in as: {address?.slice(0, 6)}...{address?.slice(-4)}</p>
        <button onClick={logout}>Logout</button>
      </div>
    );
  }

  return (
    <div>
      {!isConnected ? (
        <p>Please connect your wallet first</p>
      ) : (
        <button onClick={handleLogin} disabled={loading}>
          {loading ? 'Signing...' : 'Sign in with Ethereum'}
        </button>
      )}
      {error && <p style={{ color: 'red' }}>{error}</p>}
    </div>
  );
}

踩坑记录

  1. Domain mismatch 错误:在本地开发时,domain 是 localhost:3000,但部署到生产环境后,domain 变成了 app.example.com。SIWE 验证要求 domain 必须精确匹配前端域名。解决方案:使用 window.location.host 动态获取 domain。

  2. Nonce already used 错误:第一次测试时,我在短时间内连续点击登录按钮,结果第二次签名时后端报错。原因是 nonce 只能使用一次,我忘记在验证成功后删除 nonce。解决方案:在验证成功后立即从存储中删除 nonce。

  3. 签名弹窗不显示:使用 wagmi 的 useSignMessage 时,如果用户已经连接了钱包,但 MetaMask 不弹出签名窗口。后来发现是因为我传入了 { message } 而不是 { message: siweMessage.prepareMessage() }prepareMessage() 方法会把结构化消息转成符合 EIP-4361 格式的字符串,MetaMask 才能正确识别。

  4. JWT token 过期后用户无感知:用户登录后,如果 token 过期了,API 请求会返回 401,但前端没有提示。解决方案:在 API 请求拦截器中检查 token 的 exp 字段,如果即将过期,提前弹出提示让用户重新签名。

小结

通过 EIP-4361 + SIWE,我成功实现了一套无需密码、基于钱包签名的身份认证方案。核心收获是:不要自己造轮子,社区标准方案(SIWE)已经解决了重放攻击、过期、跨域等问题。如果想继续深挖,可以研究 EIP-4361 的扩展(如 EIP-5573),或者结合 SIWE 实现更细粒度的权限控制。

❌
❌