eth_getLogs 允许你通过过滤发出合约地址和最多四个主题位置来检索以太坊事件日志。第一个主题始终是事件签名哈希;接下来的三个对应索引参数,它们以 32 字节哈希存储。要高效查询,你必须了解索引参数如何编码、如何组合过滤器与 OR 逻辑,以及如何对大型区块范围进行分页,因为提供商施加了自己的限制。本指南解释了机制,提供了可运行示例,并提供了故障排除清单。
直接回答:如何正确过滤事件日志
要使用 eth_getLogs 过滤以太坊事件日志,你发送一个 JSON-RPC 请求,其中包含 address(或地址数组)和 topics 数组。topics 数组最多可以有四个条目,每个条目匹配日志中相应的主题位置。第一个主题是事件签名哈希(例如,keccak256("Transfer(address,address,uint256)")),接下来的三个对应索引参数。使用 null 匹配该位置的任何值,或使用数组在该位置内进行 OR 操作。例如,要获取特定代币中发送者为特定地址的所有 ERC-20 Transfer 事件,你将第一个主题设置为 Transfer 签名哈希,第二个主题设置为发送者地址的左填充 32 字节。本指南详细介绍了机制,提供了可运行示例,并解释了如何处理提供商限制和分页。
如果你是以太坊 RPC 的新手,请参阅 以太坊网络概述 和 OnFinality Learn 中心 以获取基础知识。
事件日志和主题在底层如何工作
当智能合约发出事件时,以太坊虚拟机(EVM)在交易收据中记录一个日志条目。每个日志都有一个发出者地址(发出该事件的合约)、一个topics 数组和一个 data 字段。topics 数组包含最多四个 32 字节值:第一个始终是事件签名哈希,计算方式为 keccak256("EventName(type1,type2,...)"),其中类型是规范的 Solidity 类型(例如,address、uint256、bool)。其余三个主题对应于索引参数,按它们在事件声明中出现的顺序排列。索引参数存储为 32 字节值:对于值类型如 address 和 uint256,值左填充零到 32 字节;对于引用类型如 string、bytes 和数组,值是实际数据的 keccak256 哈希。非索引参数经过 ABI 编码并连接在 data 字段中。
此编码在 Solidity 事件文档 和 以太坊执行 API 规范中的 eth_getLogs 中有详细说明。理解这一点至关重要,因为你不能直接过滤非索引参数;你必须在检索后解码 data 字段。
eth_getLogs 方法接受一个 address 参数,可以是单个地址或地址数组(OR 逻辑)。topics 参数是一个最多包含四个过滤条目的数组。每个条目可以是单个 32 字节值、一个 32 字节值数组(在该位置内进行 OR),或 null 以匹配任何值。区块范围通过 fromBlock 和 toBlock 指定,可以是十六进制区块号或标签,如 latest、earliest 或 pending。如果省略,范围默认为 latest,这意味着只返回最新区块的日志——这是一个常见陷阱。
要深入了解日志如何存储以及为什么扫描缓慢,请参阅 Ethereum Stack Exchange 讨论 作为独立参考。
- 日志存储在交易收据中,而不是合约存储中。
- 第一个主题始终是事件签名哈希。
- 每个事件的索引参数限制为三个。
- 非索引参数在
data字段中,无法过滤。 - 主题中的地址左填充到 32 字节(例如,
0x0000...0000abc...)。
为 ERC-20 Transfer 事件构建正确的过滤器
让我们为 ERC-20 Transfer 事件构建一个过滤器。事件签名为 Transfer(address indexed from, address indexed to, uint256 value)。第一个主题是 keccak256("Transfer(address,address,uint256)"),即 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef。第二个主题是 from 地址(左填充),第三个是 to 地址。value 未被索引,因此它出现在 data 字段中。
要获取特定代币中来自特定发送者的所有转账,你将 address 设置为代币合约,第一个主题设置为签名哈希,第二个主题设置为发送者地址填充到 32 字节。例如,要获取 USDC 合约中来自 0x1234... 的转账,第二个主题将是 0x0000000000000000000000001234...(地址右对齐)。
如果你想同时过滤 from 和 to,你可以为第二个主题提供一个数组以 OR 多个发送者,或使用 null 匹配任何。例如,要获取来自或发送到特定地址的转账,你需要两次单独调用,因为 from 和 to 位于不同的主题位置,不能跨位置进行 OR。
以下是一个使用 curl 查询以太坊主网的具体示例(将 RPC URL 替换为你的提供商端点,例如来自 API 服务):
curl -X POST https://your-rpc-endpoint -H "Content-Type: application/json" --data '{
"jsonrpc": "2.0",
"method": "eth_getLogs",
"params": [{
"address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"topics": [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
"0x0000000000000000000000001234567890abcdef1234567890abcdef12345678"
],
"fromBlock": "0x1000000",
"toBlock": "0x1000100"
}],
"id": 1
}'理解响应结构和重组处理
eth_getLogs 的响应是一个日志对象数组。每个日志对象包含以下字段:address(发出日志的合约)、topics(32 字节主题数组)、data(十六进制编码的非索引数据)、blockNumber(十六进制)、transactionHash、transactionIndex、blockHash、logIndex 和 removed(一个布尔值,指示日志是否因链重组而被移除)。removed 标志对于实时跟踪日志的应用程序很重要:如果日志出现 removed: true,则表示该区块已被重组,日志不再有效。
日志按收据顺序返回,但不保证跨多个区块的全局排序。如果你需要按顺序处理日志,你应该按 blockNumber 和 logIndex 对它们进行排序。
以下是单个日志的示例响应:
{
"jsonrpc": "2.0",
"id": 1,
"result": [
{
"address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"topics": [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
"0x0000000000000000000000001234567890abcdef1234567890abcdef12345678",
"0x000000000000000000000000abcdefabcdefabcdefabcdefabcdefabcdefabcd"
],
"data": "0x0000000000000000000000000000000000000000000000000000000000000064",
"blockNumber": "0x1000100",
"transactionHash": "0x...",
"transactionIndex": "0x0",
"blockHash": "0x...",
"logIndex": "0x0",
"removed": false
}
]
}性能:为什么宽范围查询缓慢以及如何分页
使用 eth_getLogs 扫描宽区块范围很慢,因为节点必须遍历范围内的每个区块,加载区块的布隆过滤器,并检查请求的地址/主题是否可能匹配。这是一个 CPU 密集型操作,尤其是在归档节点上。提供商通常对每个请求返回的日志数量或区块跨度施加限制,以保护其基础设施。这些限制是文档化的 / 因提供商而异——你应该查看提供商的文档(例如,Alchemy 的 eth_getLogs 文档 或 Infura 的文档 作为独立参考)。
要处理大型查询,你应该通过分块区块范围进行分页。一种常见策略是以固定大小的块(例如 10,000 个区块)进行查询,然后如果响应已满,则从你收到的最后一个区块号继续。但是,由于日志不保证有序,更安全的方法是使用当前块的 toBlock 作为下一个块的 fromBlock,但减去 1 以避免重复。或者,你可以使用返回的最后一个日志的 blockNumber 作为下一个 fromBlock,但如果响应在块中间被截断,这可能会错过日志。
以下 Node.js 脚本演示了分块查询方法。它使用 fetch API,旨在让读者运行以测量他们自己提供商的限制。用你的观察填写下面的结果表。
- 块大小:从 10,000 个区块开始,并根据提供商限制进行调整。
- 如果你收到指示结果过多的错误,请减小块大小。
- 如果你实时处理日志,请始终处理
removed标志。 - 对于持续监控,考虑使用
eth_newFilter和eth_getFilterChanges而不是重复的eth_getLogs调用。
const rpcUrl = 'https://your-rpc-endpoint';
async function getLogs(fromBlock, toBlock) {
const response = await fetch(rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'eth_getLogs',
params: [{
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
topics: ['0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'],
fromBlock: '0x' + fromBlock.toString(16),
toBlock: '0x' + toBlock.toString(16)
}],
id: 1
})
});
const data = await response.json();
if (data.error) throw new Error(data.error.message);
return data.result;
}
async function fetchAllLogs(startBlock, endBlock, chunkSize) {
let allLogs = [];
let currentStart = startBlock;
while (currentStart <= endBlock) {
const currentEnd = Math.min(currentStart + chunkSize - 1, endBlock);
const logs = await getLogs(currentStart, currentEnd);
allLogs = allLogs.concat(logs);
console.log(`Fetched ${logs.length} logs from block ${currentStart} to ${currentEnd}`);
if (logs.length === 0) {
// No logs in this chunk, move to next
currentStart = currentEnd + 1;
} else {
// Continue from the last block number to avoid missing logs at the boundary
const lastBlock = parseInt(logs[logs.length - 1].blockNumber, 16);
currentStart = lastBlock + 1;
}
}
return allLogs;
}
// Example: fetch logs from block 16,000,000 to 16,100,000 with 10,000 block chunks
fetchAllLogs(16000000, 16100000, 10000).then(logs => {
console.log(`Total logs fetched: ${logs.length}`);
}).catch(err => console.error(err));结果表:测量你的提供商限制
使用不同的块大小运行上述脚本并记录结果。这将帮助你了解提供商的行为并调整分页策略。下表供你填写。
| 块大小(区块数) | 返回的日志数 | 是否遇到错误或限制? | 耗时(秒) |
|---------------------|-------------------------|---------------------|----------------|
| 10,000 | | | |
| 5,000 | | | |
| 1,000 | | | |故障排除清单:常见陷阱和修复
即使是经验丰富的开发人员在使用 eth_getLogs 时也会犯错。以下是常见问题及其修复方法的清单。
- 结果为空? 检查你是否指定了
fromBlock和toBlock。如果省略,两者默认为latest,这只会返回最新区块的日志。 - 主题哈希错误? 确保事件签名与声明完全一致,包括参数类型且没有空格。使用
keccak256计算哈希。例如,Transfer(address,address,uint256)而不是Transfer(address, address, uint256)。 - 地址不匹配? 记住索引地址参数左填充到 32 字节。主题应为
0x000000000000000000000000后跟 20 字节地址(不带0x)。 - 结果太多? 减少区块范围或使用更具体的主题。如果你收到类似 'query returned more than 10000 results' 的错误,你必须分页。
- 重组后日志丢失? 检查
removed标志。如果removed: true,日志不再有效,应丢弃。 - 非索引参数过滤? 你不能过滤非索引参数。检索日志并使用 ABI 解码器解码
data字段。 - 提供商特定限制? 查阅提供商的文档以了解最大区块范围和日志数量。这些限制是文档化的 / 因提供商而异。
限制和权衡:索引与非索引、存储与检索
在设计智能合约时,你必须决定将哪些事件参数标记为 indexed。索引参数允许高效过滤,但每个事件限制为三个,并产生额外的 gas 成本。非索引参数更便宜,但不能在链上过滤;你必须检索并解码它们。对于依赖事件日志进行索引或分析的应用来说,这种权衡至关重要。
另一个限制是 eth_getLogs 不保证跨区块的全局顺序。如果你需要按顺序处理日志,你必须按 blockNumber 和 logIndex 对它们进行排序。此外,日志不会永久存储在链上;除非你使用归档节点,否则它们会在一定时期后从完整节点中修剪。对于历史查询,你需要一个归档节点,如我们的指南 通过 RPC 查询以太坊历史状态 中所述。
对于实时监控,eth_newFilter 和 eth_getFilterChanges 比轮询 eth_getLogs 更高效,因为它们只返回自上次轮询以来的新日志。但是,过滤器是有状态的,并且可能在一段时间不活动后被节点丢弃。对于一次性历史查询,eth_getLogs 是正确的工具。
最后,请注意 eth_getLogs 在节点端可能代价高昂。如果你进行大量查询,请考虑使用专用的 RPC 提供商,如 OnFinality 的 API 服务 来处理负载。有关详细信息,请参阅我们的 RPC 定价。
- 索引参数:最多 3 个,可过滤,gas 成本较高。
- 非索引参数:无限制,不可过滤,gas 成本较低。
- 对于持续监控使用
eth_newFilter,对于一次性查询使用eth_getLogs。 - 超出修剪窗口的历史日志需要归档节点。
后续步骤:进一步阅读和相关指南
既然你了解了 eth_getLogs,你可以探索相关主题以加深你的以太坊 RPC 知识。例如,了解如何使用 eth_call 状态覆盖和模拟 模拟合约调用,或了解如何使用 EVM nonce 管理与 eth_getTransactionCount 管理 nonce。如果你担心延迟,请阅读我们的指南 以太坊 RPC 延迟和性能。
有关选择正确 RPC 节点的更广泛视角,请参阅 选择以太坊 RPC 节点(RPC Assistant)。如果你需要查询历史数据,我们的指南 通过 RPC 查询以太坊历史状态 是必不可少的。
请记住始终针对可靠的 RPC 端点测试你的查询。OnFinality 提供强大的以太坊 RPC 服务;查看我们的 API 服务 了解更多信息。