Repository: rotki/rotki

Issue:
Support Aave v3 positions in all chains
## Abstract

Auto detect aave v3 positions in mainnet (and other chains) and show them to the users. That means at least detect borrowed loans and show them as liabilities. The normal v3 tokens that are being lent should also be auto-detected and if they are not we should make sure they are.

Regarding a dedicated view for Aave v3 I am not sure if it is warranted as dedicated views don't scale well, are hard to maintain and for projects that have not paid for them it makes no financial sense for us.

## Motivation

We support v2 positions but v3 not. So we should support them at least in mainnet

## Specification

Figure out what's needed to do and do it.



Discussion:
Thank you for opening up the issue!

Resolving patch:
diff --git a/rotkehlchen/api/rest.py b/rotkehlchen/api/rest.py
--- a/rotkehlchen/api/rest.py
+++ b/rotkehlchen/api/rest.py
@@ -2428,23 +2428,46 @@ def get_aave_balances(self) -> dict[str, Any]:
             method='get_balances',
             # We need to query defi balances before since defi_balances must be populated
             query_specific_balances_before=['defi'],
-            # Giving the defi balances as a lambda function here so that they
-            # are retrieved only after we are sure the defi balances have been
+            # Giving the eth/defi balances as a lambda functions here so that they
+            # are retrieved only after we are sure the eth/defi balances have been
             # queried.
             given_defi_balances=lambda: self.rotkehlchen.chains_aggregator.defi_balances,
+            given_eth_balances=lambda: self.rotkehlchen.chains_aggregator.balances.eth,
         )
 
     @async_api_call()
-    def get_module_stats_using_balances(
+    def get_aave_stats_using_balances(
             self,
-            module: Literal['aave', 'compound'],
+            module: Literal['aave'],
             from_timestamp: Timestamp,
             to_timestamp: Timestamp,
     ) -> dict[str, Any]:
-        """
-        Query the provided module for statistics using the tracked addresses for such module.
-        This function uses the defi balances to enrich statistics.
-        """
+        """Query Aave module for statistics using the tracked addresses for aave.
+        This function uses the eth/defi balances to enrich statistics."""
+        return self._eth_module_query(
+            module_name=module,
+            method='get_stats_for_addresses',
+            # We need to query defi balances before since defi_balances must be populated
+            query_specific_balances_before=['defi'],
+            addresses=self.rotkehlchen.chains_aggregator.queried_addresses_for_module(module),
+            from_timestamp=from_timestamp,
+            to_timestamp=to_timestamp,
+            # Giving the eth/defi balances as lambda functions here so that they
+            # are retrieved only after we are sure the eth/defi balances have been
+            # queried.
+            given_defi_balances=lambda: self.rotkehlchen.chains_aggregator.defi_balances,
+            given_eth_balances=lambda: self.rotkehlchen.chains_aggregator.balances.eth,
+        )
+
+    @async_api_call()
+    def get_compound_stats_using_balances(
+            self,
+            module: Literal['compound'],
+            from_timestamp: Timestamp,
+            to_timestamp: Timestamp,
+    ) -> dict[str, Any]:
+        """Query Compound module for statistics using the tracked addresses for compound.
+        This function uses the defi balances to enrich statistics."""
         return self._eth_module_query(
             module_name=module,
             method='get_stats_for_addresses',
diff --git a/rotkehlchen/api/v1/resources.py b/rotkehlchen/api/v1/resources.py
--- a/rotkehlchen/api/v1/resources.py
+++ b/rotkehlchen/api/v1/resources.py
@@ -2268,8 +2268,16 @@ def get(
             from_timestamp: Timestamp,
             to_timestamp: Timestamp,
     ) -> Response:
-        if module in (ModuleWithStats.AAVE, ModuleWithStats.COMPOUND):
-            return self.rest_api.get_module_stats_using_balances(
+        if module == ModuleWithStats.AAVE:
+            return self.rest_api.get_aave_stats_using_balances(
+                async_query=async_query,
+                module=module.serialize(),
+                from_timestamp=from_timestamp,
+                to_timestamp=to_timestamp,
+            )
+
+        if module == ModuleWithStats.COMPOUND:
+            return self.rest_api.get_compound_stats_using_balances(
                 async_query=async_query,
                 module=module.serialize(),
                 from_timestamp=from_timestamp,
diff --git a/rotkehlchen/chain/ethereum/modules/aave/aave.py b/rotkehlchen/chain/ethereum/modules/aave/aave.py
--- a/rotkehlchen/chain/ethereum/modules/aave/aave.py
+++ b/rotkehlchen/chain/ethereum/modules/aave/aave.py
@@ -7,12 +7,16 @@
 from rotkehlchen.accounting.structures.balance import Balance
 from rotkehlchen.assets.asset import Asset, CryptoAsset, EvmToken
 from rotkehlchen.chain.ethereum.constants import RAY
-from rotkehlchen.chain.evm.decoding.aave.constants import CPT_AAVE_V1, CPT_AAVE_V2
+from rotkehlchen.chain.ethereum.modules.aave.v3.constants import AAVE_V3_DATA_PROVIDER
+from rotkehlchen.chain.evm.decoding.aave.constants import CPT_AAVE_V1, CPT_AAVE_V2, CPT_AAVE_V3
 from rotkehlchen.chain.evm.types import string_to_evm_address
-from rotkehlchen.constants.resolver import ethaddress_to_identifier
+from rotkehlchen.constants.misc import ONE
+from rotkehlchen.constants.resolver import ethaddress_to_identifier, strethaddress_to_identifier
+from rotkehlchen.constants.timing import YEAR_IN_SECONDS
 from rotkehlchen.db.filtering import EvmEventFilterQuery
 from rotkehlchen.db.history_events import DBHistoryEvents
 from rotkehlchen.errors.asset import UnknownAsset, WrongAssetType
+from rotkehlchen.errors.misc import RemoteError
 from rotkehlchen.fval import FVal
 from rotkehlchen.history.events.structures.evm_event import EvmEvent
 from rotkehlchen.history.events.structures.types import HistoryEventSubType
@@ -35,7 +39,7 @@
 )
 
 if TYPE_CHECKING:
-    from rotkehlchen.chain.ethereum.defi.structures import GIVEN_DEFI_BALANCES
+    from rotkehlchen.chain.ethereum.defi.structures import GIVEN_DEFI_BALANCES, GIVEN_ETH_BALANCES
     from rotkehlchen.chain.ethereum.node_inquirer import EthereumInquirer
     from rotkehlchen.db.dbhandler import DBHandler
 
@@ -74,25 +78,56 @@ def __init__(
         self.history_lock = Semaphore()
         self.balances_lock = Semaphore()
         self.counterparties = [CPT_AAVE_V1, CPT_AAVE_V2]
+        self.v3_data_provider = self.ethereum.contracts.contract(AAVE_V3_DATA_PROVIDER)
+        self.v3_reserve_cache: dict[EvmToken, ReserveData] = {}
+
+    def _get_v3_reserve_data(self, reserve_token: 'EvmToken') -> 'ReserveData | None':
+        """Retrieves the reserve data from the v3 data provider. This data is cached only for the
+        current API call, and not cached in the DB. This is because it changes quite frequently."""
+        if (
+            (reserve_data := self.v3_reserve_cache.get(reserve_token)) is None and
+            reserve_token.underlying_tokens is not None and
+            len(reserve_token.underlying_tokens) == 1
+        ):
+            try:
+                reserve_result = self.v3_data_provider.call(
+                    node_inquirer=self.ethereum,
+                    method_name='getReserveData',
+                    arguments=[reserve_token.underlying_tokens[0].address],
+                )
+            except RemoteError as e:
+                self.msg_aggregator.add_error(
+                    f'Failed to get reserve data from Aave v3 data provider for token {reserve_token!s}, due to {e}',  # noqa: E501
+                )
+                return None
+            reserve_data = ReserveData(
+                liquidity_rate=FVal(reserve_result[5]) / RAY,
+                variable_borrow_rate=FVal(reserve_result[6]) / RAY,
+                stable_borrow_rate=FVal(reserve_result[7]) / RAY,
+            )
+            self.v3_reserve_cache[reserve_token] = reserve_data
+        return reserve_data
 
     def get_balances(
             self,
             given_defi_balances: 'GIVEN_DEFI_BALANCES',
+            given_eth_balances: 'GIVEN_ETH_BALANCES',
     ) -> dict[ChecksumEvmAddress, AaveBalances]:
         with self.balances_lock:
-            return self._get_balances(given_defi_balances)
+            return self._get_balances(
+                given_defi_balances=given_defi_balances,
+                given_eth_balances=given_eth_balances,
+            )
 
     def _get_balances(
             self,
             given_defi_balances: 'GIVEN_DEFI_BALANCES',
+            given_eth_balances: 'GIVEN_ETH_BALANCES',
     ) -> dict[ChecksumEvmAddress, AaveBalances]:
-        """Retrieves the aave balances
-
-        Receives the defi balances from zerion as an argument. They can either be directly given
-        as the defi balances mapping or as a callable that will retrieve the
-        balances mapping when executed.
-        """
-        aave_balances = {}
+        """Retrieves the aave balances. It takes the eth token balances and defi balances as an
+        argument. They can either be directly given as the balances mapping or as a callable that
+        will retrieve the balances mapping when executed."""
+        aave_balances: defaultdict[ChecksumEvmAddress, AaveBalances] = defaultdict(lambda: AaveBalances(lending={}, borrowing={}))  # noqa: E501
         reserve_cache: dict[str, ReserveData] = {}
 
         if isinstance(given_defi_balances, dict):
@@ -100,7 +135,7 @@ def _get_balances(
         else:
             defi_balances = given_defi_balances()
 
-        for account, balance_entries in defi_balances.items():
+        for account, balance_entries in defi_balances.items():  # for v1 and v2
             lending_map = {}
             borrowing_map = {}
             for balance_entry in balance_entries:
@@ -135,9 +170,9 @@ def _get_balances(
                             arguments=[reserve_address],
                         )
                         reserve_data = ReserveData(
-                            liquidity_rate=FVal(reserve_result[4] / RAY),
-                            variable_borrow_rate=FVal(reserve_result[5] / RAY),
-                            stable_borrow_rate=FVal(reserve_result[6] / RAY),
+                            liquidity_rate=FVal(reserve_result[4]) / RAY,
+                            variable_borrow_rate=FVal(reserve_result[5]) / RAY,
+                            stable_borrow_rate=FVal(reserve_result[6]) / RAY,
                         )
                     else:  # Aave V2
                         contract = self.ethereum.contracts.contract(string_to_evm_address('0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9'))  # noqa: E501
@@ -147,9 +182,9 @@ def _get_balances(
                             arguments=[reserve_address],
                         )
                         reserve_data = ReserveData(
-                            liquidity_rate=FVal(reserve_result[3] / RAY),
-                            variable_borrow_rate=FVal(reserve_result[4] / RAY),
-                            stable_borrow_rate=FVal(reserve_result[5] / RAY),
+                            liquidity_rate=FVal(reserve_result[3]) / RAY,
+                            variable_borrow_rate=FVal(reserve_result[4]) / RAY,
+                            stable_borrow_rate=FVal(reserve_result[5]) / RAY,
                         )
 
                     reserve_cache[balance_entry.base_balance.token_symbol] = reserve_data
@@ -174,6 +209,42 @@ def _get_balances(
 
             aave_balances[account] = AaveBalances(lending=lending_map, borrowing=borrowing_map)  # type: ignore
 
+        for account, balance_sheet in (  # for v3
+            given_eth_balances if isinstance(given_eth_balances, dict) else given_eth_balances()
+        ).items():
+            for asset, balance in balance_sheet.assets.items():
+                if (
+                    asset.is_evm_token() and
+                    (asset := asset.resolve_to_evm_token()).protocol == CPT_AAVE_V3 and
+                    (reserve_data := self._get_v3_reserve_data(asset)) is not None
+                ):
+                    aave_balances[account].lending[CryptoAsset(
+                        # existence of underlying_tokens is checked in _get_v3_reserve_data
+                        strethaddress_to_identifier(asset.underlying_tokens[0].address),  # type: ignore[attr-defined]  # asset is resolved to evm token
+                    )] = AaveLendingBalance(
+                        balance=balance,
+                        # converting to APY according to https://docs.aave.com/developers/guides/rates-guide#formatting-rates  # noqa: E501
+                        apy=(ONE + reserve_data.liquidity_rate / YEAR_IN_SECONDS) ** YEAR_IN_SECONDS - ONE,  # noqa: E501
+                        version=3,
+                    )
+
+            for liability, balance in balance_sheet.liabilities.items():
+                if (
+                    liability.is_evm_token() and
+                    (liability := liability.resolve_to_evm_token()).protocol == CPT_AAVE_V3 and
+                    (reserve_data := self._get_v3_reserve_data(liability)) is not None
+                ):
+                    aave_balances[account].borrowing[CryptoAsset(
+                        # existence of underlying_tokens is checked in _get_v3_reserve_data
+                        strethaddress_to_identifier(liability.underlying_tokens[0].address),  # type: ignore[attr-defined]  # asset is resolved to evm token
+                    )] = AaveBorrowingBalance(
+                        balance=balance,
+                        variable_apr=reserve_data.variable_borrow_rate,
+                        stable_apr=reserve_data.stable_borrow_rate,
+                        version=3,
+                    )
+
+        self.v3_reserve_cache.clear()
         return aave_balances
 
     def _calculate_loss(
@@ -343,6 +414,7 @@ def get_stats_for_addresses(
             from_timestamp: Timestamp,
             to_timestamp: Timestamp,
             given_defi_balances: 'GIVEN_DEFI_BALANCES',
+            given_eth_balances: 'GIVEN_ETH_BALANCES',
     ) -> dict[ChecksumEvmAddress, AaveStats]:
         """
         Calculate the profit and losses in aave v1 and v2 for the provided addresses
@@ -358,7 +430,10 @@ def get_stats_for_addresses(
         - total_earned_liquidations: A mapping of asset identifier for each repaid asset during
         liquidations.
         """
-        aave_balances = self.get_balances(given_defi_balances)
+        aave_balances = self.get_balances(
+            given_defi_balances=given_defi_balances,
+            given_eth_balances=given_eth_balances,
+        )
         result = {}
         for address in addresses:
             user_stats = self._get_stats_for_address(


Test patch:
diff --git a/rotkehlchen/tests/api/test_aave.py b/rotkehlchen/tests/api/test_aave.py
--- a/rotkehlchen/tests/api/test_aave.py
+++ b/rotkehlchen/tests/api/test_aave.py
@@ -1,21 +1,26 @@
 import random
-import warnings as test_warnings
 from contextlib import ExitStack
 from http import HTTPStatus
-from typing import Any
+from typing import TYPE_CHECKING, Final
+from unittest.mock import patch
 
 import pytest
 import requests
 
 from rotkehlchen.accounting.structures.balance import Balance
 from rotkehlchen.api.server import APIServer
+from rotkehlchen.assets.asset import UnderlyingToken
+from rotkehlchen.assets.utils import get_or_create_evm_token
 from rotkehlchen.chain.ethereum.defi.structures import (
     DefiBalance,
     DefiProtocol,
     DefiProtocolBalances,
 )
+from rotkehlchen.chain.evm.decoding.aave.constants import CPT_AAVE_V3
+from rotkehlchen.chain.evm.tokens import TokenBalancesType
 from rotkehlchen.chain.evm.types import string_to_evm_address
 from rotkehlchen.constants import ONE
+from rotkehlchen.constants.assets import A_DAI, A_USDC, A_USDT, A_WBTC, A_WETH
 from rotkehlchen.fval import FVal
 from rotkehlchen.tests.utils.api import (
     api_url_for,
@@ -25,78 +30,139 @@
     wait_for_async_task,
 )
 from rotkehlchen.tests.utils.rotkehlchen import setup_balances
-from rotkehlchen.types import ChecksumEvmAddress, deserialize_evm_tx_hash
+from rotkehlchen.types import (
+    ChainID,
+    ChecksumEvmAddress,
+    EvmTokenKind,
+    Price,
+    deserialize_evm_tx_hash,
+)
 
-AAVE_BALANCESV1_TEST_ACC = '0xC2cB1040220768554cf699b0d863A3cd4324ce32'
-AAVE_BALANCESV2_TEST_ACC = '0x8Fe178db26ebA2eEdb22575265bf10A63c395a3d'
+if TYPE_CHECKING:
+    from collections.abc import Sequence
 
+AAVE_BALANCESV1_TEST_ACC: Final = '0xC2cB1040220768554cf699b0d863A3cd4324ce32'
+AAVE_BALANCESV2_TEST_ACC: Final = '0xf2d02719309c8d5F22498c2d432eDc8eB8683d83'
+AAVE_BALANCESV3_TEST_ACC: Final = '0x18FD4323ea221cD05Fb275256C8f3A0740D0Aaa2'
 
-@pytest.mark.parametrize('ethereum_accounts', [[AAVE_BALANCESV1_TEST_ACC, AAVE_BALANCESV2_TEST_ACC]])  # noqa: E501
-@pytest.mark.parametrize('ethereum_modules', [['aave']])
-def test_query_aave_balances(
-        rotkehlchen_api_server: APIServer,
-        ethereum_accounts: list[ChecksumEvmAddress] | None,
-) -> None:
-    """Check querying the aave balances endpoint works. Uses real data.
 
-    TODO: Here we should use a test account for which we will know what balances
-    it has and we never modify
-    """
+@pytest.mark.vcr(filter_query_parameters=['apikey'])
+@pytest.mark.parametrize('ethereum_accounts', [[
+    AAVE_BALANCESV1_TEST_ACC, AAVE_BALANCESV2_TEST_ACC, AAVE_BALANCESV3_TEST_ACC,
+]])
+@pytest.mark.parametrize('ethereum_modules', [['aave']])
+def test_query_aave_balances(rotkehlchen_api_server: APIServer) -> None:
+    """Check querying the aave balances endpoint works. Uses real data for v1/v2,
+    and mocked for v3 because that need underlying tokens mapping and token balances in db."""
     async_query = random.choice([False, True])
-    rotki = rotkehlchen_api_server.rest_api.rotkehlchen
-    setup = setup_balances(
-        rotki,
-        ethereum_accounts=ethereum_accounts,
-        btc_accounts=None,
-        original_queries=['zerion'],
+
+    a_eth_weth = get_or_create_evm_token(
+        userdb=rotkehlchen_api_server.rest_api.rotkehlchen.data.db,
+        evm_address=string_to_evm_address('0x4d5F47FA6A74757f35C14fD3a6Ef8E3C9BC514E8'),
+        chain_id=ChainID.ETHEREUM,
+        token_kind=EvmTokenKind.ERC20,
+        symbol='aEthWETH',
+        protocol=CPT_AAVE_V3,
+        underlying_tokens=[UnderlyingToken(
+            address=A_WETH.resolve_to_evm_token().evm_address,
+            token_kind=EvmTokenKind.ERC20,
+            weight=ONE,
+        )],
     )
-    with ExitStack() as stack:
-        # patch ethereum/etherscan to not autodetect tokens
-        setup.enter_ethereum_patches(stack)
+    variable_debt_eth_usdc = get_or_create_evm_token(
+        userdb=rotkehlchen_api_server.rest_api.rotkehlchen.data.db,
+        evm_address=string_to_evm_address('0x72E95b8931767C79bA4EeE721354d6E99a61D004'),
+        chain_id=ChainID.ETHEREUM,
+        token_kind=EvmTokenKind.ERC20,
+        symbol='variableDebtEthUSDC',
+        protocol=CPT_AAVE_V3,
+        underlying_tokens=[UnderlyingToken(
+            address=A_USDC.resolve_to_evm_token().evm_address,
+            token_kind=EvmTokenKind.ERC20,
+            weight=ONE,
+        )],
+    )
+    aave_prices = {a_eth_weth: Price(FVal(0.1)), variable_debt_eth_usdc: Price(FVal(10))}
+
+    def mock_token_balances(addresses: 'Sequence[ChecksumEvmAddress]') -> TokenBalancesType:
+        return {
+            addresses[2]: {a_eth_weth: FVal(123), variable_debt_eth_usdc: FVal(456)},
+        }, aave_prices
+
+    with patch.object(
+        target=rotkehlchen_api_server.rest_api.rotkehlchen.chains_aggregator.get_evm_manager(chain_id=ChainID.ETHEREUM).tokens,
+        attribute='query_tokens_for_addresses',
+        new=mock_token_balances,
+    ):
         response = requests.get(api_url_for(
             rotkehlchen_api_server,
             'aavebalancesresource',
         ), json={'async_query': async_query})
-        if async_query:
-            task_id = assert_ok_async_response(response)
-            outcome = wait_for_async_task(rotkehlchen_api_server, task_id)
-            assert outcome['message'] == ''
-            result = outcome['result']
-        else:
-            result = assert_proper_response_with_result(response)
 
-    if len(result) == 0:
-        test_warnings.warn(UserWarning(f'Test account {AAVE_BALANCESV1_TEST_ACC} and {AAVE_BALANCESV2_TEST_ACC} have no aave balances'))  # noqa: E501
-        return
-
-    def _assert_valid_entries(balances: dict[str, Any]) -> None:
-        lending = v1_balances['lending']
-        for entry in lending.values():
-            assert len(entry) == 2
-            assert len(entry['balance']) == 2
-            assert 'amount' in entry['balance']
-            assert 'usd_value' in entry['balance']
-            assert '%' in entry['apy']
-        borrowing = balances['borrowing']
-        for entry in borrowing.values():
-            assert len(entry) == 3
-            assert len(entry['balance']) == 2
-            assert 'amount' in entry['balance']
-            assert 'usd_value' in entry['balance']
-            assert '%' in entry['variable_apr']
-            assert '%' in entry['stable_apr']
-
-    v1_balances = result.get(AAVE_BALANCESV1_TEST_ACC)
-    if v1_balances:
-        _assert_valid_entries(v1_balances)
+    if async_query:
+        task_id = assert_ok_async_response(response)
+        outcome = wait_for_async_task(rotkehlchen_api_server, task_id)
+        assert outcome['message'] == ''
+        result = outcome['result']
     else:
-        test_warnings.warn(UserWarning(f'Test account {AAVE_BALANCESV1_TEST_ACC} has no aave v1 balances'))  # noqa: E501
+        result = assert_proper_response_with_result(response)
 
-    v2_balances = result.get(AAVE_BALANCESV2_TEST_ACC)
-    if v2_balances:
-        _assert_valid_entries(v2_balances)
-    else:
-        test_warnings.warn(UserWarning(f'Test account {AAVE_BALANCESV2_TEST_ACC} has no aave v2 balances'))  # noqa: E501
+    assert result == {
+        AAVE_BALANCESV1_TEST_ACC: {
+            'lending': {
+                A_DAI.identifier: {
+                    'balance': Balance(
+                        amount=FVal('25319.644933920807083535'),
+                        usd_value=FVal('37979.4674008812106253025'),
+                    ).serialize(),
+                    'apy': '0.15%',
+                },
+            },
+            'borrowing': {},
+        },
+        AAVE_BALANCESV2_TEST_ACC: {
+            'lending': {
+                A_WBTC.identifier: {
+                    'balance': Balance(
+                        amount=FVal('0.43022092'),
+                        usd_value=FVal('0.645331380'),
+                    ).serialize(),
+                    'apy': '0.11%',
+                },
+            },
+            'borrowing': {
+                A_USDT.identifier: {
+                    'balance': Balance(
+                        amount=FVal('20582.495008'),
+                        usd_value=FVal('30873.7425120'),
+                    ).serialize(),
+                    'stable_apr': '39.20%',
+                    'variable_apr': '33.20%',
+                },
+            },
+        },
+        AAVE_BALANCESV3_TEST_ACC: {
+            'lending': {
+                A_WETH.identifier: {
+                    'balance': Balance(
+                        amount=FVal('123'),
+                        usd_value=FVal('12.3'),
+                    ).serialize(),
+                    'apy': '1.60%',
+                },
+            },
+            'borrowing': {
+                A_USDC.identifier: {
+                    'balance': Balance(
+                        amount=FVal('456'),
+                        usd_value=FVal('4560'),
+                    ).serialize(),
+                    'stable_apr': '9.51%',
+                    'variable_apr': '8.01%',
+                },
+            },
+        },
+    }
 
 
 @pytest.mark.parametrize('ethereum_accounts', [[AAVE_BALANCESV1_TEST_ACC]])
diff --git a/rotkehlchen/tests/unit/test_aave.py b/rotkehlchen/tests/unit/test_aave.py
--- a/rotkehlchen/tests/unit/test_aave.py
+++ b/rotkehlchen/tests/unit/test_aave.py
@@ -83,6 +83,7 @@ def test_aave_v1_events_stats(
         from_timestamp=Timestamp(0),
         to_timestamp=ts_now(),
         given_defi_balances={},
+        given_eth_balances={},
     )
     expected_stats = {
         '0x2B888954421b424C5D3D9Ce9bB67c9bD47537d12': AaveStats(