Skip to content

Commit

Permalink
Add async methods for etherscan client
Browse files Browse the repository at this point in the history
  • Loading branch information
moisses89 committed Dec 31, 2024
1 parent fae2eb6 commit d74f3b0
Show file tree
Hide file tree
Showing 7 changed files with 265 additions and 50 deletions.
45 changes: 28 additions & 17 deletions safe_eth/eth/clients/blockscout_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,6 @@ def __init__(
request_timeout: int = int(
os.environ.get("BLOCKSCOUT_CLIENT_REQUEST_TIMEOUT", 10)
),
max_requests: int = int(os.environ.get("BLOCKSCOUT_CLIENT_MAX_REQUESTS", 100)),
):
self.network = network
self.grahpql_url = self.NETWORK_WITH_URL.get(network, "")
Expand All @@ -168,10 +167,6 @@ def __init__(
f"Network {network.name} - {network.value} not supported"
)
self.http_session = requests.Session()
# Limit simultaneous connections to the same host.
self.async_session = aiohttp.ClientSession(
connector=aiohttp.TCPConnector(limit_per_host=max_requests)
)

def build_url(self, path: str):
return urljoin(self.grahpql_url, path)
Expand All @@ -183,18 +178,6 @@ def _do_request(self, url: str, query: str) -> Optional[Dict[str, Any]]:

return response.json()

async def _async_do_request(self, url: str, query: str) -> Optional[Dict[str, Any]]:
"""
Asynchronous version of _do_request
"""
async with self.async_session.post(
url, son={"query": query}, timeout=self.request_timeout
) as response:
if not response.ok:
return None

return await response.json()

def get_contract_metadata(
self, address: ChecksumAddress
) -> Optional[ContractMetadata]:
Expand All @@ -212,6 +195,34 @@ def get_contract_metadata(
)
return None


class AsyncBlockscoutClient(BlockscoutClient):
def __init__(
self,
network: EthereumNetwork,
request_timeout: int = int(
os.environ.get("BLOCKSCOUT_CLIENT_REQUEST_TIMEOUT", 10)
),
max_requests: int = int(os.environ.get("BLOCKSCOUT_CLIENT_MAX_REQUESTS", 100)),
):
super().__init__(network, request_timeout)
# Limit simultaneous connections to the same host.
self.async_session = aiohttp.ClientSession(
connector=aiohttp.TCPConnector(limit_per_host=max_requests)
)

async def _async_do_request(self, url: str, query: str) -> Optional[Dict[str, Any]]:
"""
Asynchronous version of _do_request
"""
async with self.async_session.post(
url, json={"query": query}, timeout=self.request_timeout
) as response:
if not response.ok:
return None

return await response.json()

async def async_get_contract_metadata(
self, address: ChecksumAddress
) -> Optional[ContractMetadata]:
Expand Down
29 changes: 16 additions & 13 deletions safe_eth/eth/clients/etherscan_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,21 @@ def get_contract_metadata(
return ContractMetadata(contract_name, contract_abi, False)
return None

def process_response(self, response):
if response and isinstance(response, list):
result = response[0]
abi_str = result.get("ABI")

if isinstance(abi_str, str) and abi_str.startswith("["):
try:
result["ABI"] = json.loads(abi_str)
except json.JSONDecodeError:
result["ABI"] = None # Handle the case where JSON decoding fails
else:
result["ABI"] = None

return result

def get_contract_source_code(self, contract_address: str, retry: bool = True):
"""
Get source code for a contract. Source code query also returns:
Expand All @@ -390,19 +405,7 @@ def get_contract_source_code(self, contract_address: str, retry: bool = True):
f"module=contract&action=getsourcecode&address={contract_address}"
)
response = self._retry_request(url, retry=retry) # Returns a list
if response and isinstance(response, list):
result = response[0]
abi_str = result.get("ABI")

if isinstance(abi_str, str) and abi_str.startswith("["):
try:
result["ABI"] = json.loads(abi_str)
except json.JSONDecodeError:
result["ABI"] = None # Handle the case where JSON decoding fails
else:
result["ABI"] = None

return result
return self.process_response(response)

def get_contract_abi(self, contract_address: str, retry: bool = True):
url = self.build_url(
Expand Down
88 changes: 86 additions & 2 deletions safe_eth/eth/clients/etherscan_client_v2.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import json
import os
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Union
from urllib.parse import urljoin

import aiohttp
import requests

from safe_eth.eth import EthereumNetwork
from safe_eth.eth.clients import EtherscanClient
from safe_eth.eth.clients import (
ContractMetadata,
EtherscanClient,
EtherscanRateLimitError,
)


class EtherscanClientV2(EtherscanClient):
Expand Down Expand Up @@ -78,3 +84,81 @@ def is_supported_network(cls, network: EthereumNetwork) -> bool:
return any(
item.get("chainid") == str(network.value) for item in supported_networks
)


class AsyncEtherscanClientV2(EtherscanClientV2):
def __init__(
self,
network: EthereumNetwork,
api_key: Optional[str] = None,
request_timeout: int = int(
os.environ.get("ETHERSCAN_CLIENT_REQUEST_TIMEOUT", 10)
),
max_requests: int = int(os.environ.get("ETHERSCAN_CLIENT_MAX_REQUESTS", 100)),
):
super().__init__(network, api_key, request_timeout)
self.async_session = aiohttp.ClientSession(
connector=aiohttp.TCPConnector(limit_per_host=max_requests)
)

async def _async_do_request(
self, url: str
) -> Optional[Union[Dict[str, Any], List[Any], str]]:
"""
Async version of _do_request
"""
async with self.async_session.get(
url, timeout=self.request_timeout
) as response:
if response.ok:
response_json = await response.json()
result = response_json["result"]
if "Max rate limit reached" in result:
# Max rate limit reached, please use API Key for higher rate limit
raise EtherscanRateLimitError
if response_json["status"] == "1":
return result
return None

async def async_get_contract_source_code(
self,
contract_address: str,
):
"""
Asynchronous version of get_contract_source_code
Does not implement retries
:param contract_address:
"""
url = self.build_url(
f"module=contract&action=getsourcecode&address={contract_address}"
)
response = await self._async_do_request(url) # Returns a list
return self.process_response(response)

async def async_get_contract_metadata(
self, contract_address: str
) -> Optional[ContractMetadata]:
contract_source_code = await self.async_get_contract_source_code(
contract_address
)
if contract_source_code:
contract_name = contract_source_code["ContractName"]
contract_abi = contract_source_code["ABI"]
if contract_abi:
return ContractMetadata(contract_name, contract_abi, False)
return None

async def async_get_contract_abi(self, contract_address: str):
url = self.build_url(
f"module=contract&action=getabi&address={contract_address}"
)
result = await self._async_do_request(url)
if isinstance(result, dict):
return result
elif isinstance(result, str):
try:
return json.loads(result)
except json.JSONDecodeError:
pass
return None
47 changes: 30 additions & 17 deletions safe_eth/eth/clients/sourcify_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,6 @@ def __init__(
self.base_url_repo = base_url_repo
self.http_session = prepare_http_session(10, max_requests)
self.request_timeout = request_timeout
# Limit simultaneous connections to the same host.
self.async_session = aiohttp.ClientSession(
connector=aiohttp.TCPConnector(limit_per_host=max_requests)
)

if not self.is_chain_supported(network.value):
raise SourcifyClientConfigurationProblem(
f"Network {network.name} - {network.value} not supported"
Expand All @@ -73,18 +68,6 @@ def _do_request(self, url: str) -> Optional[Dict[str, Any]]:

return response.json()

async def _async_do_request(self, url: str) -> Optional[Dict[str, Any]]:
"""
Asynchronous version of _do_request
"""
async with self.async_session.get(
url, timeout=self.request_timeout
) as response:
if not response.ok:
return None

return await response.json()

def is_chain_supported(self, chain_id: int) -> bool:
chains = self.get_chains()
if not chains:
Expand Down Expand Up @@ -127,6 +110,36 @@ def get_contract_metadata(
return ContractMetadata(name, abi, match_type == "partial_match")
return None


class AsyncSourcifyClient(SourcifyClient):
def __init__(
self,
network: EthereumNetwork = EthereumNetwork.MAINNET,
base_url_api: str = "https://sourcify.dev",
base_url_repo: str = "https://repo.sourcify.dev/",
request_timeout: int = int(
os.environ.get("SOURCIFY_CLIENT_REQUEST_TIMEOUT", 10)
),
max_requests: int = int(os.environ.get("SOURCIFY_CLIENT_MAX_REQUESTS", 100)),
):
super().__init__(network, base_url_api, base_url_repo, request_timeout)
# Limit simultaneous connections to the same host.
self.async_session = aiohttp.ClientSession(
connector=aiohttp.TCPConnector(limit_per_host=max_requests)
)

async def _async_do_request(self, url: str) -> Optional[Dict[str, Any]]:
"""
Asynchronous version of _do_request
"""
async with self.async_session.get(
url, timeout=self.request_timeout
) as response:
if not response.ok:
return None

return await response.json()

async def async_get_contract_metadata(
self, contract_address: str
) -> Optional[ContractMetadata]:
Expand Down
19 changes: 19 additions & 0 deletions safe_eth/eth/tests/clients/test_blockscout_client.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import unittest

from django.test import TestCase

import pytest

from ... import EthereumNetwork
from ...clients import BlockscoutClient, BlockScoutConfigurationProblem
from ...clients.blockscout_client import AsyncBlockscoutClient
from .mocks import sourcify_safe_metadata


Expand All @@ -23,3 +26,19 @@ def test_blockscout_client(self):
self.assertEqual(contract_metadata.abi, safe_master_copy_abi)
random_address = "0xaE32496491b53841efb51829d6f886387708F99a"
self.assertIsNone(blockscout_client.get_contract_metadata(random_address))


class TestAsyncBlockscoutClient(unittest.IsolatedAsyncioTestCase):
async def test_async_blockscout_client(self):
blockscout_client = AsyncBlockscoutClient(EthereumNetwork.GNOSIS)
safe_master_copy_abi = sourcify_safe_metadata["output"]["abi"]
safe_master_copy_address = "0x6851D6fDFAfD08c0295C392436245E5bc78B0185"
contract_metadata = await blockscout_client.async_get_contract_metadata(
safe_master_copy_address
)
self.assertEqual(contract_metadata.name, "GnosisSafe")
self.assertEqual(contract_metadata.abi, safe_master_copy_abi)
random_address = "0xaE32496491b53841efb51829d6f886387708F99a"
self.assertIsNone(
await blockscout_client.async_get_contract_metadata(random_address)
)
39 changes: 39 additions & 0 deletions safe_eth/eth/tests/clients/test_etherscan_client_v2.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import os
import unittest

from django.test import TestCase

import pytest

from ... import EthereumNetwork
from ...clients import EtherscanClientV2, EtherscanRateLimitError
from ...clients.etherscan_client_v2 import AsyncEtherscanClientV2
from .mocks import sourcify_safe_metadata


Expand Down Expand Up @@ -51,3 +53,40 @@ def test_is_supported_network(self):
)
except EtherscanRateLimitError:
self.skipTest("Etherscan rate limit reached")


class TestAsyncEtherscanClientV2(unittest.IsolatedAsyncioTestCase):
@classmethod
def get_etherscan_api(cls, network: EthereumNetwork):
etherscan_api_key_variable_name = "ETHERSCAN_API_KEY"
etherscan_api_key = os.environ.get(etherscan_api_key_variable_name)
if not etherscan_api_key:
pytest.skip(f"{etherscan_api_key_variable_name} needs to be defined")

return AsyncEtherscanClientV2(network, api_key=etherscan_api_key)

async def test_async_etherscan_get_abi(self):
try:
etherscan_api = self.get_etherscan_api(EthereumNetwork.MAINNET)
safe_master_copy_abi = sourcify_safe_metadata["output"]["abi"]
safe_master_copy_address = "0x6851D6fDFAfD08c0295C392436245E5bc78B0185"
self.assertEqual(
await etherscan_api.async_get_contract_abi(safe_master_copy_address),
safe_master_copy_abi,
)

contract_metadata = await etherscan_api.async_get_contract_metadata(
safe_master_copy_address
)
self.assertEqual(contract_metadata.name, "GnosisSafe")
self.assertEqual(contract_metadata.abi, safe_master_copy_abi)

random_address = "0xaE32496491b53841efb51829d6f886387708F99a"
self.assertIsNone(
await etherscan_api.async_get_contract_abi(random_address)
)
self.assertIsNone(
await etherscan_api.async_get_contract_metadata(random_address)
)
except EtherscanRateLimitError:
self.skipTest("Etherscan rate limit reached")
Loading

0 comments on commit d74f3b0

Please sign in to comment.