Repository: rotki/rotki

Issue:
Rethink filtering history events for non-premium
## Abstract

At the moment we have disabled most filtering for non-premium users in the history events page. The reason for that is not business driven, but technical.

It was realized during this PR: https://github.com/rotki/rotki/pull/6105

Essentially when performing paginated filter queries with the new method we use where we define an event as the group of common event_identifiers we can't apply a limit **and** a filter at the same time.

The limit can either be applied after the filtering, in which case it makes no sense anymore since it's no longer an event limit but a filter limit and then by simple pagination or date range users can get all events.

Or it can be applied before filtering as is done here: https://github.com/rotki/rotki/blob/6b1a538ca6a1ec235933b0d3936698be9ebc72ee/rotkehlchen/db/history_events.py#L403

The problem with this approach is that the filter is then applied to the GROUP BY limited query. And that query only includes the first event of each group. So if say you have an event group of a transaction of sending 10 DAI to someone. The first event is the gas burning. If you filter by DAI asset it won't work as the filter would only be applied in the first event, the gas burning.

This problem occurs only on the not premium part of the above code as that's the only part where a number limitation on the overall events (meaning grouped by events) needs to be applied.

Before https://github.com/rotki/rotki/pull/6105, when the limit was not on the grouped events, but on the individual events that problem did not exist. But then we had a more serious problem of pagination not working as, say a limit of 100 events would not be 10x10 pages, but depending on events per group would be maybe ~70 and pagination was off.

This is what that PR fixed. 

## Task

Now the problem here is that by fixing pagination and limiting  for free users the filtering no longer works. Which was an acceptable choice for 1.28.

The problem is we want to go toward tiered premium, where there will be different limits dependin on the tier. We can't have filtering off there on any but the lowest tier. We generally need a way to have both a limit **and** filtering and have it all applied to the grouped by events.

So think about this and come up with a proper solution.

Change this test to use both flags when fixed: https://github.com/rotki/rotki/pull/6105/files#diff-1bf17d119fd2bf5ac8b87baa10349638966aecfc5309660d1675564616845c9aR1312



Discussion:
So for this I have some thoughts that I will probably edit in the future depending on how they evolve.

I would like to add a new table `event_groups` which will group all related events like we do right now with the `event_identifier`. The `event_identifier` should be renamed to `group_identifier`.

The new table should contain common things for all events belonging to one group. Such as:
- A Title/description (thiis is new) -- we could choose to show it in the UI at the group's header
- The timestamp of the event.
- The group identifier

Searching/querying for events will now happen through this table with joins for other filters. The biggest question mark is the joining as it may be slower or faster -- depending on how it's done.

Resolving patch:
diff --git a/rotkehlchen/db/filtering.py b/rotkehlchen/db/filtering.py
--- a/rotkehlchen/db/filtering.py
+++ b/rotkehlchen/db/filtering.py
@@ -312,7 +312,7 @@ def prepare(
             with_pagination: bool = True,
             with_order: bool = True,
             with_group_by: bool = False,
-            special_free_query: bool = False,
+            without_ignored_asset_filter: bool = False,
     ) -> tuple[str, list[Any]]:
         """Prepares a filter by converting the filters to a query string
 
@@ -320,9 +320,9 @@ def prepare(
         - with_pagination: Use or not the pagination filters
         - with_order: Use or not the order by filters
         - with_group_by: Use or not the group by filters
-        - special_free_query: This is only for history events query and since we have quite
-        a complicated query in the backend to count the limited grouped history events, there is
-        no need to rerun the ignored assets filter as it's already part of the inner
+        - without_ignored_asset_filter: This is only for history events query and since we have
+        quite a complicated query in the backend to count the limited grouped history events, there
+        is no need to rerun the ignored assets filter as it's already part of the inner
         query in order to not count ignored/spam assets in the limit of free events.
         TODO: Quite hacky. Improve this.
         """
@@ -336,7 +336,7 @@ def prepare(
             bindings.extend(single_bindings)
 
         for fil in self.filters:
-            if special_free_query and isinstance(fil, DBIgnoredAssetsFilter):
+            if without_ignored_asset_filter and isinstance(fil, DBIgnoredAssetsFilter):
                 continue  # skip subtable select filter as it's already applied
 
             filters, single_bindings = fil.prepare()
diff --git a/rotkehlchen/db/history_events.py b/rotkehlchen/db/history_events.py
--- a/rotkehlchen/db/history_events.py
+++ b/rotkehlchen/db/history_events.py
@@ -66,15 +66,20 @@
 
 def maybe_filter_ignore_asset(
         filter_query: HistoryBaseEntryFilterQuery,
+        include_ignored_assets: bool = False,
 ) -> str:
     """An auxiliar function to find if query_filter contains `DBIgnoredAssetsFilter`. If it does
     then return that filter clause. This is done where we want to filter ignored assets
-    before applying the free limit."""
+    before applying the free limit. If `include_ignored_assets` is true then the filter is returned
+    to include them."""
     for fil in filter_query.filters:
         if isinstance(fil, DBIgnoredAssetsFilter):
             # Also don't count spam asset transactions in the limit
-            return 'WHERE (asset IS NULL OR asset NOT IN (SELECT value FROM multisettings WHERE name="ignored_asset")) '  # noqa: E501
-    return ' '
+            ignored_asset_subquery = 'SELECT value FROM multisettings WHERE name="ignored_asset")'
+            if include_ignored_assets:
+                return f'WHERE (asset IN ({ignored_asset_subquery}) '
+            return f'WHERE (asset IS NULL OR asset NOT IN ({ignored_asset_subquery}) '
+    return ''
 
 
 class DBHistoryEvents:
@@ -310,6 +315,46 @@ def get_evm_event_by_identifier(self, identifier: int) -> Optional['EvmEvent']:
 
         return deserialized
 
+    def _create_history_events_query(
+            self,
+            filter_query: HistoryBaseEntryFilterQuery,
+            entries_limit: int,
+            has_premium: bool,
+            group_by_event_ids: bool = False,
+    ) -> tuple[str, list]:
+        """Returns the sql queries and bindings for the history events without pagination."""
+        base_suffix = f'{HISTORY_BASE_ENTRY_FIELDS}, {EVM_EVENT_FIELDS}, {ETH_STAKING_EVENT_FIELDS} {ALL_EVENTS_DATA_JOIN}'  # noqa: E501
+        if (ignore_asset_filter := maybe_filter_ignore_asset(filter_query, include_ignored_assets=True)) != '':  # noqa: E501
+            ignore_asset_filter = (
+                f' WHERE event_identifier NOT IN '
+                f'(SELECT DISTINCT event_identifier FROM history_events {ignore_asset_filter})'
+            )
+
+        premium_base_suffix = f'{base_suffix} {ignore_asset_filter}'
+        free_base_suffix = (
+            f'* FROM (SELECT {base_suffix}) WHERE event_identifier IN ('
+            f'SELECT DISTINCT event_identifier FROM history_events {ignore_asset_filter} '
+            'ORDER BY timestamp DESC,sequence_index ASC LIMIT ?)'  # free query only select the last LIMIT groups  # noqa: E501
+        )
+
+        if has_premium:
+            suffix, limit = premium_base_suffix, []
+        else:
+            suffix, limit = free_base_suffix, [entries_limit]
+
+        if group_by_event_ids:
+            filters, query_bindings = filter_query.prepare(
+                with_group_by=True,
+                with_pagination=False,
+                without_ignored_asset_filter=True,
+            )
+            prefix = 'SELECT COUNT(*), *'
+        else:
+            filters, query_bindings = filter_query.prepare(with_pagination=False)
+            prefix = 'SELECT *'
+
+        return f'{prefix} FROM (SELECT {suffix}) {filters}', limit + query_bindings
+
     @overload
     def get_history_events(
             self,
@@ -397,40 +442,19 @@ def get_history_events(
         TODO: To not query all columns with all joins for all cases, we perhaps can
         peek on the entry type of the filter and adjust the SELECT fields accordingly?
         """
-        base_prefix = 'SELECT '
-        base_suffix = f'{HISTORY_BASE_ENTRY_FIELDS}, {EVM_EVENT_FIELDS}, {ETH_STAKING_EVENT_FIELDS} {ALL_EVENTS_DATA_JOIN}'  # noqa: E501
-        bindings = []
-        type_idx = 1 if group_by_event_ids is True else 0
-        special_free_query = False
-        free_query_group_by = maybe_filter_ignore_asset(filter_query)
-
-        if has_premium is True:
-            if group_by_event_ids is True:
-                base_prefix += 'COUNT(*), '
-            base_query = f'{base_prefix} {base_suffix}'
-        else:
-            if group_by_event_ids is True:
-                special_free_query = True  # a bit ugly conditions to keep limit at groups for free users  # noqa: E501
-                free_query_group_by += 'GROUP BY event_identifier'
-                base_query = (
-                    f'{base_prefix} * FROM (SELECT COUNT(*), {base_suffix} '
-                    f'{free_query_group_by} ORDER BY timestamp DESC, sequence_index ASC LIMIT ?) '
-                )
-            else:  # if we don't group them but still apply free limit, then apply this limit only on any event_identifiers that fall inside the limit  # noqa: E501
-                base_query = (
-                    f'{base_prefix} * FROM (SELECT {base_suffix} WHERE event_identifier IN '
-                    f'(SELECT DISTINCT event_identifier FROM history_events {free_query_group_by} ORDER BY timestamp DESC, sequence_index ASC LIMIT ?)) '  # noqa: E501
-                )
-            bindings = [FREE_HISTORY_EVENTS_LIMIT]
-
-        prepared_query, prepared_bindings = filter_query.prepare(
-            with_group_by=group_by_event_ids,
-            special_free_query=special_free_query,
+        base_query, filters_bindings = self._create_history_events_query(
+            has_premium=has_premium,
+            filter_query=filter_query,
+            group_by_event_ids=group_by_event_ids,
+            entries_limit=FREE_HISTORY_EVENTS_LIMIT,
         )
-        bindings.extend(prepared_bindings)
 
-        cursor.execute(base_query + prepared_query, bindings)
+        if filter_query.pagination is not None:
+            base_query = f'SELECT * FROM ({base_query}) {filter_query.pagination.prepare()}'
+
+        cursor.execute(base_query, filters_bindings)
         output: list[HistoryBaseEntry] | list[tuple[int, HistoryBaseEntry]] = []
+        type_idx = 1 if group_by_event_ids else 0
         data_start_idx = type_idx + 1
         for entry in cursor:
             entry_type = HistoryBaseEntryType(entry[type_idx])
@@ -642,36 +666,32 @@ def get_history_events_count(
         the number of events if any limit is applied, otherwise the second value matches
         the first.
         """
-        prepared_query, bindings = query_filter.prepare(with_pagination=False)
-        # we need to select everything because any column could be used in the filter
-        query = f'SELECT {query_filter.get_columns()} {query_filter.get_join_query()} {prepared_query}'  # noqa: E501
-        if group_by_event_ids:
-            query = f'SELECT event_identifier FROM ({query}) GROUP BY event_identifier'
-        query = f'SELECT COUNT(*) FROM ({query})'
-        count_without_limit = cursor.execute(query, bindings).fetchone()[0]
-
-        if entries_limit is not None:
-            prepared_query, bindings = query_filter.prepare(
-                with_group_by=False,
-                special_free_query=True,
-                with_order=False,
-                with_pagination=False,
-            )
+        free_limit = FREE_HISTORY_EVENTS_LIMIT if entries_limit is None else entries_limit
+        premium_query, premium_bindings = self._create_history_events_query(
+            has_premium=True,
+            filter_query=query_filter,
+            group_by_event_ids=group_by_event_ids,
+            entries_limit=free_limit,
+        )
+        count_without_limit = cursor.execute(
+            f'SELECT COUNT(*) FROM ({premium_query})',
+            premium_bindings,
+        ).fetchone()[0]
+
+        if entries_limit is None:
+            return count_without_limit, count_without_limit
 
-            free_query_group_by = maybe_filter_ignore_asset(query_filter)
-            group_by_query = ''
-            if group_by_event_ids:
-                group_by_query = 'GROUP BY event_identifier ORDER BY timestamp DESC, sequence_index ASC LIMIT ?'  # noqa: E501
-                bindings.insert(0, entries_limit)  # add limit's binding before prepared_query's bindings  # noqa: E501
-            count_with_limit = cursor.execute(
-                f'SELECT COUNT(*) FROM ('
-                f'SELECT {query_filter.get_columns()} {query_filter.get_join_query()}{free_query_group_by}'  # we take the groups before the limit has been applied  # noqa: E501
-                f'{group_by_query}){prepared_query}',
-                bindings,
-            ).fetchone()[0]
-            return count_without_limit, count_with_limit
-
-        return count_without_limit, count_without_limit
+        free_query, free_bindings = self._create_history_events_query(
+            has_premium=False,
+            filter_query=query_filter,
+            group_by_event_ids=group_by_event_ids,
+            entries_limit=free_limit,
+        )
+        count_with_limit = cursor.execute(
+            f'SELECT COUNT(*) FROM ({free_query})',
+            free_bindings,
+        ).fetchone()[0]
+        return count_without_limit, count_with_limit
 
     def get_value_stats(
             self,


Test patch:
diff --git a/rotkehlchen/tests/api/test_ethereum_transactions.py b/rotkehlchen/tests/api/test_ethereum_transactions.py
--- a/rotkehlchen/tests/api/test_ethereum_transactions.py
+++ b/rotkehlchen/tests/api/test_ethereum_transactions.py
@@ -1242,8 +1242,8 @@ def test_ignored_assets(rotkehlchen_api_server, ethereum_accounts):
     tx3 = make_ethereum_transaction(timestamp=3)
     event1 = make_ethereum_event(tx_hash=tx1.tx_hash, index=1, asset=A_ETH, timestamp=TimestampMS(1))  # noqa: E501
     event2 = make_ethereum_event(tx_hash=tx1.tx_hash, index=2, asset=A_BTC, timestamp=TimestampMS(1))  # noqa: E501
-    event3 = make_ethereum_event(tx_hash=tx1.tx_hash, index=3, asset=A_MKR, timestamp=TimestampMS(1))  # noqa: E501
-    event4 = make_ethereum_event(tx_hash=tx2.tx_hash, index=4, asset=A_DAI, timestamp=TimestampMS(2))  # noqa: E501
+    event3 = make_ethereum_event(tx_hash=tx2.tx_hash, index=3, asset=A_MKR, timestamp=TimestampMS(1))  # noqa: E501
+    event4 = make_ethereum_event(tx_hash=tx3.tx_hash, index=4, asset=A_DAI, timestamp=TimestampMS(2))  # noqa: E501
     with db.user_write() as cursor:
         dbevmtx.add_evm_transactions(cursor, [tx1, tx2, tx3], relevant_address=ethereum_accounts[0])  # noqa: E501
         dbevents.add_history_events(cursor, [event1, event2, event3, event4])
@@ -1254,8 +1254,8 @@ def test_ignored_assets(rotkehlchen_api_server, ethereum_accounts):
             'exclude_ignored_assets': False,
             'location': 'ethereum',
         },
-        expected_num_with_grouping=2,
-        expected_totals_with_grouping=2,
+        expected_num_with_grouping=3,
+        expected_totals_with_grouping=3,
         entries_limit=100,
     )
     expected = generate_events_response([event4, event1, event2, event3])
@@ -1265,10 +1265,10 @@ def test_ignored_assets(rotkehlchen_api_server, ethereum_accounts):
         rotkehlchen_api_server,  # test that default exclude_ignored_assets is True
         json={'location': 'ethereum'},
         expected_num_with_grouping=1,
-        expected_totals_with_grouping=2,
+        expected_totals_with_grouping=3,
         entries_limit=100,
     )
-    expected = generate_events_response([event1, event3])
+    expected = generate_events_response([event3])
     assert returned_events == expected
 
 
diff --git a/rotkehlchen/tests/db/test_history_events.py b/rotkehlchen/tests/db/test_history_events.py
--- a/rotkehlchen/tests/db/test_history_events.py
+++ b/rotkehlchen/tests/db/test_history_events.py
@@ -1,4 +1,5 @@
 from typing import Any
+from unittest.mock import patch
 
 import pytest
 
@@ -6,7 +7,7 @@
 from rotkehlchen.api.v1.types import IncludeExcludeFilterData
 from rotkehlchen.chain.evm.types import string_to_evm_address
 from rotkehlchen.constants import ONE
-from rotkehlchen.constants.assets import A_ETH
+from rotkehlchen.constants.assets import A_ETH, A_USDC, A_USDT
 from rotkehlchen.constants.limits import FREE_HISTORY_EVENTS_LIMIT
 from rotkehlchen.db.constants import HISTORY_MAPPING_KEY_STATE, HISTORY_MAPPING_STATE_CUSTOMIZED
 from rotkehlchen.db.dbhandler import DBHandler
@@ -311,3 +312,184 @@ def test_delete_last_event(database):
     assert 'was the last event of a transaction' in msg
     with db.db.conn.read_ctx() as cursor:
         assert len(db.get_history_events(cursor, HistoryEventFilterQuery.make(), True)) == 1, 'EVM event should be left'  # noqa: E501
+
+
+def test_get_history_events_free_filter(database: 'DBHandler'):
+    """Test that the history events filter works consistently with has_premium=True/False"""
+    history_events = DBHistoryEvents(database=database)
+    event_identifiers = [make_evm_tx_hash().hex() for _ in range(5)]  # pylint: disable=no-member
+    dummy_events = (
+        HistoryEvent(
+            event_identifier=event_identifiers[0],
+            sequence_index=0,
+            timestamp=TimestampMS(1000),
+            location=Location.OPTIMISM,
+            event_type=HistoryEventType.TRADE,
+            event_subtype=HistoryEventSubType.NONE,
+            asset=A_ETH,
+            balance=Balance(ONE),
+        ), HistoryEvent(
+            event_identifier=event_identifiers[1],
+            sequence_index=0,
+            timestamp=TimestampMS(2000),
+            location=Location.OPTIMISM,
+            event_type=HistoryEventType.TRADE,
+            event_subtype=HistoryEventSubType.NONE,
+            asset=A_ETH,
+            balance=Balance(FVal(2)),
+        ), HistoryEvent(
+            event_identifier=event_identifiers[1],
+            sequence_index=1,
+            timestamp=TimestampMS(2000),
+            location=Location.COINBASE,
+            event_type=HistoryEventType.RECEIVE,
+            event_subtype=HistoryEventSubType.NONE,
+            asset=A_USDC,
+            balance=Balance(FVal(1000)),
+        ), HistoryEvent(
+            event_identifier=event_identifiers[2],
+            sequence_index=0,
+            timestamp=TimestampMS(3000),
+            location=Location.BLOCKCHAIN,
+            event_type=HistoryEventType.SPEND,
+            event_subtype=HistoryEventSubType.NONE,
+            asset=A_USDT,
+            balance=Balance(FVal(5)),
+        ), HistoryEvent(
+            event_identifier=event_identifiers[2],
+            sequence_index=1,
+            timestamp=TimestampMS(3000),
+            location=Location.ETHEREUM,
+            event_type=HistoryEventType.RECEIVE,
+            event_subtype=HistoryEventSubType.NONE,
+            asset=A_USDT,
+            balance=Balance(amount=FVal('1.5')),
+        ), HistoryEvent(
+            event_identifier=event_identifiers[2],
+            sequence_index=2,
+            timestamp=TimestampMS(3000),
+            location=Location.COINBASE,
+            event_type=HistoryEventType.RECEIVE,
+            event_subtype=HistoryEventSubType.NONE,
+            asset=A_USDT,
+            balance=Balance(amount=FVal('0.02762431')),
+        ), HistoryEvent(
+            event_identifier=event_identifiers[3],
+            sequence_index=0,
+            timestamp=TimestampMS(4000),
+            location=Location.COINBASE,
+            event_type=HistoryEventType.RECEIVE,
+            event_subtype=HistoryEventSubType.NONE,
+            asset=A_USDT,
+            balance=Balance(amount=FVal('0.000076')),
+        ), HistoryEvent(
+            event_identifier=event_identifiers[3],
+            sequence_index=1,
+            timestamp=TimestampMS(4000),
+            location=Location.COINBASE,
+            event_type=HistoryEventType.RECEIVE,
+            event_subtype=HistoryEventSubType.NONE,
+            asset=A_ETH,
+            balance=Balance(amount=ONE),
+        ), HistoryEvent(
+            event_identifier=event_identifiers[3],
+            sequence_index=2,
+            timestamp=TimestampMS(4000),
+            location=Location.COINBASE,
+            event_type=HistoryEventType.RECEIVE,
+            event_subtype=HistoryEventSubType.NONE,
+            asset=A_ETH,
+            balance=Balance(amount=FVal(1000)),
+        ), HistoryEvent(
+            event_identifier=event_identifiers[4],
+            sequence_index=0,
+            timestamp=TimestampMS(5000),
+            location=Location.ETHEREUM,
+            event_type=HistoryEventType.SPEND,
+            event_subtype=HistoryEventSubType.NONE,
+            asset=A_USDT,
+            balance=Balance(amount=ONE),
+        ),
+    )
+    with database.conn.write_ctx() as write_cursor:
+        history_events.add_history_events(  # add dummy history events
+            write_cursor=write_cursor,
+            history=dummy_events,
+        )
+        database.add_to_ignored_assets(
+            write_cursor=write_cursor,
+            asset=A_USDC,
+        )
+
+    with database.conn.read_ctx() as cursor:
+        # free and premium results should be same with len(events) < FREE_LIMIT
+        for filters in (  # trying different filters with some combinations
+            HistoryEventFilterQuery.make(location=Location.OPTIMISM),
+            HistoryEventFilterQuery.make(assets=(A_ETH,)),
+            HistoryEventFilterQuery.make(assets=(A_ETH,), location=Location.COINBASE),
+            HistoryEventFilterQuery.make(from_ts=Timestamp(2)),
+            HistoryEventFilterQuery.make(assets=(A_USDT,), to_ts=Timestamp(3)),
+            HistoryEventFilterQuery.make(exclude_ignored_assets=True),
+        ):
+            assert history_events.get_history_events(  # when grouping
+                cursor=cursor,
+                filter_query=filters,
+                has_premium=True,
+                group_by_event_ids=True,
+            ) == history_events.get_history_events(
+                cursor=cursor,
+                filter_query=filters,
+                has_premium=False,
+                group_by_event_ids=True,
+            ) != []
+            assert history_events.get_history_events(  # when not grouping
+                cursor=cursor,
+                filter_query=filters,
+                has_premium=True,
+                group_by_event_ids=False,
+            ) == history_events.get_history_events(
+                cursor=cursor,
+                filter_query=filters,
+                has_premium=False,
+                group_by_event_ids=False,
+            ) != []
+
+        with patch(target='rotkehlchen.db.history_events.FREE_HISTORY_EVENTS_LIMIT', new=3):
+            for filters in (  # trying different filters with some combinations
+                HistoryEventFilterQuery.make(event_subtypes=[HistoryEventSubType.NONE]),
+                HistoryEventFilterQuery.make(assets=(A_ETH, A_USDT)),
+                HistoryEventFilterQuery.make(from_ts=Timestamp(2)),
+                HistoryEventFilterQuery.make(assets=(A_ETH, A_USDC, A_USDT)),
+                HistoryEventFilterQuery.make(exclude_ignored_assets=True),
+            ):
+                premium_grouped_result = history_events.get_history_events(  # when grouping
+                    cursor=cursor,
+                    filter_query=filters,
+                    has_premium=True,
+                    group_by_event_ids=True,
+                )
+                assert len(premium_grouped_result) > 3
+                free_grouped_result = history_events.get_history_events(
+                    cursor=cursor,
+                    filter_query=filters,
+                    has_premium=False,
+                    group_by_event_ids=True,
+                )
+                assert len(free_grouped_result) == min(3, len(premium_grouped_result))
+                assert free_grouped_result == premium_grouped_result[-3:]
+                premium_result = history_events.get_history_events(  # when not grouping
+                    cursor=cursor,
+                    filter_query=filters,
+                    has_premium=True,
+                    group_by_event_ids=False,
+                )
+                assert len(premium_result) > 3
+                free_result = history_events.get_history_events(
+                    cursor=cursor,
+                    filter_query=filters,
+                    has_premium=False,
+                    group_by_event_ids=False,
+                )
+                for free_event in free_result:
+                    assert free_event.identifier is not None
+                    assert free_event.identifier > 3, 'Free sub-events should be from the latest 3 event groups'  # noqa: E501
diff --git a/rotkehlchen/tests/external_apis/test_blockscout.py b/rotkehlchen/tests/external_apis/test_blockscout.py
--- a/rotkehlchen/tests/external_apis/test_blockscout.py
+++ b/rotkehlchen/tests/external_apis/test_blockscout.py
@@ -26,7 +26,9 @@ def test_query_withdrawals(blockscout, database):
     with database.conn.read_ctx() as cursor:
         events = dbevents.get_history_events(
             cursor=cursor,
-            filter_query=EthWithdrawalFilterQuery.make(),
+            filter_query=EthWithdrawalFilterQuery.make(
+                order_by_rules=[('timestamp', True), ('history_events_identifier', True)],
+            ),
             has_premium=True,
             group_by_event_ids=False,
         )