用 wagmi v2 实现 DeFi 前端交互:一个真实的多链代币授权与兑换实战

3 阅读1分钟

用 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 发起授权交易,等授权成功后再调用兑换合约。

但第一个版本跑起来后,问题接踵而至:

  1. 授权状态不同步:用户授权后,我手动调用 useReadContract 重新查询授权额度,但有时候查询结果还是旧的授权值,导致页面误以为还没授权,让用户重复授权。
  2. 多链切换后交易历史混乱:用户从以太坊切到 BSC,之前授权的交易哈希还在页面上显示,但链已经变了,用户点击查看交易详情会跳转到错误的区块链浏览器。
  3. 交易确认时间过长:我用 wagmi 的 useWaitForTransactionReceipt 等待交易确认,但某些链(如 Polygon)出块快,交易状态返回慢,导致 UI 卡在“等待确认”状态很久。

我当时的第一反应是“wagmi 有 bug”,但后来仔细排查,发现大部分问题是我对 wagmi v2 的异步模型和多链状态管理理解不够。比如 useReadContract 默认有缓存机制,如果链切换了但缓存没清,就会读到旧数据。而 useWaitForTransactionReceipt 需要我在链切换时主动取消监听。

核心实现

1. 多链配置与连接管理:从混乱到有序

首先,我重新设计了链配置。wagmi v2 要求用 createConfig 定义链和连接器,这里有个关键点:必须显式指定 multiInjectedProviderDiscoveryfalse,否则在某些浏览器环境下(如移动端),会重复发现钱包导致 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,一旦授权交易确认,立即清除缓存并重新查询。这样能确保用户看到最新的授权额度。
  • useWriteContractwriteContract 函数返回的是一个 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',
  }
}

关键点说明

  • useWaitForTransactionReceiptenabled 参数非常关键。如果不加这个判断,当用户切链后,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>
  )
}

踩坑记录

  1. 坑一:useWriteContract 的返回值不是 Promise
    一开始我 await writeContract(...) 并期望得到交易哈希,结果一直 undefined。后来看文档才发现,writeContract 返回的是 WriteContractReturnType,实际哈希是通过 onSuccess 回调或 data 字段获取的。正确做法是:writeContract({...}, { onSuccess: (hash) => setHash(hash) })

  2. 坑二:useWaitForTransactionReceipt 在链切换后报错
    当用户从以太坊切到 BSC 时,如果还在监听以太坊上的交易,wagmi 会尝试用 BSC 的 RPC 查询以太坊的交易哈希,导致 500 错误。解决方案是加 enabled 参数,只有当前链 ID 与交易链 ID 一致时才启用监听。

  3. 坑三:授权额度缓存不同步
    用户授权后,我调用 refetchAllowance 但返回的还是旧值。原因是 TanStack Query 的缓存需要手动失效。后来用 queryClient.invalidateQueries 解决了。

  4. 坑四:多链配置中 multiInjectedProviderDiscovery 的坑
    在移动端 MetaMask 中,如果不设置 multiInjectedProviderDiscovery: false,钱包连接会触发两次,导致 UI 闪烁。这个坑在 wagmi 官方文档的 FAQ 中有提到,但很容易忽略。

小结

这次重构让我深刻理解了 wagmi v2 的异步模型和多链状态管理。核心收获就两点:缓存失效要主动做交易状态要链相关。如果你也在做类似的项目,建议多关注 useWaitForTransactionReceiptenabled 参数和 TanStack Query 的缓存机制。下一步可以深入研究 wagmi 的 useSimulateContract 来做交易前模拟,提前发现回滚风险。