From 571dd8332f01b3f2733a470e41c8a5f1e5f8f1ea Mon Sep 17 00:00:00 2001 From: locoholy Date: Tue, 24 Feb 2026 02:31:44 +0500 Subject: [PATCH] eth/filters: add optional getLogs limit support --- eth/filters/api.go | 16 ++++++-- eth/filters/api_test.go | 20 ++++++++++ eth/filters/filter.go | 23 ++++++++++- eth/filters/filter_test.go | 82 ++++++++++++++++++++++++++++++++++++++ interfaces.go | 5 +++ 5 files changed, 142 insertions(+), 4 deletions(-) diff --git a/eth/filters/api.go b/eth/filters/api.go index f4bed35b26..349f1ffdef 100644 --- a/eth/filters/api.go +++ b/eth/filters/api.go @@ -480,8 +480,12 @@ func (api *FilterAPI) GetLogs(ctx context.Context, crit FilterCriteria) ([]*type if begin >= 0 && begin < int64(api.events.backend.HistoryPruningCutoff()) { return nil, &history.PrunedHistoryError{} } - // Construct the range filter - filter = api.sys.NewRangeFilter(begin, end, crit.Addresses, crit.Topics, api.rangeLimit) + // Construct the range filter, respecting optional result-count limit + if crit.Limit != nil && *crit.Limit > 0 { + filter = api.sys.NewRangeFilterWithLimit(begin, end, crit.Addresses, crit.Topics, api.rangeLimit, *crit.Limit) + } else { + filter = api.sys.NewRangeFilter(begin, end, crit.Addresses, crit.Topics, api.rangeLimit) + } } // Run the filter and return all the logs @@ -489,7 +493,7 @@ func (api *FilterAPI) GetLogs(ctx context.Context, crit FilterCriteria) ([]*type if err != nil { return nil, err } - return returnLogs(logs), err + return returnLogs(logs), nil } // UninstallFilter removes the filter with the given filter id. @@ -620,6 +624,7 @@ func (args *FilterCriteria) UnmarshalJSON(data []byte) error { ToBlock *rpc.BlockNumber `json:"toBlock"` Addresses interface{} `json:"address"` Topics []interface{} `json:"topics"` + Limit *hexutil.Uint64 `json:"limit"` } var raw input @@ -718,6 +723,11 @@ func (args *FilterCriteria) UnmarshalJSON(data []byte) error { } } + if raw.Limit != nil { + limitVal := uint64(*raw.Limit) + args.Limit = &limitVal + } + return nil } diff --git a/eth/filters/api_test.go b/eth/filters/api_test.go index 822bc826f6..8255eed02b 100644 --- a/eth/filters/api_test.go +++ b/eth/filters/api_test.go @@ -29,6 +29,7 @@ func TestUnmarshalJSONNewFilterArgs(t *testing.T) { var ( fromBlock rpc.BlockNumber = 0x123435 toBlock rpc.BlockNumber = 0xabcdef + limit uint64 = 0x2 address0 = common.HexToAddress("70c87d191324e6712a591f304b4eedef6ad9bb9d") address1 = common.HexToAddress("9b2055d370f73ec7d8a03e965129118dc8f5bf83") topic0 = common.HexToHash("3ac225168df54212a25c1c01fd35bebfea408fdac2e31ddd6f80a4bbf9a5f1ca") @@ -53,6 +54,9 @@ func TestUnmarshalJSONNewFilterArgs(t *testing.T) { if len(test0.Topics) != 0 { t.Fatalf("expected 0 topics, got %d topics", len(test0.Topics)) } + if test0.Limit != nil { + t.Fatalf("expected nil limit, got %d", *test0.Limit) + } // from, to block number var test1 FilterCriteria @@ -66,6 +70,22 @@ func TestUnmarshalJSONNewFilterArgs(t *testing.T) { if test1.ToBlock.Int64() != toBlock.Int64() { t.Fatalf("expected ToBlock %d, got %d", toBlock, test1.ToBlock) } + if test1.Limit != nil { + t.Fatalf("expected nil limit, got %d", *test1.Limit) + } + + // limit + var testLimit FilterCriteria + vector = fmt.Sprintf(`{"limit":"0x%x"}`, limit) + if err := json.Unmarshal([]byte(vector), &testLimit); err != nil { + t.Fatal(err) + } + if testLimit.Limit == nil { + t.Fatal("expected non-nil limit") + } + if *testLimit.Limit != limit { + t.Fatalf("expected limit %d, got %d", limit, *testLimit.Limit) + } // single address var test2 FilterCriteria diff --git a/eth/filters/filter.go b/eth/filters/filter.go index 9915f28128..e36cc8556c 100644 --- a/eth/filters/filter.go +++ b/eth/filters/filter.go @@ -46,6 +46,7 @@ type Filter struct { rangeLogsTestHook chan rangeLogsTestEvent rangeLimit uint64 + limit uint64 // Maximum number of logs to return; 0 means no limit } // NewRangeFilter creates a new filter which uses a bloom filter on blocks to @@ -60,6 +61,14 @@ func (sys *FilterSystem) NewRangeFilter(begin, end int64, addresses []common.Add return filter } +// NewRangeFilterWithLimit creates a new filter with a maximum result count. +// When limit is non-zero, the returned result is capped to that many logs. +func (sys *FilterSystem) NewRangeFilterWithLimit(begin, end int64, addresses []common.Address, topics [][]common.Hash, rangeLimit, limit uint64) *Filter { + filter := sys.NewRangeFilter(begin, end, addresses, topics, rangeLimit) + filter.limit = limit + return filter +} + // NewBlockFilter creates a new filter which directly inspects the contents of // a block to figure out whether it is interesting or not. func (sys *FilterSystem) NewBlockFilter(block common.Hash, addresses []common.Address, topics [][]common.Hash) *Filter { @@ -149,7 +158,14 @@ func (f *Filter) Logs(ctx context.Context) ([]*types.Log, error) { if f.rangeLimit != 0 && (end-begin) > f.rangeLimit { return nil, fmt.Errorf("exceed maximum block range: %d", f.rangeLimit) } - return f.rangeLogs(ctx, begin, end) + logs, err := f.rangeLogs(ctx, begin, end) + if err != nil { + return nil, err + } + if f.limit > 0 && uint64(len(logs)) > f.limit { + logs = logs[:f.limit] + } + return logs, nil } const ( @@ -451,6 +467,11 @@ func (f *Filter) unindexedLogs(ctx context.Context, chainView *filtermaps.ChainV return matches, err } matches = append(matches, found...) + // Early exit once the limit is reached on the unindexed path, avoiding + // a full raw-block scan when callers only need the first N matches. + if f.limit > 0 && uint64(len(matches)) >= f.limit { + break + } } log.Debug("Performed unindexed log search", "begin", begin, "end", end, "matches", len(matches), "elapsed", common.PrettyDuration(time.Since(start))) return matches, nil diff --git a/eth/filters/filter_test.go b/eth/filters/filter_test.go index 63727200f7..57a47291c7 100644 --- a/eth/filters/filter_test.go +++ b/eth/filters/filter_test.go @@ -131,6 +131,88 @@ func TestFiltersUnindexed(t *testing.T) { testFilters(t, 0, true) } +func TestFiltersIndexedWithLimit(t *testing.T) { + testFiltersWithLimit(t, 0, false) +} + +func TestFiltersHalfIndexedWithLimit(t *testing.T) { + testFiltersWithLimit(t, 500, false) +} + +func TestFiltersUnindexedWithLimit(t *testing.T) { + testFiltersWithLimit(t, 0, true) +} + +func testFiltersWithLimit(t *testing.T, history uint64, noHistory bool) { + const ( + totalBlocks = 1000 + queryLimit uint64 = 2 + ) + + var ( + db = rawdb.NewMemoryDatabase() + backend, sys = newTestFilterSystem(db, Config{}) + target = common.BytesToAddress([]byte("target")) + other = common.BytesToAddress([]byte("other")) + gspec = &core.Genesis{ + BaseFee: big.NewInt(params.InitialBaseFee), + Config: params.TestChainConfig, + } + ) + defer db.Close() + + _, err := gspec.Commit(db, triedb.NewDatabase(db, nil), nil) + if err != nil { + t.Fatal(err) + } + addReceiptLog := func(gen *core.BlockGen, nonce uint64, addr common.Address) { + gen.AddUncheckedReceipt(makeReceipt(addr)) + gen.AddUncheckedTx(types.NewTransaction(nonce, common.HexToAddress("0x999"), big.NewInt(1), 21000, gen.BaseFee(), nil)) + } + chain, receipts := core.GenerateChain(gspec.Config, gspec.ToBlock(), ethash.NewFaker(), db, totalBlocks, func(i int, gen *core.BlockGen) { + switch i { + case 2, 5, 900: + addReceiptLog(gen, 999, target) + case 990: + addReceiptLog(gen, 1000, other) + } + }) + for i, block := range chain { + rawdb.WriteBlock(db, block) + rawdb.WriteCanonicalHash(db, block.Hash(), block.NumberU64()) + rawdb.WriteHeadBlockHash(db, block.Hash()) + rawdb.WriteReceipts(db, block.Hash(), block.NumberU64(), receipts[i]) + } + backend.startFilterMaps(history, noHistory, filtermaps.DefaultParams) + defer backend.stopFilterMaps() + + allFilter := sys.NewRangeFilter(0, int64(rpc.LatestBlockNumber), []common.Address{target}, nil, 0) + allLogs, err := allFilter.Logs(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(allLogs) != 3 { + t.Fatalf("expected 3 total logs, got %d", len(allLogs)) + } + + limitedFilter := sys.NewRangeFilterWithLimit(0, int64(rpc.LatestBlockNumber), []common.Address{target}, nil, 0, queryLimit) + limitedLogs, err := limitedFilter.Logs(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(limitedLogs) != int(queryLimit) { + t.Fatalf("expected %d logs with limit, got %d", queryLimit, len(limitedLogs)) + } + if limitedLogs[0].BlockNumber != 3 || limitedLogs[1].BlockNumber != 6 { + t.Fatalf("unexpected limited block numbers: got [%d %d], want [3 6]", limitedLogs[0].BlockNumber, limitedLogs[1].BlockNumber) + } + for i, log := range limitedLogs { + if log.Address != target { + t.Fatalf("unexpected address in limited log #%d: got %x, want %x", i, log.Address, target) + } + } +} + func testFilters(t *testing.T, history uint64, noHistory bool) { var ( db = rawdb.NewMemoryDatabase() diff --git a/interfaces.go b/interfaces.go index 21d42c6d34..a08a762698 100644 --- a/interfaces.go +++ b/interfaces.go @@ -205,6 +205,11 @@ type FilterQuery struct { // {{A}, {B}} matches topic A in first position AND B in second position // {{A, B}, {C, D}} matches topic (A OR B) in first position AND (C OR D) in second position Topics [][]common.Hash + + // Limit caps the maximum number of logs returned. + // When non-nil and non-zero, at most Limit matching logs are returned. + // Example: set Limit to 1 to receive a single matching event. + Limit *uint64 } // LogFilterer provides access to contract log events using a one-off query or continuous