用 wagmi v2 踩坑两天,我终于搞懂了多链钱包切换在 DeFi 前端中的正确姿势

用 wagmi v2 踩坑两天,我终于搞懂了多链钱包切换在 DeFi 前端中的正确姿势

摘要

做 DeFi 聚合器前端时,用户从以太坊切到 Polygon,合约调用直接炸了。我花了两天时间排查,发现是链状态、账户地址和交易签名这三块没同步。这篇文章是我真实的踩坑记录,从问题定位到用 wagmi v2 重构,每一步都有代码和注释,希望能帮你少走弯路。

背景

我最近在做一个 DeFi 聚合器项目,用户可以在一个界面上把资产跨链存入不同协议的流动性池。比如,用户先在以太坊上存 USDC 到 Aave,然后切换到 Polygon 存入 QuickSwap。听起来挺酷,但实现起来全是坑。

一开始我用的是 wagmi v1 + ethers.js,配合一个自己写的链切换工具。项目跑在测试网上,单链功能没问题,但一旦用户手动切换钱包网络(比如从 MetaMask 把以太坊主网换成 Polygon),整个前端就乱套了:合约调用返回"链 ID 不匹配",交易签名弹窗里显示的 gas 费单位是错的,甚至有时候地址都变成 0x000... 用户反馈说"点一下切换,页面就卡死了"。

我意识到,多链切换不是简单地改个 RPC URL 就能解决的。它涉及到链状态、账户状态、合约实例、交易参数四个维度的同步。我必须找到一个稳定的解决方案。当时正好 wagmi v2 发布,我决定用它的 createConfigSwitchChain 来重构整个钱包连接层。

问题分析

我最初的思路很简单:在 React Context 里维护一个 currentChainId 状态,每当用户切换链,就更新这个状态,然后重新创建 ethers 的 provider、signer 和合约实例。代码大致像这样:

typescript 复制代码
const [chainId, setChainId] = useState(1);
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
const contract = new ethers.Contract(address, abi, signer);

问题出在哪里?第一,window.ethereum 是 MetaMask 注入的全局对象,它本身是单例的,但链切换后,provider 不会自动更新。第二,用户可能在切换链的同时切换账户(比如从账户 A 切到账户 B),这两件事是异步的,状态更新顺序不可控。第三,wagmi v1 的 useAccountuseNetwork 是分开的 Hook,我很难在同一个地方拿到最新的 chainId 和 address。

结果就是:用户先切链,前端还没反应过来,合约调用用的还是旧链的 provider,直接报错。或者,用户切了账户,但合约实例用的还是旧 signer,签名时弹窗显示旧地址。

我当时排查了两天,打印了无数 console.log,发现核心问题是"状态同步滞后"。wagmi v1 的 useProvideruseSigner 返回的是基于当前链的实例,但它们在链切换后不会立即更新,需要等待下一个渲染周期。而我的合约调用是同步触发的,所以总是拿到旧实例。

核心实现

第一步:用 wagmi v2 的 createConfig 统一管理多链

wagmi v2 最大的变化是引入了 createConfig,它把链、钱包、连接器全部集中管理。我首先定义了两个链:以太坊和 Polygon,然后创建配置。

注意这个细节:transports 必须为每个链单独指定 RPC URL,否则 wagmi 会默认用公共 RPC,流量大时容易超时。

typescript 复制代码
import { createConfig, http } from 'wagmi';
import { mainnet, polygon } from 'wagmi/chains';
import { metaMask } from 'wagmi/connectors';

// 这里有个坑:必须为每个链单独指定 transport,不能共用
export const config = createConfig({
  chains: [mainnet, polygon],
  connectors: [metaMask()],
  transports: {
    [mainnet.id]: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY'),
    [polygon.id]: http('https://polygon-mainnet.g.alchemy.com/v2/YOUR_KEY'),
  },
});

然后,在 React 组件树的顶层包裹 WagmiProvider

typescript 复制代码
import { WagmiProvider } from 'wagmi';

function App() {
  return (
    <WagmiProvider config={config}>
      <YourComponent />
    </WagmiProvider>
  );
}

这样,wagmi 内部会自动维护链和连接器的状态,我们不需要手动管理 provider。

第二步:用 useAccount 和 useChainId 实时获取最新状态

这是最关键的改进。wagmi v2 的 useAccount 返回的 addresschainId 是同步的,不会出现地址和链不匹配的情况。useChainId 则直接返回当前链 ID,省去了手动监听 chainChanged 事件的麻烦。

typescript 复制代码
import { useAccount, useChainId } from 'wagmi';

function MyComponent() {
  const { address, isConnected } = useAccount();
  const chainId = useChainId();

  // 这里 address 和 chainId 永远是匹配的
  console.log(`当前用户 ${address} 在链 ${chainId}`);
}

这里有个坑:useAccountchainId 字段在有些版本里是 undefined 直到用户连接钱包。所以最好用 useChainId 来获取当前链 ID,它始终有值(默认是配置的第一个链)。

我重构后的组件逻辑变成了这样:

typescript 复制代码
function DeFiAction() {
  const { address } = useAccount();
  const chainId = useChainId();

  // 只有 address 和 chainId 都存在时才允许操作
  const canOperate = !!address && !!chainId;

  if (!canOperate) {
    return <p>请连接钱包</p>;
  }

  return <DepositForm chainId={chainId} address={address} />;
}

第三步:用 useSwitchChain 实现优雅的链切换

用户点击"切换到 Polygon"按钮时,我需要调用钱包的 wallet_switchEthereumChain 方法。wagmi v2 提供了 useSwitchChain 这个 Hook,它封装了所有细节,包括处理用户拒绝切换的情况。

typescript 复制代码
import { useSwitchChain } from 'wagmi';

function ChainSwitcher() {
  const { switchChainAsync } = useSwitchChain();

  const handleSwitch = async () => {
    try {
      // 这里有个坑:必须用 switchChainAsync 而不是 switchChain
      // switchChain 不返回 Promise,你无法知道切换何时完成
      await switchChainAsync({ chainId: 137 }); // Polygon
      console.log('链切换成功');
    } catch (error) {
      console.error('用户取消了切换', error);
    }
  };

  return <button onClick={handleSwitch}>切换到 Polygon</button>;
}

注意 switchChainAsyncswitchChain 的区别。前者是 async 函数,会等待钱包确认后返回;后者是同步的,只发起请求。如果你在切换后立即执行合约调用,必须用 async 版本,否则链还没切换就调用了合约。

第四步:用 useWriteContract 执行交易

wagmi v2 的 useWriteContract 替代了 v1 的 useContractWrite,它更简洁,不需要手动创建合约实例。你只需要提供合约 ABI 和地址,它会自动使用当前链的 signer。

typescript 复制代码
import { useWriteContract } from 'wagmi';
import { abi } from './YourContractABI';

function DepositForm({ chainId, address }) {
  const { writeContractAsync, isPending } = useWriteContract();

  const handleDeposit = async (amount) => {
    try {
      const txHash = await writeContractAsync({
        address: '0x...', // 合约地址,不同链可能不同
        abi,
        functionName: 'deposit',
        args: [amount],
      });
      console.log('交易已发送,哈希:', txHash);
    } catch (error) {
      console.error('交易失败:', error);
    }
  };

  return (
    <button onClick={() => handleDeposit('100')} disabled={isPending}>
      {isPending ? '交易中...' : '存入'}
    </button>
  );
}

这里有个关键点:合约地址在不同链上可能不同。我需要在组件中根据 chainId 动态选择合约地址。比如:

typescript 复制代码
const contractAddresses = {
  1: '0x...', // 以太坊
  137: '0x...', // Polygon
};

const contractAddress = contractAddresses[chainId];

如果合约地址写死,用户切链后调用的是错误的合约,gas 费直接飞了。

完整代码

以下是一个完整的 DeFi 存款组件,支持多链切换和交易签名。你可以直接复制到 React + wagmi v2 项目中使用。

tsx 复制代码
// DeFiDeposit.tsx
import { useAccount, useChainId, useSwitchChain, useWriteContract } from 'wagmi';
import { useState } from 'react';

// 假设的合约 ABI,只包含 deposit 函数
const abi = [
  {
    name: 'deposit',
    type: 'function',
    stateMutability: 'payable',
    inputs: [{ name: 'amount', type: 'uint256' }],
    outputs: [],
  },
] as const;

// 不同链上的合约地址
const contractAddresses: Record<number, `0x${string}`> = {
  1: '0x1111111111111111111111111111111111111111',
  137: '0x2222222222222222222222222222222222222222',
};

export default function DeFiDeposit() {
  const { address, isConnected } = useAccount();
  const chainId = useChainId();
  const { switchChainAsync } = useSwitchChain();
  const { writeContractAsync, isPending } = useWriteContract();
  const [amount, setAmount] = useState('0');

  // 检查用户是否在正确的链上
  const isCorrectChain = chainId === 1 || chainId === 137;
  const contractAddress = contractAddresses[chainId] || '0x0';

  const handleDeposit = async () => {
    if (!isConnected || !address) {
      alert('请先连接钱包');
      return;
    }

    if (!isCorrectChain) {
      // 这里有个坑:用户可能拒绝切换,需要处理
      try {
        await switchChainAsync({ chainId: 1 }); // 默认切回以太坊
      } catch {
        alert('链切换被拒绝');
        return;
      }
    }

    try {
      const txHash = await writeContractAsync({
        address: contractAddress,
        abi,
        functionName: 'deposit',
        args: [BigInt(amount)],
      });
      console.log('存款交易已发送:', txHash);
    } catch (error) {
      console.error('存款失败:', error);
      // 这里可以显示给用户友好的错误信息
    }
  };

  return (
    <div>
      <h2>多链存款</h2>
      <p>当前链 ID: {chainId}</p>
      <p>当前账户: {address || '未连接'}</p>
      <input
        type="number"
        value={amount}
        onChange={(e) => setAmount(e.target.value)}
        placeholder="输入存款金额"
      />
      <button onClick={handleDeposit} disabled={isPending}>
        {isPending ? '交易进行中...' : '存款'}
      </button>
      {!isCorrectChain && <p style={{ color: 'red' }}>请在以太坊或 Polygon 上操作</p>}
    </div>
  );
}

踩坑记录

  1. writeContractAsync 报错 "chain not configured"

    我一开始只配置了以太坊链,用户切到 Polygon 后,wagmi 不认识这个链,直接报错。解决方法:在 createConfigchains 数组里添加所有支持的链,并且为每个链配置 transport。

  2. 交易签名弹窗显示错误的 gas 单位

    用户从 Polygon 切回以太坊后,签名弹窗里 gas 费显示为 MATIC 而不是 ETH。这是因为 MetaMask 缓存了旧链的 gas 信息,wagmi 的 useWriteContract 在切换后需要重新初始化 signer。解决:确保在调用 writeContractAsync 之前,useChainId 已经更新,并且 contractAddress 对应的是目标链的地址。

  3. switchChainAsync 返回后,useChainId 还是旧值

    这是一个竞态问题:switchChainAsync 虽然等待了钱包确认,但 React 状态更新是异步的。我需要在 switchChainAsync 之后加一个 setTimeout 或者使用 useEffect 来确保状态同步。后来我发现其实 wagmi v2 内部已经处理了,只是我的组件里有一个自定义的 useEffect 干扰了。删掉那个 effect 就好了。

  4. 用户拒绝切换链时,页面无反应

    最初我用 switchChain(非 async 版本),用户拒绝后没有任何反馈。换成 switchChainAsync 并用 try-catch 捕获错误后,可以优雅地提示用户。

小结

多链切换的核心是"状态同步"。wagmi v2 通过 createConfig 和同步的 Hook(useAccountuseChainId)帮我解决了最大的痛点。以后遇到类似问题,记得先检查 chainId 和 address 是否匹配,再执行交易。如果想深入,可以研究 wagmi 的 useReadContractuseSimulateContract,它们能帮你提前检查交易是否可执行,避免用户浪费 gas。

相关推荐
用户2136610035721 小时前
Vue项目搜索功能与面包屑导航
前端·javascript
星栈1 小时前
LiveView 的实时通信,爽是爽,但 PubSub 和广播也最容易把自己绕晕
前端·前端框架·elixir
用户2930750976691 小时前
告别关键词匹配,拥抱向量语义 —— RAG 搜索从零到一
前端
独孤留白1 小时前
从C到Rust:告别 C 的"指针 + 长度"手动模式
前端·rust
阿黎梨梨2 小时前
揭秘大语言模型的底层逻辑:从文本分词到高维向量的计算之旅
javascript·人工智能
掘金安东尼2 小时前
中小厂前端候选人简历面试拆解:从 HR 面、技术面到主管面的双赢提问法
前端·面试
天平11 小时前
油猴脚本创建webworker踩坑记录
前端·javascript·typescript
原则猫12 小时前
前端基础大厦
前端