diff --git a/core/bloom_indexer.libevm.go b/core/bloom_indexer.libevm.go
new file mode 100644
index 0000000000..70bb86cd95
--- /dev/null
+++ b/core/bloom_indexer.libevm.go
@@ -0,0 +1,46 @@
+// Copyright 2026 the libevm authors.
+//
+// The libevm additions to go-ethereum are free software: you can redistribute
+// them and/or modify them under the terms of the GNU Lesser General Public License
+// as published by the Free Software Foundation, either version 3 of the License,
+// or (at your option) any later version.
+//
+// The libevm additions are distributed in the hope that they will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
+// General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with the go-ethereum library. If not, see
+// .
+
+package core
+
+import (
+ "github.com/ava-labs/libevm/core/types"
+ "github.com/ava-labs/libevm/ethdb"
+)
+
+// BloomThrottling is the time to wait between processing two consecutive index sections.
+const BloomThrottling = bloomThrottling
+
+// NewBloomIndexerBackend creates a [BloomIndexer] instance for the given database and section size,
+// allowing users to provide custom functionality to the bloom indexer.
+func NewBloomIndexerBackend(db ethdb.Database, size uint64) *BloomIndexer {
+ return &BloomIndexer{
+ db: db,
+ size: size,
+ }
+}
+
+// ProcessWithBloomOverride is the same as [BloomIndexer.Process], but takes the header and bloom separately.
+// This must obey the same invariates as [BloomIndexer.Process], including calling [BloomIndexer.Reset]
+// to start a new section prior to this call, otherwise this function will panic.
+func (b *BloomIndexer) ProcessWithBloomOverride(header *types.Header, bloom types.Bloom) error {
+ index := uint(header.Number.Uint64() - b.section*b.size)
+ if err := b.gen.AddBloom(index, bloom); err != nil {
+ return err
+ }
+ b.head = header.Hash()
+ return nil
+}
diff --git a/eth/bloombits.libevm.go b/eth/bloombits.libevm.go
new file mode 100644
index 0000000000..70b7604da7
--- /dev/null
+++ b/eth/bloombits.libevm.go
@@ -0,0 +1,67 @@
+// Copyright 2026 the libevm authors.
+//
+// The libevm additions to go-ethereum are free software: you can redistribute
+// them and/or modify them under the terms of the GNU Lesser General Public License
+// as published by the Free Software Foundation, either version 3 of the License,
+// or (at your option) any later version.
+//
+// The libevm additions are distributed in the hope that they will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
+// General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with the go-ethereum library. If not, see
+// .
+
+package eth
+
+import (
+ "github.com/ava-labs/libevm/core/bloombits"
+ "github.com/ava-labs/libevm/ethdb"
+)
+
+const (
+ // BloomFilterThreads is the number of goroutines used locally per filter to
+ // multiplex requests onto the global servicing goroutines.
+ BloomFilterThreads = bloomFilterThreads
+
+ // BloomRetrievalBatch is the maximum number of bloom bit retrievals to
+ // service in a single batch.
+ BloomRetrievalBatch = bloomRetrievalBatch
+
+ // BloomRetrievalWait is the maximum time to wait for enough bloom bit
+ // requests to accumulate request an entire batch (avoiding hysteresis).
+ BloomRetrievalWait = bloomRetrievalWait
+)
+
+// StartBloomHandlers starts a batch of goroutines to serve data for
+// [bloombits.Retrieval] requests from any number of filters. This is identical
+// to [Ethereum.startBloomHandlers], but exposed for independent use.
+func StartBloomHandlers(db ethdb.Database, sectionSize uint64) *BloomHandlers {
+ bh := &BloomHandlers{
+ Requests: make(chan chan *bloombits.Retrieval),
+ quit: make(chan struct{}),
+ }
+ eth := &Ethereum{
+ bloomRequests: bh.Requests,
+ closeBloomHandler: bh.quit,
+ chainDb: db,
+ }
+ eth.startBloomHandlers(sectionSize)
+ return bh
+}
+
+// BloomHandlers serve data for [bloombits.Retrieval] requests from any number
+// of filters. [BloomHandlers.Close] MUST be called to release goroutines, after
+// which a send on the requests channel will block indefinitely.
+type BloomHandlers struct {
+ Requests chan chan *bloombits.Retrieval
+ quit chan struct{}
+}
+
+// Close releases resources in use by the [BloomHandlers]; repeated calls will
+// panic.
+func (bh *BloomHandlers) Close() {
+ close(bh.quit)
+}
diff --git a/eth/bloombits.libevm_test.go b/eth/bloombits.libevm_test.go
new file mode 100644
index 0000000000..0e4dcba5a8
--- /dev/null
+++ b/eth/bloombits.libevm_test.go
@@ -0,0 +1,30 @@
+// Copyright 2026 the libevm authors.
+//
+// The libevm additions to go-ethereum are free software: you can redistribute
+// them and/or modify them under the terms of the GNU Lesser General Public License
+// as published by the Free Software Foundation, either version 3 of the License,
+// or (at your option) any later version.
+//
+// The libevm additions are distributed in the hope that they will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
+// General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with the go-ethereum library. If not, see
+// .
+
+package eth
+
+import (
+ "testing"
+
+ "go.uber.org/goleak"
+
+ "github.com/ava-labs/libevm/core/rawdb"
+)
+
+func TestStartBloomHandlersNoLeaks(t *testing.T) {
+ defer goleak.VerifyNone(t, goleak.IgnoreCurrent())
+ StartBloomHandlers(rawdb.NewMemoryDatabase(), 42).Close()
+}
diff --git a/eth/filters/filter.go b/eth/filters/filter.go
index 3f30c11a4b..9aa3b53955 100644
--- a/eth/filters/filter.go
+++ b/eth/filters/filter.go
@@ -290,7 +290,7 @@ func (f *Filter) unindexedLogs(ctx context.Context, end uint64, logChan chan *ty
// blockLogs returns the logs matching the filter criteria within a single block.
func (f *Filter) blockLogs(ctx context.Context, header *types.Header) ([]*types.Log, error) {
- if bloomFilter(header.Bloom, f.addresses, f.topics) {
+ if bloomFilter(maybeOverrideBloom(header, f.sys.backend), f.addresses, f.topics) {
return f.checkMatches(ctx, header)
}
return nil, nil
@@ -337,7 +337,7 @@ func (f *Filter) pendingLogs() []*types.Log {
if block == nil || receipts == nil {
return nil
}
- if bloomFilter(block.Bloom(), f.addresses, f.topics) {
+ if bloomFilter(maybeOverrideBloom(block.Header(), f.sys.backend), f.addresses, f.topics) {
var unfiltered []*types.Log
for _, r := range receipts {
unfiltered = append(unfiltered, r.Logs...)
diff --git a/eth/filters/filter_system.go b/eth/filters/filter_system.go
index f59a688a39..97b0e1d26f 100644
--- a/eth/filters/filter_system.go
+++ b/eth/filters/filter_system.go
@@ -508,7 +508,7 @@ func (es *EventSystem) lightFilterNewHead(newHeader *types.Header, callBack func
// filter logs of a single header in light client mode
func (es *EventSystem) lightFilterLogs(header *types.Header, addresses []common.Address, topics [][]common.Hash, remove bool) []*types.Log {
- if !bloomFilter(header.Bloom, addresses, topics) {
+ if !bloomFilter(maybeOverrideBloom(header, es.backend), addresses, topics) {
return nil
}
// Get the logs of the block
diff --git a/eth/filters/filter_system.libevm.go b/eth/filters/filter_system.libevm.go
new file mode 100644
index 0000000000..5762aa0065
--- /dev/null
+++ b/eth/filters/filter_system.libevm.go
@@ -0,0 +1,35 @@
+// Copyright 2026 the libevm authors.
+//
+// The libevm additions to go-ethereum are free software: you can redistribute
+// them and/or modify them under the terms of the GNU Lesser General Public License
+// as published by the Free Software Foundation, either version 3 of the License,
+// or (at your option) any later version.
+//
+// The libevm additions are distributed in the hope that they will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
+// General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with the go-ethereum library. If not, see
+// .
+
+package filters
+
+import (
+ "github.com/ava-labs/libevm/core/types"
+)
+
+// BloomOverrider is an optional extension to [Backend], allowing arbitrary
+// bloom filters to be returned for a header. If not implemented,
+// [types.Header.Bloom] is used instead.
+type BloomOverrider interface {
+ OverrideHeaderBloom(*types.Header) types.Bloom
+}
+
+func maybeOverrideBloom(header *types.Header, backend Backend) types.Bloom {
+ if bo, ok := backend.(BloomOverrider); ok {
+ return bo.OverrideHeaderBloom(header)
+ }
+ return header.Bloom
+}
diff --git a/eth/filters/filter_system.libevm_test.go b/eth/filters/filter_system.libevm_test.go
new file mode 100644
index 0000000000..c0de2c0da0
--- /dev/null
+++ b/eth/filters/filter_system.libevm_test.go
@@ -0,0 +1,91 @@
+// Copyright 2026 the libevm authors.
+//
+// The libevm additions to go-ethereum are free software: you can redistribute
+// them and/or modify them under the terms of the GNU Lesser General Public License
+// as published by the Free Software Foundation, either version 3 of the License,
+// or (at your option) any later version.
+//
+// The libevm additions are distributed in the hope that they will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
+// General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with the go-ethereum library. If not, see
+// .
+
+package filters
+
+import (
+ "math/big"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/ava-labs/libevm/core"
+ "github.com/ava-labs/libevm/core/rawdb"
+ "github.com/ava-labs/libevm/core/types"
+ "github.com/ava-labs/libevm/rpc"
+)
+
+type bloomOverriderBackend struct {
+ *testBackend
+ overridden chan struct{}
+}
+
+var _ BloomOverrider = (*bloomOverriderBackend)(nil)
+
+func (b *bloomOverriderBackend) OverrideHeaderBloom(header *types.Header) types.Bloom {
+ b.overridden <- struct{}{}
+ return header.Bloom
+}
+
+func TestBloomOverride(t *testing.T) {
+ db := rawdb.NewMemoryDatabase()
+ backend, sys := newTestFilterSystem(t, db, Config{})
+ sut := &bloomOverriderBackend{
+ testBackend: backend,
+ overridden: make(chan struct{}),
+ }
+ sys.backend = sut
+
+ t.Run("lightFilterLogs", func(t *testing.T) {
+ api := NewFilterAPI(sys, true /*lightMode*/)
+ defer CloseAPI(api)
+
+ id, err := api.NewFilter(FilterCriteria{})
+ require.NoErrorf(t, err, "%T.NewFilter()", api)
+ defer api.UninstallFilter(id)
+
+ // If there is no historical header then the filter system returns early.
+ for i := range int64(2) {
+ sut.chainFeed.Send(core.ChainEvent{
+ Block: types.NewBlockWithHeader(&types.Header{
+ Number: big.NewInt(i),
+ }),
+ })
+ }
+ <-sut.overridden
+ })
+
+ t.Run("blockLogs", func(t *testing.T) {
+ hdr := &types.Header{Number: big.NewInt(0)}
+ h := hdr.Hash()
+ rawdb.WriteHeader(db, hdr)
+ rawdb.WriteCanonicalHash(db, h, 0)
+ rawdb.WriteHeaderNumber(db, h, 0)
+
+ go sys.NewBlockFilter(h, nil, nil).Logs(t.Context()) //nolint:errcheck // Known but irrelevant error
+ <-sut.overridden
+ })
+
+ t.Run("pendingLogs", func(t *testing.T) {
+ hdr := &types.Header{Number: big.NewInt(1)}
+ sut.pendingBlock = types.NewBlockWithHeader(hdr)
+ sut.pendingReceipts = types.Receipts{}
+
+ n := rpc.PendingBlockNumber.Int64()
+ go sys.NewRangeFilter(n, n, nil, nil).Logs(t.Context()) //nolint:errcheck // Known but irrelevant error
+ <-sut.overridden
+ })
+}