Repository: rotki/rotki

Issue:
All open nodes (apart from 1inch) started returning out of gas error for the zerion getBalances call
## Problem Definition

We started seeing failed test with etherscan returning out of gas errors: https://github.com/rotki/rotki/pull/1965/checks?check_run_id=1597743462

For some `eth_call`s that used to work fine until 2 hours ago they are now throwing an `out of gas error`. They work fine with any other provider and with my local node so this should not critically affect users since we have many backup open nodes. But it should be investigated nonetheless.

I already opened a ticket with etherscan. In their proxy API it's stated that 

> The gas parameter to eth_call is capped at 2x the current block gas limit

So that should be fine for the calls that failed. Example failing call:
https://api.etherscan.io/api?module=proxy&action=eth_call&to=0x06FE76B2f432fdfEcAEf1a7d4f6C3d41B5861672&data=0xc84aae1700000000000000000000000021d05071ca08593e13cd3afd0b4869537e015c92

Returns:

`{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"out of gas"}}`

## Task

See how the situation evolves and see what etherscan responds. Also provide a fix for the failing tests (probably in those tests also allow secondary nodes to be used)



Discussion:
So this is due to zerion adding more protocols. The `getBalances()` call for a single account has started hitting the gas limit.

All open nodes (including etherscan) are affected with the notable exception of 1inch.

The solution is to split the call into multiple batched calls per protocol if the user does not use their own node.

Resolving patch:
diff --git a/rotkehlchen/chain/ethereum/defi/zerionsdk.py b/rotkehlchen/chain/ethereum/defi/zerionsdk.py
--- a/rotkehlchen/chain/ethereum/defi/zerionsdk.py
+++ b/rotkehlchen/chain/ethereum/defi/zerionsdk.py
@@ -12,16 +12,18 @@
     DefiProtocol,
     DefiProtocolBalances,
 )
+from rotkehlchen.chain.ethereum.typing import NodeName
 from rotkehlchen.chain.ethereum.utils import token_normalized_value_decimals
 from rotkehlchen.constants.assets import A_DAI, A_USDC
 from rotkehlchen.constants.ethereum import ZERION_ABI
 from rotkehlchen.constants.misc import ZERO
-from rotkehlchen.errors import UnknownAsset, UnsupportedAsset
+from rotkehlchen.errors import RemoteError, UnknownAsset, UnsupportedAsset
 from rotkehlchen.fval import FVal
 from rotkehlchen.inquirer import Inquirer, get_underlying_asset_price
 from rotkehlchen.serialization.deserialize import deserialize_ethereum_address
 from rotkehlchen.typing import ChecksumEthAddress, Price
 from rotkehlchen.user_messages import MessagesAggregator
+from rotkehlchen.utils.misc import get_chunks
 
 if TYPE_CHECKING:
     from rotkehlchen.chain.ethereum.manager import EthereumManager
@@ -29,6 +31,77 @@
 log = logging.getLogger(__name__)
 
 
+KNOWN_ZERION_PROTOCOL_NAMES = (
+    'Curve • Vesting',
+    'Curve • Liquidity Gauges',
+    'ygov.finance (v1)',
+    'ygov.finance (v2)',
+    'mStable • Staking',
+    'Swerve • Liquidity Gauges',
+    'Pickle Finance • Farms',
+    'Pickle Finance • Staking',
+    'Aave • Staking',
+    'C.R.E.A.M. • Staking',
+    'Compound Governance',
+    'zlot.finance',
+    'FinNexus',
+    'Pickle Finance',
+    'DODO',
+    'Berezka',
+    'bZx',
+    'C.R.E.A.M.',
+    'Swerve',
+    'SashimiSwap',
+    'Harvest',
+    'Harvest • Profit Sharing',
+    'KIMCHI',
+    'SushiSwap',
+    'Nexus Mutual',
+    'Mooniswap',
+    'Matic',
+    'Aragon',
+    'Melon',
+    'yearn.finance • Vaults',
+    'KeeperDAO',
+    'mStable',
+    'KyberDAO',
+    'DDEX • Spot',
+    'DDEX • Margin',
+    'DDEX • Lending',
+    'Ampleforth',
+    'Maker Governance',
+    'Gnosis Protocol',
+    'Chi Gastoken by 1inch',
+    'Idle • Risk-Adjusted',
+    'Aave • Uniswap Market',
+    'Uniswap V2',
+    'PieDAO',
+    'Multi-Collateral Dai',
+    'Bancor',
+    'DeFi Money Market',
+    'TokenSets',
+    '0x Staking',
+    'Uniswap V1',
+    'Synthetix',
+    'PoolTogether',
+    'Dai Savings Rate',
+    'Chai',
+    'iearn.finance (v3)',
+    'iearn.finance (v2)',
+    'Idle',
+    'Idle • Early Rewards',
+    'dYdX',
+    'Curve',
+    'Compound',
+    'Balancer',
+    'Aave',
+    'SnowSwap',
+    'Aave V2',
+    'Aave V2 • Variable Debt',
+    'Aave V2 • Stable Debt',
+)
+
+
 def _is_token_non_standard(symbol: str, address: ChecksumEthAddress) -> bool:
     """ignore some assets we do not query directly as token balances
 
@@ -95,16 +168,50 @@ def __init__(
             deployed_block=1586199170,
         )
 
+    def _query_chain_for_all_balances(self, account: ChecksumEthAddress) -> List:
+        if NodeName.OWN in self.ethereum.web3_mapping:
+            try:
+                # In this case we don't care about the gas limit
+                return self.contract.call(
+                    ethereum=self.ethereum,
+                    method_name='getBalances',
+                    arguments=[account],
+                    call_order=[NodeName.OWN, NodeName.ONEINCH],
+                )
+            except RemoteError:
+                log.warning(
+                    'Failed to query zerionsdk balances with own node. Falling '
+                    'back to multiple calls to getProtocolBalances',
+                )
+
+        # but if we are not connected to our own node the zerion sdk get balances call
+        # has unfortunately crossed the default limits of almost all open nodes apart from 1inch
+        # https://github.com/rotki/rotki/issues/1969
+        # For now split the list in 2. In the near future we probably need
+        # a more flexible/dynamic approach
+        result = []
+        protocol_chunks: List[List[str]] = list(get_chunks(
+            list(KNOWN_ZERION_PROTOCOL_NAMES),
+            n=int(len(KNOWN_ZERION_PROTOCOL_NAMES) / 2),
+        ))
+        for protocol_names in protocol_chunks:
+            contract_result = self.contract.call(
+                ethereum=self.ethereum,
+                method_name='getProtocolBalances',
+                arguments=[account, protocol_names],
+            )
+            if len(contract_result) == 0:
+                continue
+            result.extend(contract_result)
+
+        return result
+
     def all_balances_for_account(self, account: ChecksumEthAddress) -> List[DefiProtocolBalances]:
         """Calls the contract's getBalances() to get all protocol balances for account
 
         https://docs.zerion.io/smart-contracts/adapterregistry-v3#getbalances
         """
-        result = self.contract.call(
-            ethereum=self.ethereum,
-            method_name='getBalances',
-            arguments=[account],
-        )
+        result = self._query_chain_for_all_balances(account=account)
         protocol_balances = []
         for entry in result:
             protocol = DefiProtocol(
diff --git a/rotkehlchen/chain/ethereum/manager.py b/rotkehlchen/chain/ethereum/manager.py
--- a/rotkehlchen/chain/ethereum/manager.py
+++ b/rotkehlchen/chain/ethereum/manager.py
@@ -393,7 +393,7 @@ def query(self, method: Callable, call_order: Sequence[NodeName], **kwargs: Any)
         # no node in the call order list was succesfully queried
         raise RemoteError(
             f'Failed to query {str(method)} after trying the following '
-            f'nodes: {[str(x) for x in call_order]}',
+            f'nodes: {[str(x) for x in call_order]}. Check logs for details.',
         )
 
     def _get_latest_block_number(self, web3: Optional[Web3]) -> int:


Test patch:
diff --git a/rotkehlchen/tests/api/test_balances.py b/rotkehlchen/tests/api/test_balances.py
--- a/rotkehlchen/tests/api/test_balances.py
+++ b/rotkehlchen/tests/api/test_balances.py
@@ -327,7 +327,7 @@ def test_query_all_balances_ignore_cache(
             else:
                 assert fn.call_count == 2, msg
         msg = 'etherscan call count should have doubled after forced token detection'
-        expected_count = full_query_etherscan_count * 2 - len(ethereum_accounts) - 1
+        expected_count = full_query_etherscan_count * 2 - len(ethereum_accounts) * 3 - 1
         assert etherscan_mock.call_count == expected_count, msg
 
 
diff --git a/rotkehlchen/tests/unit/test_zerionsdk.py b/rotkehlchen/tests/unit/test_zerionsdk.py
--- a/rotkehlchen/tests/unit/test_zerionsdk.py
+++ b/rotkehlchen/tests/unit/test_zerionsdk.py
@@ -2,10 +2,15 @@
 
 import pytest
 
-from rotkehlchen.chain.ethereum.defi.zerionsdk import ZerionSDK
+from rotkehlchen.chain.ethereum.defi.zerionsdk import KNOWN_ZERION_PROTOCOL_NAMES, ZerionSDK
 from rotkehlchen.fval import FVal
+from rotkehlchen.tests.utils.ethereum import (
+    ETHEREUM_TEST_PARAMETERS,
+    wait_until_all_nodes_connected,
+)
 
 
+@pytest.mark.parametrize(*ETHEREUM_TEST_PARAMETERS)
 @pytest.mark.parametrize('mocked_current_prices', [{
     'SNX': FVal('1'),
     'cUSDT': FVal('1'),
@@ -15,6 +20,8 @@ def test_query_all_protocol_balances_for_account(
         ethereum_manager,
         function_scope_messages_aggregator,
         inquirer,  # pylint: disable=unused-argument
+        ethereum_manager_connect_at_start,
+        call_order,  # pylint: disable=unused-argument
 ):
     """Simple test that we can get balances for various defi protocols via zerion
 
@@ -25,8 +32,13 @@ def test_query_all_protocol_balances_for_account(
     certain balance in a few DeFi protocols and does not change them. This way
     we can have something stable to check again.
     """
+    wait_until_all_nodes_connected(
+        ethereum_manager_connect_at_start=ethereum_manager_connect_at_start,
+        ethereum=ethereum_manager,
+    )
     zerion = ZerionSDK(ethereum_manager, function_scope_messages_aggregator)
     balances = zerion.all_balances_for_account('0xf753beFE986e8Be8EBE7598C9d2b6297D9DD6662')
+
     if len(balances) == 0:
         test_warnings.warn(UserWarning('Test account for DeFi balances has no balances'))
         return
@@ -38,77 +50,6 @@ def test_query_all_protocol_balances_for_account(
     assert len(warnings) == 0
 
 
-KNOWN_ZERION_PROTOCOL_NAMES = (
-    'Curve • Vesting',
-    'Curve • Liquidity Gauges',
-    'ygov.finance (v1)',
-    'ygov.finance (v2)',
-    'mStable • Staking',
-    'Swerve • Liquidity Gauges',
-    'Pickle Finance • Farms',
-    'Pickle Finance • Staking',
-    'Aave • Staking',
-    'C.R.E.A.M. • Staking',
-    'Compound Governance',
-    'zlot.finance',
-    'FinNexus',
-    'Pickle Finance',
-    'DODO',
-    'Berezka',
-    'bZx',
-    'C.R.E.A.M.',
-    'Swerve',
-    'SashimiSwap',
-    'Harvest',
-    'Harvest • Profit Sharing',
-    'KIMCHI',
-    'SushiSwap',
-    'Nexus Mutual',
-    'Mooniswap',
-    'Matic',
-    'Aragon',
-    'Melon',
-    'yearn.finance • Vaults',
-    'KeeperDAO',
-    'mStable',
-    'KyberDAO',
-    'DDEX • Spot',
-    'DDEX • Margin',
-    'DDEX • Lending',
-    'Ampleforth',
-    'Maker Governance',
-    'Gnosis Protocol',
-    'Chi Gastoken by 1inch',
-    'Idle • Risk-Adjusted',
-    'Aave • Uniswap Market',
-    'Uniswap V2',
-    'PieDAO',
-    'Multi-Collateral Dai',
-    'Bancor',
-    'DeFi Money Market',
-    'TokenSets',
-    '0x Staking',
-    'Uniswap V1',
-    'Synthetix',
-    'PoolTogether',
-    'Dai Savings Rate',
-    'Chai',
-    'iearn.finance (v3)',
-    'iearn.finance (v2)',
-    'Idle',
-    'Idle • Early Rewards',
-    'dYdX',
-    'Curve',
-    'Compound',
-    'Balancer',
-    'Aave',
-    'SnowSwap',
-    'Aave V2',
-    'Aave V2 • Variable Debt',
-    'Aave V2 • Stable Debt',
-)
-
-
 def test_protocol_names_are_known(
         ethereum_manager,
         function_scope_messages_aggregator,
diff --git a/rotkehlchen/tests/utils/blockchain.py b/rotkehlchen/tests/utils/blockchain.py
--- a/rotkehlchen/tests/utils/blockchain.py
+++ b/rotkehlchen/tests/utils/blockchain.py
@@ -241,6 +241,23 @@ def mock_requests_get(url, *args, **kwargs):
                 args = []
                 result = '0x' + web3.codec.encode_abi(output_types, [args]).hex()
                 response = f'{{"jsonrpc":"2.0","id":1,"result":"{result}"}}'
+            elif 'data=0x85c6a7930' in url:  # getProtocolBalances
+                data = url.split('data=')[1]
+                if '&apikey' in data:
+                    data = data.split('&apikey')[0]
+
+                fn_abi = contract._find_matching_fn_abi(
+                    fn_identifier='getProtocolBalances',
+                    args=['address', ['some', 'protocol', 'names']],
+                )
+                input_types = get_abi_input_types(fn_abi)
+                output_types = get_abi_output_types(fn_abi)
+                decoded_input = web3.codec.decode_abi(input_types, bytes.fromhex(data[10:]))
+                # TODO: This here always returns empty response. If/when we want to
+                # mock it for etherscan, this is where we do it
+                args = []
+                result = '0x' + web3.codec.encode_abi(output_types, [args]).hex()
+                response = f'{{"jsonrpc":"2.0","id":1,"result":"{result}"}}'
             else:
                 raise AssertionError(f'Unexpected etherscan call during tests: {url}')
         elif f'api.etherscan.io/api?module=proxy&action=eth_call&to={ETH_SCAN.address}' in url: