用 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 发布,我决定用它的 createConfig 和 SwitchChain 来重构整个钱包连接层。
问题分析
我最初的思路很简单:在 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 的 useAccount 和 useNetwork 是分开的 Hook,我很难在同一个地方拿到最新的 chainId 和 address。
结果就是:用户先切链,前端还没反应过来,合约调用用的还是旧链的 provider,直接报错。或者,用户切了账户,但合约实例用的还是旧 signer,签名时弹窗显示旧地址。
我当时排查了两天,打印了无数 console.log,发现核心问题是"状态同步滞后"。wagmi v1 的 useProvider 和 useSigner 返回的是基于当前链的实例,但它们在链切换后不会立即更新,需要等待下一个渲染周期。而我的合约调用是同步触发的,所以总是拿到旧实例。
核心实现
第一步:用 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 返回的 address 和 chainId 是同步的,不会出现地址和链不匹配的情况。useChainId 则直接返回当前链 ID,省去了手动监听 chainChanged 事件的麻烦。
typescript
import { useAccount, useChainId } from 'wagmi';
function MyComponent() {
const { address, isConnected } = useAccount();
const chainId = useChainId();
// 这里 address 和 chainId 永远是匹配的
console.log(`当前用户 ${address} 在链 ${chainId}`);
}
这里有个坑:useAccount 的 chainId 字段在有些版本里是 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>;
}
注意 switchChainAsync 和 switchChain 的区别。前者是 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>
);
}
踩坑记录
-
writeContractAsync报错 "chain not configured"我一开始只配置了以太坊链,用户切到 Polygon 后,wagmi 不认识这个链,直接报错。解决方法:在
createConfig的chains数组里添加所有支持的链,并且为每个链配置 transport。 -
交易签名弹窗显示错误的 gas 单位
用户从 Polygon 切回以太坊后,签名弹窗里 gas 费显示为 MATIC 而不是 ETH。这是因为 MetaMask 缓存了旧链的 gas 信息,wagmi 的
useWriteContract在切换后需要重新初始化 signer。解决:确保在调用writeContractAsync之前,useChainId已经更新,并且contractAddress对应的是目标链的地址。 -
switchChainAsync返回后,useChainId还是旧值这是一个竞态问题:
switchChainAsync虽然等待了钱包确认,但 React 状态更新是异步的。我需要在switchChainAsync之后加一个setTimeout或者使用useEffect来确保状态同步。后来我发现其实 wagmi v2 内部已经处理了,只是我的组件里有一个自定义的useEffect干扰了。删掉那个 effect 就好了。 -
用户拒绝切换链时,页面无反应
最初我用
switchChain(非 async 版本),用户拒绝后没有任何反馈。换成switchChainAsync并用 try-catch 捕获错误后,可以优雅地提示用户。
小结
多链切换的核心是"状态同步"。wagmi v2 通过 createConfig 和同步的 Hook(useAccount、useChainId)帮我解决了最大的痛点。以后遇到类似问题,记得先检查 chainId 和 address 是否匹配,再执行交易。如果想深入,可以研究 wagmi 的 useReadContract 和 useSimulateContract,它们能帮你提前检查交易是否可执行,避免用户浪费 gas。