背景
去年下半年,我参与了一个 DeFi 项目的流动性池监控面板开发。需求很简单:用户连接钱包后,页面上要实时显示某个流动性池的 Swap、Mint、Burn 事件,包括交易对、数量、时间等。这个面板要跑在多个链上(Ethereum、Polygon、Arbitrum),用户切换链时,事件监听也要跟着切换。
当时我第一个想法是:用 ethers.js 的 contract.on() 或者轮询。但实际做起来才发现,事情远没有这么简单。连接断开后监听没恢复、用户切换链后事件重复触发、页面切后台再回来时监听已经失效......这些问题一个个蹦出来,让我整整折腾了三天。
后来我换成了 wagmi v2 的 useWatchContractEvent,配合一些状态管理和错误处理,终于稳定了。这篇文章就是记录我这次踩坑和最终解决的过程。
问题分析:为什么轮询和 ethers.js 的 on 不够用
我一开始用的是 ethers.js 的 contract.on("Swap", callback)。这是最直接的方式,但问题在于:
- 连接不可靠:用户刷新页面或网络波动时,WebSocket 或 Provider 会断开,监听就失效了。我需要手动重连,但重连逻辑很容易写错。
- 多链切换麻烦 :用户切换链时,我必须销毁旧监听,创建新监听。但 ethers.js 的
removeAllListeners()有时会残留,导致新旧监听一起跑,页面卡死。 - 区块回滚 :链上偶尔会出现临时分叉,事件可能被回滚。ethers.js 的
on不会处理这个,需要自己维护一个待确认队列。
后来我试了轮询:用 setInterval 每 10 秒调用 contract.queryFilter()。这种方法简单,但延迟高,而且每次查询都会消耗 RPC 配额,用户量大了成本很高。
我当时就想:wagmi 作为 React 生态里的 Web3 主流库,肯定有更好的方案。于是我切到了 wagmi v2。
核心实现:用 wagmi v2 的 useWatchContractEvent 搞定监听
1. 安装和配置 wagmi
首先,项目里需要装好 wagmi v2 和 viem。我用的版本是 wagmi@2.x 和 viem@2.x。
bash
npm install wagmi viem @tanstack/react-query
然后在 App.tsx 里配置 WagmiProvider。这里有个坑:wagmi v2 不再内置 React Query,需要自己引入 @tanstack/react-query。
tsx
// App.tsx
import { WagmiProvider, createConfig, http } from 'wagmi'
import { mainnet, polygon, arbitrum } from 'wagmi/chains'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const config = createConfig({
chains: [mainnet, polygon, arbitrum],
transports: {
[mainnet.id]: http(),
[polygon.id]: http(),
[arbitrum.id]: http(),
},
})
const queryClient = new QueryClient()
export default function App() {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<PoolMonitor />
</QueryClientProvider>
</WagmiProvider>
)
}
2. 使用 useWatchContractEvent 监听事件
核心组件 PoolMonitor 里,我用 useWatchContractEvent 来监听 Swap 事件。这个 hook 是 wagmi v2 新增的,底层用的是 viem 的 watchContractEvent,它会自动管理连接和重连。
tsx
// PoolMonitor.tsx
import { useWatchContractEvent } from 'wagmi'
import { useChainId } from 'wagmi'
import { useState } from 'react'
// 假设这是你的流动性池合约 ABI 和地址
const POOL_ABI = [
// Swap 事件的 ABI
{
anonymous: false,
inputs: [
{ indexed: true, name: 'sender', type: 'address' },
{ indexed: false, name: 'amount0In', type: 'uint256' },
{ indexed: false, name: 'amount1In', type: 'uint256' },
{ indexed: false, name: 'amount0Out', type: 'uint256' },
{ indexed: false, name: 'amount1Out', type: 'uint256' },
{ indexed: true, name: 'to', type: 'address' },
],
name: 'Swap',
type: 'event',
},
] as const
const POOL_ADDRESS = '0x...' // 替换为实际地址
export default function PoolMonitor() {
const chainId = useChainId()
const [events, setEvents] = useState<any[]>([])
useWatchContractEvent({
address: POOL_ADDRESS as `0x${string}`,
abi: POOL_ABI,
eventName: 'Swap',
chainId: chainId, // 这里指定当前链 ID
onLogs(logs) {
// 每次监听到新事件,更新状态
setEvents(prev => [...prev, ...logs])
},
onError(error) {
console.error('事件监听出错:', error)
},
})
return (
<div>
<h3>当前链: {chainId}</h3>
<ul>
{events.map((event, index) => (
<li key={index}>
{event.args.sender} - {event.args.amount0In.toString()} / {event.args.amount1In.toString()}
</li>
))}
</ul>
</div>
)
}
这里有个坑 :useWatchContractEvent 的 chainId 参数必须和当前链一致。如果你不传这个参数,它会默认使用 wagmi 配置中的第一个链。我当时没传,结果用户切到 Polygon 后,监听还在 Ethereum 上跑,事件完全收不到。后来加上了 chainId 才正常。
另外,onLogs 回调的参数是 logs 数组,不是单个事件。wagmi v2 的 watchContractEvent 会把一段时间内的事件批量返回,所以要用 ...logs 展开。
3. 处理区块回滚:用 useBlockNumber 做确认
光监听事件还不够,链上偶尔会有临时分叉。比如一个区块被挖出来后,又被回滚了,那事件就是假的。我需要等一定数量的区块确认后再展示。
wagmi v2 提供了 useBlockNumber 可以获取当前区块号,配合事件中的 blockNumber 字段,可以实现确认逻辑。
tsx
import { useBlockNumber } from 'wagmi'
export default function PoolMonitor() {
const chainId = useChainId()
const { data: currentBlockNumber } = useBlockNumber({ chainId, watch: true })
const [confirmedEvents, setConfirmedEvents] = useState<any[]>([])
const [pendingEvents, setPendingEvents] = useState<any[]>([])
useWatchContractEvent({
address: POOL_ADDRESS as `0x${string}`,
abi: POOL_ABI,
eventName: 'Swap',
chainId,
onLogs(logs) {
// 新事件先放入待确认队列
setPendingEvents(prev => [...prev, ...logs])
},
})
// 每产生一个新区块,检查待确认事件是否已足够确认
useEffect(() => {
if (!currentBlockNumber) return
const confirmations = 6 // 等待 6 个区块确认
setPendingEvents(prev => {
const newConfirmed = prev.filter(
(event) => currentBlockNumber - Number(event.blockNumber) >= confirmations
)
const stillPending = prev.filter(
(event) => currentBlockNumber - Number(event.blockNumber) < confirmations
)
if (newConfirmed.length > 0) {
setConfirmedEvents(prevConfirmed => [...prevConfirmed, ...newConfirmed])
}
return stillPending
})
}, [currentBlockNumber])
return (
<div>
<h3>已确认事件 ({confirmedEvents.length})</h3>
<ul>
{confirmedEvents.map((event, index) => (
<li key={index}>
区块 {event.blockNumber}: {event.args.sender} - {event.args.amount0In.toString()}
</li>
))}
</ul>
<h3>待确认事件 ({pendingEvents.length})</h3>
<ul>
{pendingEvents.map((event, index) => (
<li key={index}>
区块 {event.blockNumber}: 等待确认中...
</li>
))}
</ul>
</div>
)
}
注意这个细节 :event.blockNumber 是 bigint 类型,需要转成 Number 才能做减法。另外,useBlockNumber 的 watch: true 会让它自动轮询新区块,默认是每 4 秒一次,这个频率够用了。
4. 多链切换:监听链 ID 变化,自动重启
前面提到过,用户切换链时,useWatchContractEvent 会通过 chainId 参数自动重启监听。但有一个问题:如果切链时旧监听还没完全销毁,新监听就创建了,会导致短时间内重复收到事件。
我的解决方法是:在切链时,先清空事件状态,再让组件重新挂载。可以用 React 的 key 属性来实现。
tsx
export default function PoolMonitor() {
const chainId = useChainId()
// 使用 chainId 作为 key,切链时组件完全重建
return <PoolMonitorInner key={chainId} />
}
function PoolMonitorInner() {
const chainId = useChainId()
const [events, setEvents] = useState<any[]>([])
useWatchContractEvent({
address: POOL_ADDRESS as `0x${string}`,
abi: POOL_ABI,
eventName: 'Swap',
chainId,
onLogs(logs) {
setEvents(prev => [...prev, ...logs])
},
})
return (
<div>
<h3>当前链: {chainId}</h3>
<ul>
{events.map((event, index) => (
<li key={index}>
{event.args.sender} - {event.args.amount0In.toString()}
</li>
))}
</ul>
</div>
)
}
这样,每次切链时,PoolMonitorInner 会完全卸载再重新挂载,旧监听自然就销毁了。代价是事件状态会清空,但这对用户来说其实是合理的------切链后看新链的数据。
完整代码:可直接复制运行的示例
我把上面所有逻辑整合成一个完整的组件,放在 CodeSandbox 里测试通过。这里贴出核心代码:
tsx
// App.tsx
import { WagmiProvider, createConfig, http } from 'wagmi'
import { mainnet, polygon, arbitrum } from 'wagmi/chains'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import PoolMonitor from './PoolMonitor'
const config = createConfig({
chains: [mainnet, polygon, arbitrum],
transports: {
[mainnet.id]: http(),
[polygon.id]: http(),
[arbitrum.id]: http(),
},
})
const queryClient = new QueryClient()
export default function App() {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<PoolMonitor />
</QueryClientProvider>
</WagmiProvider>
)
}
tsx
// PoolMonitor.tsx
import { useWatchContractEvent, useBlockNumber, useChainId } from 'wagmi'
import { useState, useEffect } from 'react'
const POOL_ABI = [
{
anonymous: false,
inputs: [
{ indexed: true, name: 'sender', type: 'address' },
{ indexed: false, name: 'amount0In', type: 'uint256' },
{ indexed: false, name: 'amount1In', type: 'uint256' },
{ indexed: false, name: 'amount0Out', type: 'uint256' },
{ indexed: false, name: 'amount1Out', type: 'uint256' },
{ indexed: true, name: 'to', type: 'address' },
],
name: 'Swap',
type: 'event',
},
] as const
const POOL_ADDRESS = '0x...' // 替换为实际地址
export default function PoolMonitor() {
const chainId = useChainId()
return <PoolMonitorInner key={chainId} />
}
function PoolMonitorInner() {
const chainId = useChainId()
const { data: currentBlockNumber } = useBlockNumber({ chainId, watch: true })
const [confirmedEvents, setConfirmedEvents] = useState<any[]>([])
const [pendingEvents, setPendingEvents] = useState<any[]>([])
useWatchContractEvent({
address: POOL_ADDRESS as `0x${string}`,
abi: POOL_ABI,
eventName: 'Swap',
chainId,
onLogs(logs) {
setPendingEvents(prev => [...prev, ...logs])
},
onError(error) {
console.error('监听出错:', error)
},
})
useEffect(() => {
if (!currentBlockNumber) return
const confirmations = 6
setPendingEvents(prev => {
const newConfirmed = prev.filter(
(event) => currentBlockNumber - Number(event.blockNumber) >= confirmations
)
const stillPending = prev.filter(
(event) => currentBlockNumber - Number(event.blockNumber) < confirmations
)
if (newConfirmed.length > 0) {
setConfirmedEvents(prevConfirmed => {
const updated = [...prevConfirmed, ...newConfirmed]
// 只保留最近的 100 条已确认事件,防止内存溢出
return updated.slice(-100)
})
}
return stillPending
})
}, [currentBlockNumber])
return (
<div style={{ padding: '20px' }}>
<h2>流动性池事件监控</h2>
<p>当前链 ID: {chainId}</p>
<p>当前区块: {currentBlockNumber?.toString()}</p>
<h3>已确认事件 ({confirmedEvents.length})</h3>
<ul>
{confirmedEvents.map((event, index) => (
<li key={index}>
区块 {event.blockNumber.toString()}: {event.args.sender} - {event.args.amount0In.toString()}
</li>
))}
</ul>
<h3>待确认事件 ({pendingEvents.length})</h3>
<ul>
{pendingEvents.map((event, index) => (
<li key={index}>
区块 {event.blockNumber.toString()}: 等待确认中...
</li>
))}
</ul>
</div>
)
}
踩坑记录
坑 1: useWatchContractEvent 不传 chainId,监听永远跑在默认链上
现象 :用户切到 Polygon 后,事件监听没反应,但切回 Ethereum 又能收到。
原因 :useWatchContractEvent 默认使用 wagmi 配置中的第一个链(mainnet)。
解决 :显式传入 chainId 参数。
坑 2: 事件中的 blockNumber 是 bigint,不能直接加减
现象 :用 currentBlockNumber - event.blockNumber 时报错 Cannot mix BigInt and other types。
原因 :viem 返回的区块号是 bigint,而 useBlockNumber 返回的也是 bigint,但减法操作需要类型一致。
解决 :用 Number(event.blockNumber) 转成 number,或者用 BigInt(currentBlockNumber) - event.blockNumber。
坑 3: 切链后事件重复出现
现象 :从 Ethereum 切到 Polygon,再切回来,之前的事件又出现一次。
原因 :useWatchContractEvent 在切链时没有完全销毁旧监听,或者 React 状态没有重置。
解决 :用 key={chainId} 强制组件重新挂载,清空所有状态。
坑 4: 页面切后台再回来,事件不更新了
现象 :用户把浏览器标签页切到后台,过几分钟切回来,事件列表没有新增。
原因 :浏览器为了省电,会暂停页面上的定时器和 WebSocket。wagmi 的 watchContractEvent 用的是长轮询或 WebSocket,但页面被挂起后不会自动恢复。
解决 :用 document.visibilitychange 事件监听页面可见性变化,当页面重新可见时手动刷新一次。但 wagmi v2 的 useWatchContractEvent 内部有重连机制,通常能自动恢复,只是需要几秒钟。如果等不了,可以加个手动刷新按钮。
小结
wagmi v2 的 useWatchContractEvent 确实比 ethers.js 的裸监听好用很多,自动管理了连接和重连,配合 useBlockNumber 做确认逻辑,基本能满足大部分 DeFi 监控场景。但多链切换和页面挂起的问题还是需要手动处理。如果你需要更精细的控制,比如自定义确认数、过滤重复事件,可以看看 viem 的 watchContractEvent 底层 API,或者用 usePublicClient 自己封装一个 hook。