Skip to content

Commit 8e75014

Browse files
authored
Add async methods to source contracts clients (#1505)
1 parent cabd05f commit 8e75014

8 files changed

+362
-38
lines changed

safe_eth/eth/clients/__init__.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# flake8: noqa F401
22
from .blockscout_client import (
3+
AsyncBlockscoutClient,
34
BlockscoutClient,
45
BlockscoutClientException,
56
BlockScoutConfigurationProblem,
@@ -12,14 +13,18 @@
1213
EtherscanClientException,
1314
EtherscanRateLimitError,
1415
)
15-
from .etherscan_client_v2 import EtherscanClientV2
16+
from .etherscan_client_v2 import AsyncEtherscanClientV2, EtherscanClientV2
1617
from .sourcify_client import (
18+
AsyncSourcifyClient,
1719
SourcifyClient,
1820
SourcifyClientConfigurationProblem,
1921
SourcifyClientException,
2022
)
2123

2224
__all__ = [
25+
"AsyncBlockscoutClient",
26+
"AsyncEtherscanClientV2",
27+
"AsyncSourcifyClient",
2328
"BlockScoutConfigurationProblem",
2429
"BlockscoutClient",
2530
"BlockscoutClientException",

safe_eth/eth/clients/blockscout_client.py

+69-10
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import json
2+
import os
23
from typing import Any, Dict, Optional
34
from urllib.parse import urljoin
45

6+
import aiohttp
57
import requests
68
from eth_typing import ChecksumAddress
79

@@ -150,9 +152,16 @@ class BlockscoutClient:
150152
EthereumNetwork.EXSAT_TESTNET: "https://scan-testnet.exsat.network/api/v1/graphql",
151153
}
152154

153-
def __init__(self, network: EthereumNetwork):
155+
def __init__(
156+
self,
157+
network: EthereumNetwork,
158+
request_timeout: int = int(
159+
os.environ.get("BLOCKSCOUT_CLIENT_REQUEST_TIMEOUT", 10)
160+
),
161+
):
154162
self.network = network
155163
self.grahpql_url = self.NETWORK_WITH_URL.get(network, "")
164+
self.request_timeout = request_timeout
156165
if not self.grahpql_url:
157166
raise BlockScoutConfigurationProblem(
158167
f"Network {network.name} - {network.value} not supported"
@@ -169,19 +178,69 @@ def _do_request(self, url: str, query: str) -> Optional[Dict[str, Any]]:
169178

170179
return response.json()
171180

172-
def get_contract_metadata(
173-
self, address: ChecksumAddress
181+
@staticmethod
182+
def _process_contract_metadata(
183+
contract_data: dict[str, Any]
174184
) -> Optional[ContractMetadata]:
175-
query = '{address(hash: "%s") { hash, smartContract {name, abi} }}' % address
176-
result = self._do_request(self.grahpql_url, query)
185+
"""
186+
Return a ContractMetadata from BlockScout response
187+
188+
:param contract_data:
189+
:return:
190+
"""
177191
if (
178-
result
179-
and "error" not in result
180-
and result.get("data", {}).get("address", {})
181-
and result["data"]["address"]["smartContract"]
192+
"error" not in contract_data
193+
and contract_data.get("data", {}).get("address", {})
194+
and contract_data["data"]["address"]["smartContract"]
182195
):
183-
smart_contract = result["data"]["address"]["smartContract"]
196+
smart_contract = contract_data["data"]["address"]["smartContract"]
184197
return ContractMetadata(
185198
smart_contract["name"], json.loads(smart_contract["abi"]), False
186199
)
187200
return None
201+
202+
def get_contract_metadata(
203+
self, address: ChecksumAddress
204+
) -> Optional[ContractMetadata]:
205+
query = '{address(hash: "%s") { hash, smartContract {name, abi} }}' % address
206+
contract_data = self._do_request(self.grahpql_url, query)
207+
if contract_data:
208+
return self._process_contract_metadata(contract_data)
209+
return None
210+
211+
212+
class AsyncBlockscoutClient(BlockscoutClient):
213+
def __init__(
214+
self,
215+
network: EthereumNetwork,
216+
request_timeout: int = int(
217+
os.environ.get("BLOCKSCOUT_CLIENT_REQUEST_TIMEOUT", 10)
218+
),
219+
max_requests: int = int(os.environ.get("BLOCKSCOUT_CLIENT_MAX_REQUESTS", 100)),
220+
):
221+
super().__init__(network, request_timeout)
222+
# Limit simultaneous connections to the same host.
223+
self.async_session = aiohttp.ClientSession(
224+
connector=aiohttp.TCPConnector(limit_per_host=max_requests)
225+
)
226+
227+
async def _async_do_request(self, url: str, query: str) -> Optional[Dict[str, Any]]:
228+
"""
229+
Asynchronous version of _do_request
230+
"""
231+
async with self.async_session.post(
232+
url, json={"query": query}, timeout=self.request_timeout
233+
) as response:
234+
if not response.ok:
235+
return None
236+
237+
return await response.json()
238+
239+
async def async_get_contract_metadata(
240+
self, address: ChecksumAddress
241+
) -> Optional[ContractMetadata]:
242+
query = '{address(hash: "%s") { hash, smartContract {name, abi} }}' % address
243+
contract_data = await self._async_do_request(self.grahpql_url, query)
244+
if contract_data:
245+
return self._process_contract_metadata(contract_data)
246+
return None

safe_eth/eth/clients/etherscan_client.py

+28-17
Original file line numberDiff line numberDiff line change
@@ -353,19 +353,42 @@ def _retry_request(
353353
time.sleep(5)
354354
return None
355355

356+
@staticmethod
357+
def _process_contract_metadata(
358+
contract_data: Dict[str, Any]
359+
) -> Optional[ContractMetadata]:
360+
contract_name = contract_data["ContractName"]
361+
contract_abi = contract_data["ABI"]
362+
if contract_abi:
363+
return ContractMetadata(contract_name, contract_abi, False)
364+
return None
365+
356366
def get_contract_metadata(
357367
self, contract_address: str, retry: bool = True
358368
) -> Optional[ContractMetadata]:
359369
contract_source_code = self.get_contract_source_code(
360370
contract_address, retry=retry
361371
)
362372
if contract_source_code:
363-
contract_name = contract_source_code["ContractName"]
364-
contract_abi = contract_source_code["ABI"]
365-
if contract_abi:
366-
return ContractMetadata(contract_name, contract_abi, False)
373+
return self._process_contract_metadata(contract_source_code)
367374
return None
368375

376+
@staticmethod
377+
def _process_get_contract_source_code_response(response):
378+
if response and isinstance(response, list):
379+
result = response[0]
380+
abi_str = result.get("ABI")
381+
382+
if isinstance(abi_str, str) and abi_str.startswith("["):
383+
try:
384+
result["ABI"] = json.loads(abi_str)
385+
except json.JSONDecodeError:
386+
result["ABI"] = None # Handle the case where JSON decoding fails
387+
else:
388+
result["ABI"] = None
389+
390+
return result
391+
369392
def get_contract_source_code(self, contract_address: str, retry: bool = True):
370393
"""
371394
Get source code for a contract. Source code query also returns:
@@ -390,19 +413,7 @@ def get_contract_source_code(self, contract_address: str, retry: bool = True):
390413
f"module=contract&action=getsourcecode&address={contract_address}"
391414
)
392415
response = self._retry_request(url, retry=retry) # Returns a list
393-
if response and isinstance(response, list):
394-
result = response[0]
395-
abi_str = result.get("ABI")
396-
397-
if isinstance(abi_str, str) and abi_str.startswith("["):
398-
try:
399-
result["ABI"] = json.loads(abi_str)
400-
except json.JSONDecodeError:
401-
result["ABI"] = None # Handle the case where JSON decoding fails
402-
else:
403-
result["ABI"] = None
404-
405-
return result
416+
return self._process_get_contract_source_code_response(response)
406417

407418
def get_contract_abi(self, contract_address: str, retry: bool = True):
408419
url = self.build_url(

safe_eth/eth/clients/etherscan_client_v2.py

+83-2
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
1+
import json
12
import os
2-
from typing import Any, Dict, List, Optional
3+
from typing import Any, Dict, List, Optional, Union
34
from urllib.parse import urljoin
45

6+
import aiohttp
57
import requests
68

79
from safe_eth.eth import EthereumNetwork
8-
from safe_eth.eth.clients import EtherscanClient
10+
from safe_eth.eth.clients import (
11+
ContractMetadata,
12+
EtherscanClient,
13+
EtherscanRateLimitError,
14+
)
915

1016

1117
class EtherscanClientV2(EtherscanClient):
@@ -78,3 +84,78 @@ def is_supported_network(cls, network: EthereumNetwork) -> bool:
7884
return any(
7985
item.get("chainid") == str(network.value) for item in supported_networks
8086
)
87+
88+
89+
class AsyncEtherscanClientV2(EtherscanClientV2):
90+
def __init__(
91+
self,
92+
network: EthereumNetwork,
93+
api_key: Optional[str] = None,
94+
request_timeout: int = int(
95+
os.environ.get("ETHERSCAN_CLIENT_REQUEST_TIMEOUT", 10)
96+
),
97+
max_requests: int = int(os.environ.get("ETHERSCAN_CLIENT_MAX_REQUESTS", 100)),
98+
):
99+
super().__init__(network, api_key, request_timeout)
100+
self.async_session = aiohttp.ClientSession(
101+
connector=aiohttp.TCPConnector(limit_per_host=max_requests)
102+
)
103+
104+
async def _async_do_request(
105+
self, url: str
106+
) -> Optional[Union[Dict[str, Any], List[Any], str]]:
107+
"""
108+
Async version of _do_request
109+
"""
110+
async with self.async_session.get(
111+
url, timeout=self.request_timeout
112+
) as response:
113+
if response.ok:
114+
response_json = await response.json()
115+
result = response_json["result"]
116+
if "Max rate limit reached" in result:
117+
# Max rate limit reached, please use API Key for higher rate limit
118+
raise EtherscanRateLimitError
119+
if response_json["status"] == "1":
120+
return result
121+
return None
122+
123+
async def async_get_contract_source_code(
124+
self,
125+
contract_address: str,
126+
):
127+
"""
128+
Asynchronous version of get_contract_source_code
129+
Does not implement retries
130+
131+
:param contract_address:
132+
"""
133+
url = self.build_url(
134+
f"module=contract&action=getsourcecode&address={contract_address}"
135+
)
136+
response = await self._async_do_request(url) # Returns a list
137+
return self._process_get_contract_source_code_response(response)
138+
139+
async def async_get_contract_metadata(
140+
self, contract_address: str
141+
) -> Optional[ContractMetadata]:
142+
contract_source_code = await self.async_get_contract_source_code(
143+
contract_address
144+
)
145+
if contract_source_code:
146+
return self._process_contract_metadata(contract_source_code)
147+
return None
148+
149+
async def async_get_contract_abi(self, contract_address: str):
150+
url = self.build_url(
151+
f"module=contract&action=getabi&address={contract_address}"
152+
)
153+
result = await self._async_do_request(url)
154+
if isinstance(result, dict):
155+
return result
156+
elif isinstance(result, str):
157+
try:
158+
return json.loads(result)
159+
except json.JSONDecodeError:
160+
pass
161+
return None

0 commit comments

Comments
 (0)