Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@

## Perf

### 2026-06-18

- Skip non-matching blocks in `eth_getLogs` using the per-block header bloom, avoiding body/receipt loads for blocks that provably cannot match [#6813](https://github.com/lambdaclass/ethrex/pull/6813)

### 2026-06-15

- Pad `Code` bytecode with 33 trailing `STOP` bytes so the hot dispatch fetch and `pc` advance drop their bounds checks (~8% fewer instructions, ~13% fewer branches on dispatch). The logical length is tracked separately and `Code` is encapsulated so all consumers read the true length [#6866](https://github.com/lambdaclass/ethrex/pull/6866)
Expand Down
202 changes: 196 additions & 6 deletions crates/networking/rpc/eth/logs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use crate::{
},
utils::RpcErr,
};
use ethereum_types::{Bloom, BloomInput};
use ethrex_common::{H160, H256};
use ethrex_storage::Store;
use serde::Deserialize;
Expand Down Expand Up @@ -148,19 +149,25 @@ pub(crate) async fn fetch_logs_with_filter(
// and for each transaction, we'll need its receipts, which
// contain the actual logs we want.
for block_num in from..=to {
// Take the header of the block, we
// The block header carries a bloom filter over every (address, topic)
// pair logged in the block. If it can't possibly contain a log matching
// this filter, skip the block without loading its body or receipts.
let block_header = storage
.get_block_header(block_num)?
.ok_or(RpcErr::Internal(format!(
"Could not get header for block {block_num}"
)))?;
if !block_bloom_matches(&block_header.logs_bloom, &address_filter, &filter.topics) {
continue;
}
// Take the body of the block, we
// will use it to access the transactions.
let block_body = storage
.get_block_body(block_num)
.await?
.ok_or(RpcErr::Internal(format!(
"Could not get body for block {block_num}"
)))?;
let block_header = storage
.get_block_header(block_num)?
.ok_or(RpcErr::Internal(format!(
"Could not get header for block {block_num}"
)))?;
let block_hash = block_header.hash();

let mut block_log_index = 0_u64;
Expand Down Expand Up @@ -231,6 +238,47 @@ pub(crate) async fn fetch_logs_with_filter(
Ok(filtered_logs)
}

/// Necessary-condition check: returns `true` if the block's header bloom could
/// contain a log matching the filter, `false` only when it provably cannot.
///
/// A log matches when its address is one of the requested addresses (or none
/// were requested) AND, for every constrained topic position, the log's topic
/// equals one of the allowed values. Since the header bloom records every
/// logged address and topic (position-agnostic), a matching log implies its
/// address and each constrained topic are present in the bloom. We therefore
/// require: at least one requested address present (if any), and at least one
/// allowed topic present for each constrained position. Bloom false positives
/// are fine — exact filtering still runs on the blocks we don't skip.
fn block_bloom_matches(
bloom: &Bloom,
address_filter: &HashSet<&H160>,
topics: &[TopicFilter],
) -> bool {
if !address_filter.is_empty()
&& !address_filter
.iter()
.any(|address| bloom.contains_input(BloomInput::Raw(address.as_bytes())))
{
return false;
}

let topic_in_bloom = |topic: &H256| bloom.contains_input(BloomInput::Raw(topic.as_bytes()));
topics.iter().all(|topic_filter| match topic_filter {
// A wildcard position imposes no constraint.
TopicFilter::Topic(None) => true,
TopicFilter::Topic(Some(topic)) => topic_in_bloom(topic),
// An empty alternatives list, or one containing any `None`, is a
// wildcard for this position (the `None` means "any topic" — without
// it, `topics: [[null, T]]` would skip blocks matching via the wildcard
// and drop valid logs). Otherwise OR over the concrete alternatives.
TopicFilter::Topics(sub_topics) => {
sub_topics.is_empty()
|| sub_topics.iter().any(Option::is_none)
|| sub_topics.iter().flatten().any(topic_in_bloom)
}
})
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -280,4 +328,146 @@ mod tests {
);
assert_eq!(request.topics, vec![TopicFilter::Topic(Some(H256::zero()))]);
}

fn addr(n: u64) -> H160 {
H160::from_low_u64_be(n)
}

fn topic(n: u64) -> H256 {
H256::from_low_u64_be(n)
}

/// Builds a header bloom the same way the block producer does: by accruing
/// every address and topic of every log (see `bloom_from_logs`).
fn bloom_with(addresses: &[H160], topics: &[H256]) -> Bloom {
let mut bloom = Bloom::zero();
for address in addresses {
bloom.accrue(BloomInput::Raw(address.as_bytes()));
}
for topic in topics {
bloom.accrue(BloomInput::Raw(topic.as_bytes()));
}
bloom
}

fn addr_set(addresses: &[H160]) -> HashSet<&H160> {
addresses.iter().collect()
}

#[test]
fn bloom_match_empty_filter_always_matches() {
// No address and no topic constraints: never skip a block.
assert!(block_bloom_matches(&Bloom::zero(), &HashSet::new(), &[]));
}

#[test]
fn bloom_match_address_present_and_absent() {
let bloom = bloom_with(&[addr(1)], &[]);
assert!(block_bloom_matches(&bloom, &addr_set(&[addr(1)]), &[]));
assert!(!block_bloom_matches(&bloom, &addr_set(&[addr(2)]), &[]));
}

#[test]
fn bloom_match_multiple_addresses_is_or() {
let bloom = bloom_with(&[addr(1)], &[]);
// Only one of the requested addresses needs to be present.
assert!(block_bloom_matches(
&bloom,
&addr_set(&[addr(1), addr(2)]),
&[]
));
assert!(!block_bloom_matches(
&bloom,
&addr_set(&[addr(2), addr(3)]),
&[]
));
}

#[test]
fn bloom_match_topic_present_and_absent() {
let bloom = bloom_with(&[], &[topic(1)]);
assert!(block_bloom_matches(
&bloom,
&HashSet::new(),
&[TopicFilter::Topic(Some(topic(1)))]
));
assert!(!block_bloom_matches(
&bloom,
&HashSet::new(),
&[TopicFilter::Topic(Some(topic(2)))]
));
}

#[test]
fn bloom_match_wildcard_topic_ignored() {
// A `None` (wildcard) topic position imposes no constraint.
assert!(block_bloom_matches(
&Bloom::zero(),
&HashSet::new(),
&[TopicFilter::Topic(None)]
));
assert!(block_bloom_matches(
&Bloom::zero(),
&HashSet::new(),
&[TopicFilter::Topics(vec![])]
));
}

#[test]
fn bloom_match_topics_with_none_element_is_wildcard() {
// A `None` inside a `Topics([...])` alternatives list means "any topic"
// at this position, so the position is a wildcard and must not be skipped
// even when the sibling topic is absent from the bloom. Regression test for
// a false-negative that dropped valid logs for `topics: [[null, T]]` queries.
let bloom = bloom_with(&[], &[]); // contains neither topic
assert!(block_bloom_matches(
&bloom,
&HashSet::new(),
&[TopicFilter::Topics(vec![Some(topic(2)), None])]
));
}

#[test]
fn bloom_match_topic_position_is_or_across_positions_is_and() {
let bloom = bloom_with(&[], &[topic(1), topic(2)]);
// OR within a position: any allowed value present is enough.
assert!(block_bloom_matches(
&bloom,
&HashSet::new(),
&[TopicFilter::Topics(vec![Some(topic(2)), Some(topic(9))])]
));
// AND across positions: every constrained position must be satisfied.
assert!(block_bloom_matches(
&bloom,
&HashSet::new(),
&[
TopicFilter::Topic(Some(topic(1))),
TopicFilter::Topic(Some(topic(2))),
]
));
assert!(!block_bloom_matches(
&bloom,
&HashSet::new(),
&[
TopicFilter::Topic(Some(topic(1))),
TopicFilter::Topic(Some(topic(9))),
]
));
}

#[test]
fn bloom_match_requires_both_address_and_topic() {
let bloom = bloom_with(&[addr(1)], &[topic(1)]);
assert!(block_bloom_matches(
&bloom,
&addr_set(&[addr(1)]),
&[TopicFilter::Topic(Some(topic(1)))]
));
// Address matches but topic does not.
assert!(!block_bloom_matches(
&bloom,
&addr_set(&[addr(1)]),
&[TopicFilter::Topic(Some(topic(2)))]
));
}
}
Loading