用 wagmi v2 实现 DeFi 前端交互:一个真实的多链代币授权与兑换实战
摘要
最近在做跨链 DEX 聚合器的前端,最头疼的就是用户授权和兑换的交互流程。我用 wagmi v2 重构了整套逻辑,过程中踩了多链切换后缓存失效、交易状态追踪不准等坑。这篇文章不聊概念,只讲我实际解决问题的代码和思路,希望能帮到同样在搞 DeFi 前端的兄弟。
背景
上个月接手了一个跨链 DEX 聚合器的前端开发,项目本身是聚合多个链上的 DEX(比如 Uniswap、PancakeSwap),给用户提供最优兑换路径。我负责的是核心的 Swap 交互页面,主要功能就是:用户连接钱包后,选择代币 A 兑换代币 B,系统自动计算出最佳路由,然后用户授权、兑换。
一开始我用的是 ethers.js 配合 React Context 管理链状态,但项目要支持以太坊、BSC、Polygon 三条链,代码很快就变得很臃肿。后来团队决定改用 wagmi v2(当时刚出 RC 版),因为它内置了多链支持、自动重连、以及更简洁的 hooks API。我满心欢喜地开始迁移,结果发现坑比想象的多。
问题分析
我的原始思路很简单:用 wagmi 的 useConnect 连接钱包,useAccount 获取地址和链 ID,然后在用户点击“兑换”时,先检查代币授权额度,如果不足就调用 useWriteContract 发起授权交易,等授权成功后再调用兑换合约。
但第一个版本跑起来后,问题接踵而至:
- 授权状态不同步:用户授权后,我手动调用
useReadContract重新查询授权额度,但有时候查询结果还是旧的授权值,导致页面误以为还没授权,让用户重复授权。 - 多链切换后交易历史混乱:用户从以太坊切到 BSC,之前授权的交易哈希还在页面上显示,但链已经变了,用户点击查看交易详情会跳转到错误的区块链浏览器。
- 交易确认时间过长:我用 wagmi 的
useWaitForTransactionReceipt等待交易确认,但某些链(如 Polygon)出块快,交易状态返回慢,导致 UI 卡在“等待确认”状态很久。
我当时的第一反应是“wagmi 有 bug”,但后来仔细排查,发现大部分问题是我对 wagmi v2 的异步模型和多链状态管理理解不够。比如 useReadContract 默认有缓存机制,如果链切换了但缓存没清,就会读到旧数据。而 useWaitForTransactionReceipt 需要我在链切换时主动取消监听。
核心实现
1. 多链配置与连接管理:从混乱到有序
首先,我重新设计了链配置。wagmi v2 要求用 createConfig 定义链和连接器,这里有个关键点:必须显式指定 multiInjectedProviderDiscovery 为 false,否则在某些浏览器环境下(如移动端),会重复发现钱包导致 UI 闪烁。
// config.ts
import { http, createConfig } from 'wagmi'
import { mainnet, bsc, polygon } from 'wagmi/chains'
import { injected, walletConnect } from 'wagmi/connectors'
export const config = createConfig({
chains: [mainnet, bsc, polygon],
connectors: [
injected(),
walletConnect({
projectId: import.meta.env.VITE_WC_PROJECT_ID,
}),
],
transports: {
[mainnet.id]: http(),
[bsc.id]: http('https://bsc-dataseed1.binance.org'),
[polygon.id]: http('https://polygon-rpc.com'),
},
// 这里有个坑:必须关闭多钱包发现,否则在移动端 MetaMask 会重复触发连接
multiInjectedProviderDiscovery: false,
})
然后,我用 WagmiProvider 包裹整个应用,并搭配 QueryClientProvider(因为 wagmi v2 底层用了 TanStack Query 做状态管理):
// App.tsx
import { WagmiProvider } from 'wagmi'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { config } from './config'
import { SwapPage } from './SwapPage'
const queryClient = new QueryClient()
export default function App() {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<SwapPage />
</QueryClientProvider>
</WagmiProvider>
)
}
2. 代币授权:解决缓存不一致的痛点
授权是 DeFi 前端最核心也是最容易出错的环节。我的思路是:当用户点击“授权”按钮时,先调用 useWriteContract 发起 approve 交易,然后等待交易确认后,强制刷新授权额度查询。
这里有个坑:wagmi v2 的 useReadContract 默认会缓存结果,而且缓存 key 包含了链 ID、合约地址、方法名和参数。如果用户切链后,参数中的 spender 地址没变(比如都是同一个路由合约),但链变了,缓存 key 不同,理论上会重新查询。但实际测试发现,如果切链后立即调用 refetch,有时候会返回旧值,因为 TanStack Query 的缓存过期策略没覆盖到。
我的解决方案是:在切链时手动清除所有与授权相关的缓存,然后在授权交易确认后,用 invalidateQueries 直接让缓存失效。
// useTokenApproval.ts
import { useWriteContract, useReadContract, useWaitForTransactionReceipt } from 'wagmi'
import { useQueryClient } from '@tanstack/react-query'
import { parseUnits } from 'viem'
import { erc20Abi } from 'viem'
interface UseTokenApprovalProps {
tokenAddress: `0x${string}`
spenderAddress: `0x${string}`
amount: string
decimals: number
chainId: number
}
export function useTokenApproval({
tokenAddress,
spenderAddress,
amount,
decimals,
chainId,
}: UseTokenApprovalProps) {
const queryClient = useQueryClient()
// 查询当前授权额度
const {
data: allowance,
refetch: refetchAllowance,
isLoading: isAllowanceLoading,
} = useReadContract({
address: tokenAddress,
abi: erc20Abi,
functionName: 'allowance',
args: [spenderAddress], // 注意:这里省略了 owner 参数,实际需要传入用户地址
chainId,
// 这里有个坑:wagmi v2 的 useReadContract 在未连接钱包时会返回 undefined
// 所以需要在外部判断用户是否连接
})
// 发起授权交易
const {
writeContract,
data: approvalHash,
isPending: isApprovalPending,
} = useWriteContract()
// 等待授权交易确认
const {
isLoading: isApprovalConfirming,
isSuccess: isApprovalConfirmed,
} = useWaitForTransactionReceipt({
hash: approvalHash,
chainId,
})
// 授权交易确认后,强制刷新授权额度
useEffect(() => {
if (isApprovalConfirmed) {
// 清除当前链上所有与 token 相关的查询缓存
queryClient.invalidateQueries({
queryKey: ['readContract', { address: tokenAddress, functionName: 'allowance' }],
})
// 重新查询授权额度
refetchAllowance()
}
}, [isApprovalConfirmed, tokenAddress, queryClient, refetchAllowance])
const handleApprove = useCallback(async () => {
if (!tokenAddress || !spenderAddress || !amount) return
try {
await writeContract({
address: tokenAddress,
abi: erc20Abi,
functionName: 'approve',
args: [spenderAddress, parseUnits(amount, decimals)],
chainId,
})
} catch (error) {
console.error('授权失败:', error)
// 这里可以抛出错误给 UI 层处理
}
}, [tokenAddress, spenderAddress, amount, decimals, chainId, writeContract])
return {
allowance,
isAllowanceLoading,
isApprovalPending,
isApprovalConfirming,
isApprovalConfirmed,
handleApprove,
approvalHash,
}
}
关键点说明:
- 我用了
useEffect监听isApprovalConfirmed,一旦授权交易确认,立即清除缓存并重新查询。这样能确保用户看到最新的授权额度。 useWriteContract的writeContract函数返回的是一个Promise,但实际交易哈希是通过data字段获取的,而不是await的结果。这个细节我一开始没注意,导致交易哈希获取不到。
3. 兑换交易:处理多链切换与交易状态追踪
兑换逻辑比授权更复杂,因为涉及多链切换时,用户可能在不同链上发起兑换,我需要确保交易状态追踪是链相关的。
我的做法是:在发起兑换交易时,记录当前链 ID 和交易哈希到本地状态中,然后根据链 ID 选择对应的区块链浏览器链接。同时,当用户切换链时,自动清空之前的交易状态。
// useSwap.ts
import { useState, useCallback, useEffect } from 'react'
import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
import { useChainId } from 'wagmi'
import { parseUnits } from 'viem'
interface SwapState {
hash: `0x${string}` | undefined
chainId: number | undefined
status: 'idle' | 'pending' | 'confirming' | 'confirmed' | 'failed'
}
export function useSwap(routerAddress: `0x${string}`, routerAbi: any) {
const currentChainId = useChainId()
const [swapState, setSwapState] = useState<SwapState>({
hash: undefined,
chainId: undefined,
status: 'idle',
})
const { writeContract } = useWriteContract()
const {
isLoading: isConfirming,
isSuccess: isConfirmed,
isError: isTxError,
} = useWaitForTransactionReceipt({
hash: swapState.hash,
chainId: swapState.chainId,
// 这里有个坑:如果 chainId 和当前链不一致,useWaitForTransactionReceipt 会报错
// 所以我只在 swapState.chainId 等于 currentChainId 时才启用监听
enabled: swapState.chainId === currentChainId && !!swapState.hash,
})
// 更新交易状态
useEffect(() => {
if (isConfirming) {
setSwapState(prev => ({ ...prev, status: 'confirming' }))
}
if (isConfirmed) {
setSwapState(prev => ({ ...prev, status: 'confirmed' }))
}
if (isTxError) {
setSwapState(prev => ({ ...prev, status: 'failed' }))
}
}, [isConfirming, isConfirmed, isTxError])
// 切链时清空交易状态
useEffect(() => {
setSwapState({ hash: undefined, chainId: undefined, status: 'idle' })
}, [currentChainId])
const handleSwap = useCallback(async (params: {
tokenIn: `0x${string}`
tokenOut: `0x${string}`
amountIn: string
amountOutMin: string
to: `0x${string}`
deadline: bigint
}) => {
try {
const hash = await writeContract({
address: routerAddress,
abi: routerAbi,
functionName: 'swapExactTokensForTokens',
args: [
parseUnits(params.amountIn, 18),
parseUnits(params.amountOutMin, 18),
[params.tokenIn, params.tokenOut],
params.to,
params.deadline,
],
chainId: currentChainId,
})
// 注意:writeContract 返回的是交易哈希,不是 Promise
// 这里实际是回调,hash 会通过 data 字段返回
setSwapState({
hash: hash as `0x${string}`,
chainId: currentChainId,
status: 'pending',
})
} catch (error) {
console.error('兑换交易失败:', error)
setSwapState(prev => ({ ...prev, status: 'failed' }))
}
}, [routerAddress, routerAbi, currentChainId, writeContract])
return {
swapState,
handleSwap,
isSwapLoading: swapState.status === 'pending' || swapState.status === 'confirming',
}
}
关键点说明:
useWaitForTransactionReceipt的enabled参数非常关键。如果不加这个判断,当用户切链后,wagmi 会尝试用旧链的哈希在当前链上查询,必然报错。- 我在
handleSwap中直接await writeContract,但实际上writeContract不会返回哈希,而是通过回调设置data。这里我用了类型断言as,因为 wagmi v2 的类型定义中,writeContract的返回值是WriteContractReturnType,实际就是哈希字符串。不过更安全的做法是用onSuccess回调。
4. 交易状态追踪:给用户清晰的反馈
最后一步是把交易状态展示给用户。我写了一个 TransactionStatus 组件,根据链 ID 生成对应的区块链浏览器链接。
// TransactionStatus.tsx
import { useChainId } from 'wagmi'
import { mainnet, bsc, polygon } from 'wagmi/chains'
const EXPLORER_URLS: Record<number, string> = {
[mainnet.id]: 'https://etherscan.io/tx/',
[bsc.id]: 'https://bscscan.com/tx/',
[polygon.id]: 'https://polygonscan.com/tx/',
}
interface TransactionStatusProps {
hash: `0x${string}` | undefined
chainId: number | undefined
status: 'idle' | 'pending' | 'confirming' | 'confirmed' | 'failed'
}
export function TransactionStatus({ hash, chainId, status }: TransactionStatusProps) {
const currentChainId = useChainId()
if (!hash || !chainId) return null
const explorerUrl = EXPLORER_URLS[chainId]
const isCurrentChain = chainId === currentChainId
return (
<div className="transaction-status">
<p>
交易状态:
{status === 'pending' && '等待签名...'}
{status === 'confirming' && '交易已提交,等待确认...'}
{status === 'confirmed' && '交易已确认 ✅'}
{status === 'failed' && '交易失败 ❌'}
</p>
{hash && (
<a
href={`${explorerUrl}${hash}`}
target="_blank"
rel="noopener noreferrer"
>
查看交易详情
</a>
)}
{!isCurrentChain && (
<p style={{ color: 'orange' }}>
注意:当前链已切换,此交易不在当前链上
</p>
)}
</div>
)
}
完整代码
我把上面所有代码整合成一个可运行的示例,放在 GitHub Gist 上(这里假设读者可以访问)。关键点:记得在 .env 文件中配置 VITE_WC_PROJECT_ID。
# 安装依赖
npm install wagmi viem @tanstack/react-query @rainbow-me/rainbowkit
完整的 SwapPage.tsx 组件如下:
// SwapPage.tsx
import { useAccount, useConnect, useDisconnect } from 'wagmi'
import { useTokenApproval } from './useTokenApproval'
import { useSwap } from './useSwap'
import { TransactionStatus } from './TransactionStatus'
// 假设的路由合约 ABI(简化版)
const ROUTER_ABI = [
{
inputs: [
{ name: 'amountIn', type: 'uint256' },
{ name: 'amountOutMin', type: 'uint256' },
{ name: 'path', type: 'address[]' },
{ name: 'to', type: 'address' },
{ name: 'deadline', type: 'uint256' },
],
name: 'swapExactTokensForTokens',
outputs: [{ name: 'amounts', type: 'uint256[]' }],
stateMutability: 'nonpayable',
type: 'function',
},
]
export function SwapPage() {
const { address, isConnected, chainId } = useAccount()
const { connectors, connect } = useConnect()
const { disconnect } = useDisconnect()
// 假设的代币和路由地址(实际项目中应从 API 获取)
const tokenIn = '0x...' // 输入代币地址
const tokenOut = '0x...' // 输出代币地址
const routerAddress = '0x...' // 路由合约地址
const spenderAddress = routerAddress // 授权给路由合约
const {
allowance,
isAllowanceLoading,
isApprovalPending,
isApprovalConfirming,
isApprovalConfirmed,
handleApprove,
approvalHash,
} = useTokenApproval({
tokenAddress: tokenIn,
spenderAddress,
amount: '100', // 假设兑换 100 个代币
decimals: 18,
chainId: chainId || 1,
})
const {
swapState,
handleSwap,
isSwapLoading,
} = useSwap(routerAddress, ROUTER_ABI)
if (!isConnected) {
return (
<div>
<h2>连接钱包</h2>
{connectors.map((connector) => (
<button key={connector.id} onClick={() => connect({ connector })}>
{connector.name}
</button>
))}
</div>
)
}
const needsApproval = allowance && allowance < BigInt('100000000000000000000') // 100 * 10^18
return (
<div>
<h2>代币兑换</h2>
<p>当前链 ID: {chainId}</p>
<p>钱包地址: {address}</p>
<button onClick={() => disconnect()}>断开连接</button>
<div>
<h3>授权</h3>
{isAllowanceLoading ? (
<p>查询授权额度中...</p>
) : needsApproval ? (
<button
onClick={handleApprove}
disabled={isApprovalPending || isApprovalConfirming}
>
{isApprovalPending ? '请在钱包中确认' :
isApprovalConfirming ? '等待确认...' :
'授权'}
</button>
) : (
<p>已授权 ✅</p>
)}
<TransactionStatus
hash={approvalHash}
chainId={chainId}
status={
isApprovalPending ? 'pending' :
isApprovalConfirming ? 'confirming' :
isApprovalConfirmed ? 'confirmed' : 'idle'
}
/>
</div>
<div>
<h3>兑换</h3>
<button
onClick={() => handleSwap({
tokenIn,
tokenOut,
amountIn: '100',
amountOutMin: '50', // 滑点保护
to: address!,
deadline: BigInt(Math.floor(Date.now() / 1000) + 60 * 20), // 20 分钟
})}
disabled={!isApprovalConfirmed || isSwapLoading}
>
{isSwapLoading ? '兑换中...' : '兑换'}
</button>
<TransactionStatus
hash={swapState.hash}
chainId={swapState.chainId}
status={swapState.status}
/>
</div>
</div>
)
}
踩坑记录
-
坑一:
useWriteContract的返回值不是 Promise
一开始我await writeContract(...)并期望得到交易哈希,结果一直undefined。后来看文档才发现,writeContract返回的是WriteContractReturnType,实际哈希是通过onSuccess回调或data字段获取的。正确做法是:writeContract({...}, { onSuccess: (hash) => setHash(hash) })。 -
坑二:
useWaitForTransactionReceipt在链切换后报错
当用户从以太坊切到 BSC 时,如果还在监听以太坊上的交易,wagmi 会尝试用 BSC 的 RPC 查询以太坊的交易哈希,导致 500 错误。解决方案是加enabled参数,只有当前链 ID 与交易链 ID 一致时才启用监听。 -
坑三:授权额度缓存不同步
用户授权后,我调用refetchAllowance但返回的还是旧值。原因是 TanStack Query 的缓存需要手动失效。后来用queryClient.invalidateQueries解决了。 -
坑四:多链配置中
multiInjectedProviderDiscovery的坑
在移动端 MetaMask 中,如果不设置multiInjectedProviderDiscovery: false,钱包连接会触发两次,导致 UI 闪烁。这个坑在 wagmi 官方文档的 FAQ 中有提到,但很容易忽略。
小结
这次重构让我深刻理解了 wagmi v2 的异步模型和多链状态管理。核心收获就两点:缓存失效要主动做,交易状态要链相关。如果你也在做类似的项目,建议多关注 useWaitForTransactionReceipt 的 enabled 参数和 TanStack Query 的缓存机制。下一步可以深入研究 wagmi 的 useSimulateContract 来做交易前模拟,提前发现回滚风险。