☰
ethers.js事件过滤完全指南:从queryFilter到实时监听
2026/10/15 4:43:02 网站建设 项目流程

1. 事件过滤到底在解决什么问题

做链上开发的朋友应该都有这种体会:合约里的数据本身是“沉默”的,你没法直接问它“刚才谁给我转了一笔钱”。链上所有状态变化,都是通过事件(Event)这种日志形式记录下来的。而ethers.js提供的事件过滤机制,就是我们从海量链上日志里精准捞取所需信息的核心手段。

我最早接触ethers事件过滤,是为了做一笔链上转账的监听需求。当时合约里每笔 Transfer 事件都会产生一条日志,区块里还夹杂着大量其他合约的日志。如果不做过滤,光靠遍历所有日志,数据量和解析复杂度都让人头大。ethers 的queryFilter、getLogs和监听接口,本质上就是提供了一套“日志检索 + 解码还原”的标准管道:先按条件框定范围,再把日志解析成可读结构。

这个能力解决的是几个层面的问题:

  • 定位精准:能按合约地址、事件签名(Topic0)、索引参数(Topic1/2/3)甚至区块高度范围来筛选,定位到非常具体的某类事件。
  • 性能可控:不需要拉取全量日志再做字符串匹配,而是在索引层面就开始筛选,效率高得多。
  • 解析还原:拿到原始日志后,ethers 能根据 ABI 自动解码,把十六进制数据还原成带字段名和类型的可读对象。
  • 场景丰富:既能做实时监听(等待新事件),也能回溯查询某个历史时间段的链上记录。

适合谁来读这篇内容?如果你正在写链上数据索引脚本、做钱包充值监控、做交易策略的信号触发,或者只是想把某个合约的历史事件拉下来做数据分析,这篇内容都能直接落地上手。下面的内容,我按从概念到实操再到踩坑的顺序展开,完全不绕弯子。

2. 四种过滤姿势:从接口到原理的完整认知

2.1 事件数据的底层存储机制:为什么过滤是必要的

要想理解事件过滤,先得知道日志在链上是怎么存的。每个事件日志包含几个关键部分:

字段含义是否可索引
address发出日志的合约地址是
topics[0]事件签名哈希,如 Transfer 的 Keccak-256是
topics[1..3]标记为 indexed 的参数是
data未标记 indexed 的参数,ABI 编码后拼接否

也就是说,链上日志的“索引能力”只覆盖三块:合约地址、事件签名、最多三个 indexed 参数。其他参数,就算是想过滤也过滤不了,只能拉回来再在内存里慢慢筛。这个底层机制直接决定了我们用 ethers 时能做什么过滤、不能做什么过滤。

比如一个典型的 ERC-20 转账事件:

event Transfer(address indexed from, address indexed to, uint256 value);

from和to都标记了indexed,所以它们会被放进 topics 里用于索引过滤;value没标记,只能放进 data 字段。要是你想“过滤出转账金额大于某个值的记录”,那就没法在查询层面做,只能把全部转账事件拉回本地再逐个解析判断。

到这里你应该明白了,事件过滤的边界从合约编写的那一刻就决定了。所以有些项目方在跑节点数据时,甚至会让合约开发把查询频率高的参数全部标注为indexed,就是为了后续数据检索方便。

2.2 QueryFilter:最常用的历史事件检索工具

queryFilter是 ethers 里查询历史事件的主力方法。它的使用场景是:已知某合约在某段区块范围内产生过事件,现在需要把符合条件的事件全部捞出来。

const contract = new ethers.Contract(contractAddress, abi, provider); const filter = contract.filters.Transfer(fromAddress, toAddress); const events = await contract.queryFilter(filter, startBlock, endBlock);

这里有几个关键参数:

  • filter:事件过滤器,由合约实例的filters方法生成。不传任何参数代表匹配该事件类型的所有日志。
  • startBlock/endBlock:区块范围,可传区块号,也可传字符串"latest"。如果不传,默认从当前最新块往前查,但需注意查询范围过大会导致 RPC 节点报错。
  • 返回值:一个Log对象数组,每个 Log 里包含blockNumber、transactionHash、args等字段。

从原理上讲,queryFilter底层走的是eth_getLogs这个 JSON-RPC 接口。节点拿到过滤条件后,在本地索引里做匹配,把符合条件的原始日志返回给客户端,然后 ethers 根据 ABI 把日志解码。所以它的效率上限取决于节点的索引能力和 RPC 接口的限制。

我在实际项目里常用queryFilter来做“增量归档”需求,比如每 5 分钟同步一次最新的充值记录,然后和数据库已有记录做去重合并。对于中小型项目,这套逻辑完全够用,不用自己搭索引服务。

2.3 GetLogs:绕过 ABI 直接操作原始日志

和queryFilter相比,provider.getLogs是更底层的方法。它不依赖 ABI,直接接受一个过滤器对象,返回的也是未经解码的原始日志。使用场景一般是:你还没拿到合约 ABI,或者只需要日志里的区块高度、交易哈希这类元信息,不想做完整解码。

const filter = { fromBlock: 18000000, toBlock: 18001000, address: contractAddress, topics: [ethers.id("Transfer(address,address,uint256)")] }; const logs = await provider.getLogs(filter);

构造过滤对象时,topics数组里第一个元素是事件签名的 Keccak-256 哈希,也就是对"Transfer(address,address,uint256)"做ethers.id()计算。如果还想限定 indexed 参数,就往 topics 里塞对应的值。

用getLogs的好处是轻量灵活,不需要先定义合约实例,也避开了合约调用中可能出现的各种约束。坏处是拿到手的 log 不会自动解析,data字段是一大串十六进制,你必须自己用ethers.AbiCoder手工解码,或者配合合约的parseLog方法来做。

我的习惯是:如果在脚本里只是统计一些事件数量、交易哈希范围,直接用getLogs就够;如果是业务代码里需要事件参数参与后续逻辑,就用queryFilter加合约解析。

2.4 实时监听与历史回填的组合:On 和 Once 的区别

除了查历史,另一大类需求是对新产生的事件做实时捕获。这时候contract.on和contract.once就派上用场了。

// 持续监听,每次新事件都会回调 contract.on(contract.filters.Transfer(), async (from, to, value, event) => { console.log(`检测到转账:${from} -> ${to}, 金额 ${ethers.formatEther(value)}`); }); // 只监听一次,事件触发后自动移除监听器 contract.once("Transfer", async (from, to, value, event) => { console.log("只处理一次转账事件"); });

使用监听时有一个最经典的坑:默认监听的起始位置是“当前最新区块”,你程序启动之前发生的所有事件都不会被监听到。所以实时监听的正确姿势是“先回填,再监听”:

  1. 启动时先确定一个合理的起始区块。
  2. 用queryFilter把这段时间内的事件回填处理一遍。
  3. 再调用on开始实时监听后续事件。

这里我建议回填和监听之间要有一个重叠区间,而不是刚好接上。因为 RPC 节点的最新区块和你本地的“最新”之间可能有延迟,处理不及时会漏事件。重叠多少块?我一般留 20~50 个区块,业务上完全能容忍重复处理,但漏一条就是事故。

2.5 工具选型对比:什么时候用哪种方案

学到这里,大家可能已经有感觉了,ethers 的事件过滤接口并不复杂,难点在于“在正确的场景里选正确的方法”。我把四种方案整理成一张对比表:

方法历史查询实时监听需要 ABI解析能力典型场景
queryFilter支持不支持需要自动解析历史事件回填、区间检索
getLogs支持不支持不需要需手工解析日志元信息统计、扩展查询
contract.on不支持支持需要自动解析持续事件订阅、业务实时通知
contract.once不支持支持需要自动解析一次性等待某事件(如交易确认)

一个典型的混合用法是:先用queryFilter把过去 7 天的充值记录全部拉出来处理一遍,然后调用contract.on开始监听新的充值事件,同时用一个定时任务每 5 分钟再做一次增量queryFilter,防止监听服务重启导致的漏单。三管齐下之后,数据完整性才说得上可靠。

3. 核心过滤规则详解:地址、Topic 与区块组合的实操

3.1 事件过滤器的本质:如何用 filters 方法构造条件

ethers 合约实例有一个很贴心的设计:每个事件都自动生成对应的filters方法,方法名和事件名完全一致,参数是可选的 indexed 参数值。也就是说,你传进去的值会变成过滤条件,不传就是通配。

// 监听所有 Transfer 事件 contract.filters.Transfer() // 监听某个地址作为转出方的 Transfer 事件 contract.filters.Transfer(senderAddress) // 监听某个地址作为接收方的 Transfer 事件 contract.filters.Transfer(null, recipientAddress) // 监听某个地址向另一个地址转账的 Transfer 事件 contract.filters.Transfer(senderAddress, recipientAddress)

注意第三行的null:只要参数位传了null,就表示这一位不做限定。其他位置的参数则必须是精确匹配的值,不支持模糊匹配或者大小比较。

那如果想要“获取某个地址所有相关交易,不区分是转出还是转入”,怎么办?答案是没有办法靠单个 filter 实现,需要用两个 filter 分别查询再合并去重:

const asSender = await contract.queryFilter(contract.filters.Transfer(sender), startBlock, endBlock); const asReceiver = await contract.queryFilter(contract.filters.Transfer(null, sender), startBlock, endBlock); const uniqueEvents = [...asSender, ...asReceiver].filter( (event, index, self) => self.findIndex(e => e.transactionHash === event.transactionHash) === index );

这个合并逻辑看起来不复杂,但实测下来非常实用,很多中心化交易所的内部对账系统就是这么拉数据的。

3.2 Topics 数组的排列规则:从原理层面理解过滤

用getLogs的时候,构造 topics 数组有一些需要死记但也很容易理解的规则。topics数组的索引和事件的 indexed 参数一一对应:

  • topics[0] 一定是事件签名哈希
  • topics[1] 对应第一个 indexed 参数
  • topics[2] 对应第二个 indexed 参数
  • topics[3] 对应第三个 indexed 参数

如果想构造“多个条件中的任意一个”,可以在对应位置放一个数组,例如:

const filter = { address: contractAddress, topics: [ ethers.id("Transfer(address,address,uint256)"), [ethers.getAddress(userA), ethers.getAddress(userB)] ] };

上面这个过滤器的含义是:只查 Transfer 事件,且转出方要么是 userA,要么是 userB。这种多值匹配在监控多个地址时特别好用,可以一个请求把多个地址的交易全拉出来,省下大量的 RPC 调用次数。

不过有一点要提醒:topics 里的地址必须做校验和格式转换。很多用户输入的地址是小写形式,直接放进 topics 会导致匹配不上,因为节点是按精确字节序列匹配的。安全做法是统一用ethers.getAddress()转一次。

3.3 区块高度的边界:数量限制与节点策略

所有历史查询都绕不开一个话题——区块范围的限制。公共 RPC 节点通常对一次eth_getLogs能覆盖的区块数量有限制,比如某些节点限制单次查询不得超过 10000 个区块,否则直接报错。

应对方式是按区块区间做分段扫描,每段范围控制在节点允许范围内,然后把结果拼接:

async function queryLogsInBatch(contract, filter, fromBlock, toBlock, batchSize = 5000) { let results = []; for (let start = fromBlock; start <= toBlock; start += batchSize) { const end = Math.min(start + batchSize - 1, toBlock); const logs = await contract.queryFilter(filter, start, end); results = results.concat(logs); } return results; }

这个批量查询函数我基本每次写数据脚本都会用到。原因很简单:你不分段,一个请求把 10 万块范围的日志全拉回来,大概率被节点拒掉;分段之后就稳了,只是请求次数变多。配合并发控制,比如同时最多发 5 个请求,整个流程既快又不会触发限流。

在批次大小的选择上,建议先测一下目标节点对eth_getLogs的限制。有的节点看的是日志条数,有的看的是区块跨度,有的两个都看。保守起见,5000 块一个批次在大多数公共节点上都没问题。

3.4 事件签名计算:那些看起来一样但哈希值完全不同的事件

再聊一个极其隐蔽的坑:事件签名的哈希计算不是简单字符串哈希,而是带括号、带类型的规范化字符串哈希。比如:

ethers.id("Transfer(address,address,uint256)")

这里的uint256不能写成uint,address不能写成address payable,参数顺序不能换。不同合约里同名但参数不同的事件,哈希值完全不一样。之前帮一个团队排查问题,他们的合约事件用的是自定义结构体参数,标准做法是先把结构体映射成元组字符串,再计算签名,一步写错整个查询结果就是空数组。

如果你不确定事件签名哈希是什么,可以让 ethers 帮你算:

const iface = new ethers.Interface(abi); const topic = iface.getEvent("Transfer").topicHash; console.log(topic); // 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef

使用getEvent从 ABI 里直接取 topicHash 是最保险的,比手写字符串再哈希更不容易出错。

4. 完整实操:从零搭建一个链上充值监控服务

4.1 定义场景和合约接口

你要知道,事件过滤不是孤立存在的,它一定是某个业务逻辑的输入源。这里我设计一个在真实开发中出现频率极高的场景,带着大家完整体验一遍:做一个“充值监控服务”,监控某个代币合约的 Transfer 事件,只要有人往某个归集地址转账,就自动记录并通知业务系统。

这是一个做钱包服务的团队几乎每天都要碰的需求。代币可能是 USDT 这类标准 ERC-20,也可能是项目自己的代币,但只要是标准 Transfer 事件,流程基本一致。

先定义需要监控的合约接口,这里我用一个最小化的 ABI 就够了:

const abi = [ "event Transfer(address indexed from, address indexed to, uint256 value)", "function decimals() view returns (uint8)" ];

这里有必要解释一下为什么 ABI 只包含事件和decimals函数。事件是为了告诉 ethers “你要监听和解码的东西长什么样”;decimals是为了后续把原始金额换算成人类可读的精度格式。ABI 不需要写全整个合约的所有函数,只写你用到的最小集合,足够让 ethers 正常工作,同时避免引入额外依赖。

4.2 初始化基础设施:Provider 与合约实例

const { ethers } = require("ethers"); const RPC_URL = process.env.RPC_URL; // 私有的或公共的节点地址 const TOKEN_ADDRESS = "0x..."; // 目标代币合约地址 const WALLET_ADDRESS = "0x..."; // 归集接收地址 const provider = new ethers.JsonRpcProvider(RPC_URL); const contract = new ethers.Contract(TOKEN_ADDRESS, abi, provider);

初始化这块有两个经验分享:

第一,Provider 建议用长连接的 WebSocket 而不是 HTTP。原因是充值监听对实时性要求高,HTTP 每次轮询都有延迟和额外请求开销,WebSocket 能实现真正的服务端推送,延迟可降到 1 秒以内。ethers 的WebSocketProvider用起来和 JsonRpcProvider 别无二致,切换成本极低。

第二,不建议把 RPC 地址硬编码在代码里。公共节点经常有并发和频率限制,如果你跑的服务长时间高频调用,很快会被限流。生产环境里,我一般会让团队申请一个专用节点服务,并配置好 API Key,通过环境变量注入。

4.3 历史事件回填:增量同步逻辑设计

实时监听前,先把“过去这段时间内遗漏的事件”处理完,否则服务一重启就有空窗期。我设计了这样一个回填流程:

const START_BLOCK = 18000000; // 业务上确定的起始块 async function syncHistoricalEvents() { const latestBlock = await provider.getBlockNumber(); console.log(`当前最新区块:${latestBlock}`); // 从上次同步到的区块+1开始,直到最新区块 const batchSize = 5000; let lastSyncedBlock = await getLastProcessedBlock(); // 从数据库读取 if (lastSyncedBlock === 0) { lastSyncedBlock = START_BLOCK; } for (let from = lastSyncedBlock + 1; from <= latestBlock; from += batchSize) { const to = Math.min(from + batchSize - 1, latestBlock); const events = await contract.queryFilter( contract.filters.Transfer(null, WALLET_ADDRESS), from, to ); for (const event of events) { await handleDepositEvent(event); } await saveLastProcessedBlock(to); console.log(`已同步至区块 ${to}`); } }

这里有一个容易被忽视的细节:同步状态必须持久化。如果程序在同步过程中崩溃,重启之后如果从头开始拉,会浪费大量时间和 RPC 额度;如果从数据库记录的最新块继续,就只补中间断开的部分。getLastProcessedBlock和saveLastProcessedBlock这两步是整个增量同步可靠性的命根子。

与此同时,注意我们用的是Transfer(null, WALLET_ADDRESS),表示“接收方为归集地址”的所有转账,这就精确锁定充值场景了。如果还要考虑归集地址自身转出等操作,则需要额外加一个过滤器并做去重,前面已经讲过合并办法。

4.4 实时监听与掉线重连:生产环境必须考虑的稳定性

回填完成之后,开始实时监听:

async function startRealtimeListener() { contract.on(contract.filters.Transfer(null, WALLET_ADDRESS), async (from, to, value, event) => { console.log(`[实时监听] 收到充值:${from} -> ${to}, 金额:${ethers.formatEther(value)}`); await handleDepositEvent(event); }); }

到这里,一个最简单的充值监控就通了。代码量不多,但生产环境里真正要跑得稳,还有几个问题是绕不过去的:

  • 监听器断线重连:WebSocket 连接不可能永远稳定,断线后监听会静默失效。我的做法是给 Provider 配置一个心跳检测,定期确认连接状态,失联后自动重建 Provider 和合约实例。
  • 回调并发控制:如果一段时间内充值特别多,回调函数可能同时触发多个实例。handleDepositEvent里如果有对数据库的写操作,需要加锁或使用数据库事务,防止插入重复记录。
  • 事件确认数:默认情况下,事件在区块打包后立即就会被推送。但如果这个区块之后发生了重组,事件可能被回滚。很多交易平台的做法是等待 12~24 个区块确认后再处理,稳妥为上。要拿到“已确认”事件,可以在回调里检查event.blockNumber和当前latestBlock的差值,再决定是否执行业务逻辑。

4.5 如何处理多地址多币种扩展

虽然上面只盯着一个地址一个币种,但真实业务往往是要同时监控多个钱包地址和多个代币。有两种扩展方式:

一是“单合约多地址”:对同一个代币合约,用多个 filter 构造条件,然后合并监听:

const filters = walletAddresses.flatMap((wallet) => [ contract.filters.Transfer(null, wallet), ]);

不过这里要提醒,contract.on对同一个合约的多个过滤器,实际内部还是多个独立注册。回调里需要判断args.to具体是哪个钱包。逻辑不算复杂,就是要注意别漏。

二是“多合约多地址”:初始化多个合约实例,统一封装一个事件处理管理器。每个合约实例各自监听,但事件回调统一进入一个队列,由中心处理器分发。这样代码清晰、扩展方便,新增币种只需新增一个合约实例和相应配置,业务处理层完全复用。

5. 监听常见隐患与排查手段:全是实在踩过的坑

事件过滤的 API 并不复杂,但真正上线跑起来,各种毛病就出来了。这些坑有的是 ethers 本身的限制,有的是对链上机制理解不够造成的,我把它们整理成一份排查表,并且每一条都写清楚来龙去脉。

现象根本原因解决办法
queryFilter 返回空数组,但链上明明有事件事件签名哈希不匹配 / 地址格式不一致用iface.getEvent("xxx").topicHash校验签名;对地址统一getAddress
查询超过几百个区块就报错RPC 节点对 getLogs 有区块跨度限制分段查询,5000 块一批,串行或并发拉取
监听启动后历史事件没有回调默认只监听新事件,不回溯历史先 queryFilter 回填,再 on 监听
事件里的地址是小写,导致过滤不到节点匹配精确字节序列构造过滤器前用getAddress转换成校验和格式
WebSocket 监听几天后失效连接被服务端或网络断开,未处理重连Provider 级心跳检测 + 重建合约实例
数据字段解析出来乱码事件 ABI 和链上实际事件不一致用解析接口parseLog对照校验,确认事件定义版本
重复收到同一条事件回填和监听区间重叠,未做去重以交易哈希或“区块+日志索引”为主键做去重
金额数值看起来特别大精度未做换算,代币 decimals 通常为 18用formatEther或按 decimals 除以 10 的幂

5.1 解析失败:为什么 parseLog 会抛错

有一次我在处理某个非标准合约时,queryFilter返回了日志,但parseLog直接抛异常。排查下来发现,这个合约的事件声明里有一个参数类型是uint128,而 ABI 里我却按uint256来解析。两者虽然数值上可能兼容,但严格来说 ABIs 不一致时 ethers 会拒绝解码。

正确做法是,遇到解析失败的日志,先把原始日志打印出来,对照合约源码检查 ABI 定义。很多时候问题不在于 ethers,而是 ABI 和合约代码对不上。

还有一种情况是合约升级了,旧日志的事件参数结构和当前 ABI 不一致。这种场景下可以考虑用版本化的 ABI 列表,从历史到新版本都保留一份,解析时按区块高度选择对应版本。听起来复杂,但经历过一次数据事故后,你就知道前期麻烦一点是值得的。

5.2 事件监听漏数据:三个不同层面的原因

数据漏了是最让人头疼的,往往等业务对账时才发现。我把漏数据的可能原因按层面拆开:

层面一:程序层面的漏。监听服务在某个时刻挂了或者断网了,重连之后没有对断开期间的数据做回补。解决办法前面已经说过了,启动时增量回填 + 定期兜底同步。

层面二:节点层面的漏。有人使用某些负载均衡的公共节点,请求被路由到了同步进度不同的节点上,导致不同请求看到的最新区块不一样。解决方法是固定使用单节点,或至少保证查询的节点状态一致。

层面三:链层面的漏。链上发生了区块重组,你以为那个区块已经稳了,但其实被回滚了。如果处理得早,数据库里已经有这条充值的记录,后面区块重组把这条记录抹掉了,最终结果是账目虚增。这方面没有完美解决办法,只能默认等待 12 个以上确认再入账;具体确认数可以根据链的实际情况调。

5.3 性能优化:并发查询与 RPC 限流之间的平衡

如果历史数据量很大,比如要从创始块开始同步某个热门代币的全部转账记录,那分段查询的请求数量是惊人的。假设 2 万块一批,以太坊近 2000 万块,就是一千次请求,每次返回大量数据。这还没算解析和数据落库的时间。

对此我的优化策略是分三层:

第一层,限制并发数。用p-limit或者自己实现一个简单的信号量,并发控制在 5~8 个,降低被限流概率。

第二层,合并请求。如果多个代币合约事件的数据分析需求同时触发,而它们有重叠的区块范围,尽量合并过滤器,一次请求把多个合约的日志拉回来,再在内存里分拣。

第三层,数据落库后走索引查询。链上查询只是数据管道的一环,真正高频的查询需求应该建立在本地的关系型数据库上。把事件数据解析成结构化行数据存起来,后续统计、报表全部走数据库,不再反复请求 RPC。

5.4 测试环境与主网环境的差异

测试链和主网的区块结构虽然类似,但公共测试节点往往能力更弱,对getLogs的限制更严格。我在测试环境调通的批次大小,可能在主网没问题,但在测试网上同样范围就直接报错。

更反直觉的是,有些测试网节点的日志索引不完整,导致某些确实存在的事件通过getLogs查不到。如果你在测试环境发现“查询条件明明没问题,但就是返回空”,先别怀疑代码,换一个节点试一下。这个坑说出来很基础,但真会卡你好几个小时。

6. 从经验谈扩展:事件过滤的上限取决于你的设计

学完了具体用法,还想分享一点大局观层面的理解。事件过滤是 ethers 里看似不大的一块功能,但它几乎是所有链上数据应用的基石。无论你是写一个简单的充值脚本,还是在设计一个完整的链上数据服务,过滤器设计得合理不合理,直接决定了后续系统的复杂度上限是多少。

有一次我重构一个监控系统时,发现原来的代码问题不在于查询效率,而在于对业务场景的建模不够清晰。代码里堆了一大堆过滤器,每个过滤器都是为某一个临时需求写的,没有统一管理。重构时我把所有过滤器按“事件类型 + 业务含义”组织起来,一个事件类型对应一个处理器,过滤逻辑全部从配置读取,代码量少了三分之一,排查问题的效率也上去了。

再做一次回望,有三个设计原则是我每次都会遵循的:

  • 过滤器尽量精确。不加限定条件的全量过滤器,虽然写起来简单,但会给下游解析和存储带来成倍的压力。能用 indexed 参数限定的,就别用全量再内存过滤。
  • 查询与业务解耦。历史回填、实时监听、增量同步,都是数据采集层的事,不要让业务逻辑散落在事件回调里。统一入口,统一日志,才能在生产环境里快速定位问题。
  • 数据确认策略前置。事件到达本地并不代表交易最终确认,尤其在低延迟场景下,必须把“待确认”“已确认”的状态设计在数据模型里,而不是后面再打补丁。

在实际测试里,一个设计良好的事件过滤服务,从新区块产生到业务系统收到通知,延迟可以控制在 2 秒以内。这个数字对于大多数场景都已经足够快,而且实现代码并不复杂——前提是你能理解 ethers 事件过滤的底层逻辑,并且提前把上面说过的这些坑都避掉。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询