From 78f286d533f11e145957e660fa7cf50e7614a7da Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Wed, 11 Sep 2024 13:14:34 +0200 Subject: [PATCH 01/12] Create ExplorerSettings to simplify calling Etherscan APIs --- boa/explorer.py | 29 +++++++++++-------- boa/interpret.py | 12 ++++---- tests/integration/fork/test_from_etherscan.py | 20 ++++++------- 3 files changed, 33 insertions(+), 28 deletions(-) diff --git a/boa/explorer.py b/boa/explorer.py index f1524523..8a9cc4d7 100644 --- a/boa/explorer.py +++ b/boa/explorer.py @@ -1,4 +1,6 @@ +import os import time +from dataclasses import dataclass from typing import Optional from boa.rpc import json @@ -21,25 +23,28 @@ SESSION = Session() +@dataclass +class ExplorerSettings: + api_key: Optional[str] = os.environ.get("ETHERSCAN_API_KEY") + uri: str = os.environ.get("ETHERSCAN_URI", "https://api.etherscan.io/api") + + def _fetch_etherscan( - uri: str, api_key: Optional[str] = None, num_retries=10, backoff_ms=400, **params + settings: ExplorerSettings, num_retries=10, backoff_ms=400, **params ) -> dict: """ Fetch data from Etherscan API. Offers a simple caching mechanism to avoid redundant queries. Retries if rate limit is reached. - :param uri: Etherscan API URI - :param api_key: Etherscan API key + :param settings: Etherscan settings :param num_retries: Number of retries :param backoff_ms: Backoff in milliseconds :param params: Additional query parameters :return: JSON response """ - if api_key is not None: - params["apikey"] = api_key - + params = {**params, "apiKey": settings.api_key} for i in range(num_retries): - res = SESSION.get(uri, params=params) + res = SESSION.get(settings.uri, params=params) res.raise_for_status() data = res.json() if not _is_rate_limited(data): @@ -70,23 +75,23 @@ def _is_rate_limited(data: dict) -> bool: def fetch_abi_from_etherscan( - address: str, uri: str = "https://api.etherscan.io/api", api_key: str = None + address: str, settings: ExplorerSettings = ExplorerSettings() ): # resolve implementation address if `address` is a proxy contract - address = _resolve_implementation_address(address, uri, api_key) + address = _resolve_implementation_address(address, settings) # fetch ABI of `address` params = dict(module="contract", action="getabi", address=address) - data = _fetch_etherscan(uri, api_key, **params) + data = _fetch_etherscan(settings, **params) return json.loads(data["result"].strip()) # fetch the address of a contract; resolves at most one layer of indirection # if the address is a proxy contract. -def _resolve_implementation_address(address: str, uri: str, api_key: Optional[str]): +def _resolve_implementation_address(address: str, settings: ExplorerSettings): params = dict(module="contract", action="getsourcecode", address=address) - data = _fetch_etherscan(uri, api_key, **params) + data = _fetch_etherscan(settings, **params) source_data = data["result"][0] # check if the contract is a proxy diff --git a/boa/interpret.py b/boa/interpret.py index db746f9f..8ee512e4 100644 --- a/boa/interpret.py +++ b/boa/interpret.py @@ -28,7 +28,7 @@ VyperDeployer, ) from boa.environment import Env -from boa.explorer import fetch_abi_from_etherscan +from boa.explorer import ExplorerSettings, fetch_abi_from_etherscan from boa.rpc import json from boa.util.abi import Address from boa.util.disk_cache import DiskCache @@ -41,6 +41,7 @@ _disk_cache = None _search_path = None +explorer_settings = ExplorerSettings() def set_search_path(path: list[str]): @@ -253,11 +254,12 @@ def _compile(): return _disk_cache.caching_lookup(cache_key, _compile) -def from_etherscan( - address: Any, name=None, uri="https://api.etherscan.io/api", api_key=None -): +def from_etherscan(address: Any, name=None, uri=None, api_key=None): addr = Address(address) - abi = fetch_abi_from_etherscan(addr, uri, api_key) + api_key = api_key or explorer_settings.api_key + uri = uri or explorer_settings.uri + settings = ExplorerSettings(api_key, uri) + abi = fetch_abi_from_etherscan(addr, settings) return ABIContractFactory.from_abi_dict(abi, name=name).at(addr) diff --git a/tests/integration/fork/test_from_etherscan.py b/tests/integration/fork/test_from_etherscan.py index 6e04b342..662dac6e 100644 --- a/tests/integration/fork/test_from_etherscan.py +++ b/tests/integration/fork/test_from_etherscan.py @@ -14,22 +14,22 @@ voting_agent = "0xE478de485ad2fe566d49342Cbd03E49ed7DB3356" -@pytest.fixture(scope="module") +@pytest.fixture(scope="module", autouse=True) def api_key(): - return os.environ.get("ETHERSCAN_API_KEY") + boa.interpret.explorer_settings.api_key = os.environ["ETHERSCAN_API_KEY"] @pytest.fixture(scope="module") -def crvusd_contract(api_key): - return boa.from_etherscan(crvusd, name="crvUSD", api_key=api_key) +def crvusd_contract(): + return boa.from_etherscan(crvusd, name="crvUSD") @pytest.fixture(scope="module") -def proxy_contract(api_key): - return boa.from_etherscan(voting_agent, name="VotingAgent", api_key=api_key) +def proxy_contract(): + return boa.from_etherscan(voting_agent, name="VotingAgent") -def test_cache(api_key, proxy_contract): +def test_cache(proxy_contract): assert isinstance(SESSION, CachedSession) with patch("requests.adapters.HTTPAdapter.send") as mock: mock.side_effect = AssertionError @@ -37,12 +37,10 @@ def test_cache(api_key, proxy_contract): # cache miss for non-cached contract with pytest.raises(AssertionError): address = boa.env.generate_address() - boa.from_etherscan(address, name="VotingAgent", api_key=api_key) + boa.from_etherscan(address, name="VotingAgent") # cache hit for cached contract - c = boa.from_etherscan( - proxy_contract.address, name="VotingAgent", api_key=api_key - ) + c = boa.from_etherscan(proxy_contract.address, name="VotingAgent") assert isinstance(c, ABIContract) From df5b807587f338f26e79e08d71076e6b13294fac Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Wed, 11 Sep 2024 13:37:51 +0200 Subject: [PATCH 02/12] Add fetch to BlockExplorer --- boa/explorer.py | 73 +++++++++++++------ boa/interpret.py | 12 ++- tests/integration/fork/test_from_etherscan.py | 2 +- 3 files changed, 55 insertions(+), 32 deletions(-) diff --git a/boa/explorer.py b/boa/explorer.py index 8a9cc4d7..edef0ab7 100644 --- a/boa/explorer.py +++ b/boa/explorer.py @@ -24,38 +24,63 @@ @dataclass -class ExplorerSettings: +class BlockExplorer: api_key: Optional[str] = os.environ.get("ETHERSCAN_API_KEY") uri: str = os.environ.get("ETHERSCAN_URI", "https://api.etherscan.io/api") + num_retries: int = 10 + backoff_ms: int = 400 + + def fetch(self, **params) -> dict: + """ + Fetch data from Etherscan API. + Offers a simple caching mechanism to avoid redundant queries. + Retries if rate limit is reached. + :param num_retries: Number of retries + :param backoff_ms: Backoff in milliseconds + :param params: Additional query parameters + :return: JSON response + """ + params = {**params, "apiKey": self.api_key} + for i in range(self.num_retries): + res = SESSION.get(self.uri, params=params) + res.raise_for_status() + data = res.json() + if not _is_rate_limited(data): + break + backoff_factor = 1.1**i # 1.1**10 ~= 2.59 + time.sleep(backoff_factor * self.backoff_ms / 1000) + + if not _is_success_response(data): + raise ValueError(f"Failed to retrieve data from API: {data}") + + return data + + +etherscan = BlockExplorer() def _fetch_etherscan( - settings: ExplorerSettings, num_retries=10, backoff_ms=400, **params + uri: Optional[str], + api_key: Optional[str] = None, + num_retries=10, + backoff_ms=400, + **params, ) -> dict: """ Fetch data from Etherscan API. Offers a simple caching mechanism to avoid redundant queries. Retries if rate limit is reached. - :param settings: Etherscan settings + :param uri: Etherscan API URI + :param api_key: Etherscan API key :param num_retries: Number of retries :param backoff_ms: Backoff in milliseconds :param params: Additional query parameters :return: JSON response """ - params = {**params, "apiKey": settings.api_key} - for i in range(num_retries): - res = SESSION.get(settings.uri, params=params) - res.raise_for_status() - data = res.json() - if not _is_rate_limited(data): - break - backoff_factor = 1.1**i # 1.1**10 ~= 2.59 - time.sleep(backoff_factor * backoff_ms / 1000) - - if not _is_success_response(data): - raise ValueError(f"Failed to retrieve data from API: {data}") - - return data + uri = uri or etherscan.uri + api_key = api_key or etherscan.api_key + explorer = BlockExplorer(api_key, uri, num_retries, backoff_ms) + return explorer.fetch(**params) def _is_success_response(data: dict) -> bool: @@ -74,24 +99,24 @@ def _is_rate_limited(data: dict) -> bool: return "rate limit" in data.get("result", "") and data.get("status") == "0" -def fetch_abi_from_etherscan( - address: str, settings: ExplorerSettings = ExplorerSettings() -): +def fetch_abi_from_etherscan(address: str, uri: str = None, api_key: str = None): # resolve implementation address if `address` is a proxy contract - address = _resolve_implementation_address(address, settings) + address = _resolve_implementation_address(address, uri, api_key) # fetch ABI of `address` params = dict(module="contract", action="getabi", address=address) - data = _fetch_etherscan(settings, **params) + data = _fetch_etherscan(uri, api_key, **params) return json.loads(data["result"].strip()) # fetch the address of a contract; resolves at most one layer of indirection # if the address is a proxy contract. -def _resolve_implementation_address(address: str, settings: ExplorerSettings): +def _resolve_implementation_address( + address: str, uri: Optional[str], api_key: Optional[str] +): params = dict(module="contract", action="getsourcecode", address=address) - data = _fetch_etherscan(settings, **params) + data = _fetch_etherscan(uri, api_key, **params) source_data = data["result"][0] # check if the contract is a proxy diff --git a/boa/interpret.py b/boa/interpret.py index 8ee512e4..18e62de6 100644 --- a/boa/interpret.py +++ b/boa/interpret.py @@ -28,7 +28,7 @@ VyperDeployer, ) from boa.environment import Env -from boa.explorer import ExplorerSettings, fetch_abi_from_etherscan +from boa.explorer import fetch_abi_from_etherscan from boa.rpc import json from boa.util.abi import Address from boa.util.disk_cache import DiskCache @@ -41,7 +41,6 @@ _disk_cache = None _search_path = None -explorer_settings = ExplorerSettings() def set_search_path(path: list[str]): @@ -254,12 +253,11 @@ def _compile(): return _disk_cache.caching_lookup(cache_key, _compile) -def from_etherscan(address: Any, name=None, uri=None, api_key=None): +def from_etherscan( + address: Any, name: str = None, uri: str = None, api_key: str = None +): addr = Address(address) - api_key = api_key or explorer_settings.api_key - uri = uri or explorer_settings.uri - settings = ExplorerSettings(api_key, uri) - abi = fetch_abi_from_etherscan(addr, settings) + abi = fetch_abi_from_etherscan(addr, uri, api_key) return ABIContractFactory.from_abi_dict(abi, name=name).at(addr) diff --git a/tests/integration/fork/test_from_etherscan.py b/tests/integration/fork/test_from_etherscan.py index 662dac6e..a380be98 100644 --- a/tests/integration/fork/test_from_etherscan.py +++ b/tests/integration/fork/test_from_etherscan.py @@ -16,7 +16,7 @@ @pytest.fixture(scope="module", autouse=True) def api_key(): - boa.interpret.explorer_settings.api_key = os.environ["ETHERSCAN_API_KEY"] + boa.explorer.etherscan.api_key = os.environ["ETHERSCAN_API_KEY"] @pytest.fixture(scope="module") From 201c0dd5b23abcaddca725d60f7ad4a159ec4874 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Wed, 11 Sep 2024 13:48:44 +0200 Subject: [PATCH 03/12] Generalize TmpEnvMgr thing --- CONTRIBUTING.md | 2 +- boa/__init__.py | 31 +++++++++++++------ tests/integration/fork/test_from_etherscan.py | 3 +- tests/unitary/test_boa.py | 4 +-- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ab445856..b2dba295 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,7 +53,7 @@ source venv/bin/activate # Install dev requirements pip install -r dev-requirements.txt # Install prod requirements (in the pyproject.tom) -pip install . +pip install . ``` *Note: When you delete your terminal/shell, you will need to reactivate this virtual environment again each time. To exit this python virtual environment, type `deactivate`* diff --git a/boa/__init__.py b/boa/__init__.py index 980cd35c..8a5fe17f 100644 --- a/boa/__init__.py +++ b/boa/__init__.py @@ -1,11 +1,13 @@ import contextlib import sys +import boa.explorer from boa.contracts.base_evm_contract import BoaError from boa.contracts.vyper.vyper_contract import check_boa_error_matches from boa.dealer import deal from boa.debugger import BoaDebug from boa.environment import Env +from boa.explorer import BlockExplorer from boa.interpret import ( from_etherscan, load, @@ -49,19 +51,18 @@ def set_env(new_env): # Simple context manager which functions like the `open()` builtin - # if simply called, it never calls __exit__, but if used as a context manager, # it calls __exit__ at scope exit -class _TmpEnvMgr: - def __init__(self, new_env): - global env - self.old_env = env - - set_env(new_env) +class _TemporaryContext: + def __init__(self, old_value, new_value, set_value): + self.old_value = old_value + self.set_value = set_value + set_value(new_value) def __enter__(self): # dummy pass def __exit__(self, *args): - set_env(self.old_env) + self.set_value(self.old_value) def fork( @@ -75,20 +76,30 @@ def fork( new_env = Env() new_env.fork(url=url, block_identifier=block_identifier, deprecated=False, **kwargs) - return _TmpEnvMgr(new_env) + return _TemporaryContext(env, new_env, set_env) def set_browser_env(address=None): """Set the environment to use the browser's network in Jupyter/Colab""" # import locally because jupyter is generally not installed + global env from boa.integrations.jupyter import BrowserEnv - return _TmpEnvMgr(BrowserEnv(address)) + return _TemporaryContext(env, BrowserEnv(address), set_env) def set_network_env(url): """Set the environment to use a custom network URL""" - return _TmpEnvMgr(NetworkEnv.from_url(url)) + global env + return _TemporaryContext(env, NetworkEnv.from_url(url), set_env) + + +def set_etherscan(*args, **kwargs): + def set(explorer: BlockExplorer): + boa.explorer.etherscan = explorer + + explorer = BlockExplorer(*args, **kwargs) + return _TemporaryContext(boa.explorer.etherscan, explorer, set) def reset_env(): diff --git a/tests/integration/fork/test_from_etherscan.py b/tests/integration/fork/test_from_etherscan.py index a380be98..2d96fcb1 100644 --- a/tests/integration/fork/test_from_etherscan.py +++ b/tests/integration/fork/test_from_etherscan.py @@ -16,7 +16,8 @@ @pytest.fixture(scope="module", autouse=True) def api_key(): - boa.explorer.etherscan.api_key = os.environ["ETHERSCAN_API_KEY"] + with boa.set_etherscan(os.environ["ETHERSCAN_API_KEY"]): + yield @pytest.fixture(scope="module") diff --git a/tests/unitary/test_boa.py b/tests/unitary/test_boa.py index b73089b1..9fefcac5 100644 --- a/tests/unitary/test_boa.py +++ b/tests/unitary/test_boa.py @@ -4,7 +4,7 @@ def test_env_mgr_noctx(): s = boa.env t = boa.Env() - boa._TmpEnvMgr(t) + boa._TemporaryContext(t) assert boa.env is not s assert boa.env is t @@ -13,7 +13,7 @@ def test_env_mgr_with_ctx(): s = boa.env t = boa.Env() - with boa._TmpEnvMgr(t): + with boa._TemporaryContext(t): assert boa.env is not s assert boa.env is t From 204508da615640cbc35c7c14e8c9c54624c874c0 Mon Sep 17 00:00:00 2001 From: Daniel Schiavini Date: Wed, 11 Sep 2024 13:52:01 +0200 Subject: [PATCH 04/12] Update test --- tests/unitary/test_boa.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unitary/test_boa.py b/tests/unitary/test_boa.py index 9fefcac5..5f35257b 100644 --- a/tests/unitary/test_boa.py +++ b/tests/unitary/test_boa.py @@ -4,7 +4,7 @@ def test_env_mgr_noctx(): s = boa.env t = boa.Env() - boa._TemporaryContext(t) + boa._TemporaryContext(s, t, boa.set_env) assert boa.env is not s assert boa.env is t @@ -13,7 +13,7 @@ def test_env_mgr_with_ctx(): s = boa.env t = boa.Env() - with boa._TemporaryContext(t): + with boa._TemporaryContext(s, t, boa.set_env): assert boa.env is not s assert boa.env is t From 8af8e3ab380e2ab0fd675fde223922f3f8d35941 Mon Sep 17 00:00:00 2001 From: Charles Cooper Date: Wed, 11 Sep 2024 07:58:39 -0400 Subject: [PATCH 05/12] rename BlockExplorer class --- boa/__init__.py | 8 ++++---- boa/explorer.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/boa/__init__.py b/boa/__init__.py index 8a5fe17f..125006c2 100644 --- a/boa/__init__.py +++ b/boa/__init__.py @@ -7,7 +7,7 @@ from boa.dealer import deal from boa.debugger import BoaDebug from boa.environment import Env -from boa.explorer import BlockExplorer +from boa.explorer import Etherscan from boa.interpret import ( from_etherscan, load, @@ -95,11 +95,11 @@ def set_network_env(url): def set_etherscan(*args, **kwargs): - def set(explorer: BlockExplorer): + def set_(explorer: Etherscan): boa.explorer.etherscan = explorer - explorer = BlockExplorer(*args, **kwargs) - return _TemporaryContext(boa.explorer.etherscan, explorer, set) + explorer = Etherscan(*args, **kwargs) + return _TemporaryContext(boa.explorer.etherscan, explorer, set_) def reset_env(): diff --git a/boa/explorer.py b/boa/explorer.py index edef0ab7..c906b8b3 100644 --- a/boa/explorer.py +++ b/boa/explorer.py @@ -24,7 +24,7 @@ @dataclass -class BlockExplorer: +class Etherscan: api_key: Optional[str] = os.environ.get("ETHERSCAN_API_KEY") uri: str = os.environ.get("ETHERSCAN_URI", "https://api.etherscan.io/api") num_retries: int = 10 @@ -56,7 +56,7 @@ def fetch(self, **params) -> dict: return data -etherscan = BlockExplorer() +etherscan = Etherscan() def _fetch_etherscan( @@ -79,7 +79,7 @@ def _fetch_etherscan( """ uri = uri or etherscan.uri api_key = api_key or etherscan.api_key - explorer = BlockExplorer(api_key, uri, num_retries, backoff_ms) + explorer = Etherscan(api_key, uri, num_retries, backoff_ms) return explorer.fetch(**params) From 661bec0dbd3385994ad3f5fd9385985cdb331873 Mon Sep 17 00:00:00 2001 From: Charles Cooper Date: Wed, 11 Sep 2024 08:00:06 -0400 Subject: [PATCH 06/12] factor out backoff_factor --- boa/explorer.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/boa/explorer.py b/boa/explorer.py index c906b8b3..39d14ed2 100644 --- a/boa/explorer.py +++ b/boa/explorer.py @@ -28,7 +28,8 @@ class Etherscan: api_key: Optional[str] = os.environ.get("ETHERSCAN_API_KEY") uri: str = os.environ.get("ETHERSCAN_URI", "https://api.etherscan.io/api") num_retries: int = 10 - backoff_ms: int = 400 + backoff_ms: int | float = 400.0 + backoff_factor: float = 1.1 # 1.1**10 ~= 2.59 def fetch(self, **params) -> dict: """ @@ -47,8 +48,10 @@ def fetch(self, **params) -> dict: data = res.json() if not _is_rate_limited(data): break - backoff_factor = 1.1**i # 1.1**10 ~= 2.59 - time.sleep(backoff_factor * self.backoff_ms / 1000) + + f = self.backoff_factor**i + seconds = self.backoff_ms / 1000 + time.sleep(f * seconds) if not _is_success_response(data): raise ValueError(f"Failed to retrieve data from API: {data}") From 1c3d12ee08d8d62f5347c2b8d6897effbc7a7e4e Mon Sep 17 00:00:00 2001 From: Charles Cooper Date: Wed, 11 Sep 2024 08:13:03 -0400 Subject: [PATCH 07/12] fix lint --- boa/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/boa/__init__.py b/boa/__init__.py index 166330e8..e5bed866 100644 --- a/boa/__init__.py +++ b/boa/__init__.py @@ -81,8 +81,10 @@ def set_network_env(url): """Set the environment to use a custom network URL""" return _env_mgr(NetworkEnv.from_url(url)) + def set_etherscan(*args, **kwargs): get = lambda: boa.explorer.etherscan # noqa: E731 + def set_(explorer: Etherscan): boa.explorer.etherscan = explorer From 2b8c55be5ff75e244b67fb96d8f76ea77dcd7739 Mon Sep 17 00:00:00 2001 From: Charles Cooper Date: Wed, 11 Sep 2024 08:34:37 -0400 Subject: [PATCH 08/12] draft - break API compatibility --- boa/__init__.py | 10 +---- boa/explorer.py | 100 ++++++++++++++++++++++------------------------- boa/interpret.py | 20 ++++++++-- 3 files changed, 65 insertions(+), 65 deletions(-) diff --git a/boa/__init__.py b/boa/__init__.py index e5bed866..dedc270a 100644 --- a/boa/__init__.py +++ b/boa/__init__.py @@ -7,7 +7,7 @@ from boa.dealer import deal from boa.debugger import BoaDebug from boa.environment import Env -from boa.explorer import Etherscan +from boa.explorer import Etherscan, set_from_args from boa.interpret import ( from_etherscan, load, @@ -83,13 +83,7 @@ def set_network_env(url): def set_etherscan(*args, **kwargs): - get = lambda: boa.explorer.etherscan # noqa: E731 - - def set_(explorer: Etherscan): - boa.explorer.etherscan = explorer - - explorer = Etherscan(*args, **kwargs) - return Open(get, set_, explorer) + set_from_args(*args, **kwargs) def reset_env(): diff --git a/boa/explorer.py b/boa/explorer.py index 39d14ed2..232428e5 100644 --- a/boa/explorer.py +++ b/boa/explorer.py @@ -1,9 +1,9 @@ -import os import time from dataclasses import dataclass from typing import Optional from boa.rpc import json +from boa.util.open_ctx import Open try: from requests_cache import CachedSession @@ -22,16 +22,22 @@ SESSION = Session() +DEFAULT_ETHERSCAN_URI = "https://api.etherscan.io/api" + @dataclass class Etherscan: - api_key: Optional[str] = os.environ.get("ETHERSCAN_API_KEY") - uri: str = os.environ.get("ETHERSCAN_URI", "https://api.etherscan.io/api") + uri: str = DEFAULT_ETHERSCAN_URI + api_key: Optional[str] = None num_retries: int = 10 backoff_ms: int | float = 400.0 backoff_factor: float = 1.1 # 1.1**10 ~= 2.59 - def fetch(self, **params) -> dict: + def __post_init__(self): + if self.uri is None: + self.uri = DEFAULT_ETHERSCAN_URI + + def _fetch(self, **params) -> dict: """ Fetch data from Etherscan API. Offers a simple caching mechanism to avoid redundant queries. @@ -58,32 +64,45 @@ def fetch(self, **params) -> dict: return data + def fetch_abi(self, address: str): + # resolve implementation address if `address` is a proxy contract + address = self._resolve_implementation_address(address) -etherscan = Etherscan() + # fetch ABI of `address` + params = dict(module="contract", action="getabi", address=address) + data = self._fetch(**params) + return json.loads(data["result"].strip()) + + # fetch the address of a contract; resolves at most one layer of + # indirection if the address is a proxy contract. + def _resolve_implementation_address(self, address: str): + params = dict(module="contract", action="getsourcecode", address=address) + data = self._fetch(**params) + source_data = data["result"][0] + + # check if the contract is a proxy + if int(source_data["Proxy"]) == 1: + return source_data["Implementation"] + else: + return address + + +_etherscan = Etherscan() + + +def get_etherscan(): + return _etherscan -def _fetch_etherscan( - uri: Optional[str], - api_key: Optional[str] = None, - num_retries=10, - backoff_ms=400, - **params, -) -> dict: - """ - Fetch data from Etherscan API. - Offers a simple caching mechanism to avoid redundant queries. - Retries if rate limit is reached. - :param uri: Etherscan API URI - :param api_key: Etherscan API key - :param num_retries: Number of retries - :param backoff_ms: Backoff in milliseconds - :param params: Additional query parameters - :return: JSON response - """ - uri = uri or etherscan.uri - api_key = api_key or etherscan.api_key - explorer = Etherscan(api_key, uri, num_retries, backoff_ms) - return explorer.fetch(**params) + +def _set_etherscan(etherscan: Etherscan): + global _etherscan + _etherscan = etherscan + + +def set_from_args(*args, **kwargs): + explorer = Etherscan(*args, **kwargs) + return Open(get_etherscan, _set_etherscan, explorer) def _is_success_response(data: dict) -> bool: @@ -100,30 +119,3 @@ def _is_rate_limited(data: dict) -> bool: :return: True if rate limited, False otherwise """ return "rate limit" in data.get("result", "") and data.get("status") == "0" - - -def fetch_abi_from_etherscan(address: str, uri: str = None, api_key: str = None): - # resolve implementation address if `address` is a proxy contract - address = _resolve_implementation_address(address, uri, api_key) - - # fetch ABI of `address` - params = dict(module="contract", action="getabi", address=address) - data = _fetch_etherscan(uri, api_key, **params) - - return json.loads(data["result"].strip()) - - -# fetch the address of a contract; resolves at most one layer of indirection -# if the address is a proxy contract. -def _resolve_implementation_address( - address: str, uri: Optional[str], api_key: Optional[str] -): - params = dict(module="contract", action="getsourcecode", address=address) - data = _fetch_etherscan(uri, api_key, **params) - source_data = data["result"][0] - - # check if the contract is a proxy - if int(source_data["Proxy"]) == 1: - return source_data["Implementation"] - else: - return address diff --git a/boa/interpret.py b/boa/interpret.py index 18e62de6..17fd60ac 100644 --- a/boa/interpret.py +++ b/boa/interpret.py @@ -28,7 +28,7 @@ VyperDeployer, ) from boa.environment import Env -from boa.explorer import fetch_abi_from_etherscan +from boa.explorer import Etherscan, get_etherscan from boa.rpc import json from boa.util.abi import Address from boa.util.disk_cache import DiskCache @@ -254,10 +254,24 @@ def _compile(): def from_etherscan( - address: Any, name: str = None, uri: str = None, api_key: str = None + address: Any, + name: str = None, + uri: str = None, + api_key: str = None, + etherscan: Etherscan = None, ): addr = Address(address) - abi = fetch_abi_from_etherscan(addr, uri, api_key) + + if uri is not None or api_key is not None: + if etherscan is not None: + raise ValueError("Cannot set both uri and api_key at the same time") + + warnings.warn("use of uri or api_key is deprecated! use etherscan=... instead!") + etherscan = Etherscan(uri, api_key) + elif etherscan is None: + etherscan = get_etherscan() + + abi = etherscan.fetch_abi(addr) return ABIContractFactory.from_abi_dict(abi, name=name).at(addr) From 029d821120480b87cc4dfc88ae04436bf89fb6c3 Mon Sep 17 00:00:00 2001 From: Charles Cooper Date: Wed, 11 Sep 2024 08:35:51 -0400 Subject: [PATCH 09/12] fix lint --- boa/explorer.py | 2 +- boa/interpret.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/boa/explorer.py b/boa/explorer.py index 232428e5..251050b0 100644 --- a/boa/explorer.py +++ b/boa/explorer.py @@ -27,7 +27,7 @@ @dataclass class Etherscan: - uri: str = DEFAULT_ETHERSCAN_URI + uri: Optional[str] = DEFAULT_ETHERSCAN_URI api_key: Optional[str] = None num_retries: int = 10 backoff_ms: int | float = 400.0 diff --git a/boa/interpret.py b/boa/interpret.py index 17fd60ac..463f4b5b 100644 --- a/boa/interpret.py +++ b/boa/interpret.py @@ -1,5 +1,6 @@ import sys import textwrap +import warnings from importlib.abc import MetaPathFinder from importlib.machinery import SourceFileLoader from importlib.util import spec_from_loader From 3a9c57b826c618d2c83b727fe7335e262c1c2b4c Mon Sep 17 00:00:00 2001 From: Charles Cooper Date: Wed, 11 Sep 2024 09:25:00 -0400 Subject: [PATCH 10/12] remove etherscan= param --- boa/interpret.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/boa/interpret.py b/boa/interpret.py index 463f4b5b..88c55842 100644 --- a/boa/interpret.py +++ b/boa/interpret.py @@ -1,6 +1,5 @@ import sys import textwrap -import warnings from importlib.abc import MetaPathFinder from importlib.machinery import SourceFileLoader from importlib.util import spec_from_loader @@ -255,21 +254,13 @@ def _compile(): def from_etherscan( - address: Any, - name: str = None, - uri: str = None, - api_key: str = None, - etherscan: Etherscan = None, + address: Any, name: str = None, uri: str = None, api_key: str = None ): addr = Address(address) if uri is not None or api_key is not None: - if etherscan is not None: - raise ValueError("Cannot set both uri and api_key at the same time") - - warnings.warn("use of uri or api_key is deprecated! use etherscan=... instead!") etherscan = Etherscan(uri, api_key) - elif etherscan is None: + else: etherscan = get_etherscan() abi = etherscan.fetch_abi(addr) From 7f265ea73f835b1c7bb5fc9fdd946218f4c9ecb8 Mon Sep 17 00:00:00 2001 From: Charles Cooper Date: Wed, 11 Sep 2024 09:30:21 -0400 Subject: [PATCH 11/12] remove set_from_args helper --- boa/__init__.py | 5 +++-- boa/explorer.py | 6 ------ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/boa/__init__.py b/boa/__init__.py index dedc270a..2f1b1d74 100644 --- a/boa/__init__.py +++ b/boa/__init__.py @@ -7,7 +7,7 @@ from boa.dealer import deal from boa.debugger import BoaDebug from boa.environment import Env -from boa.explorer import Etherscan, set_from_args +from boa.explorer import Etherscan, _set_etherscan, get_etherscan from boa.interpret import ( from_etherscan, load, @@ -83,7 +83,8 @@ def set_network_env(url): def set_etherscan(*args, **kwargs): - set_from_args(*args, **kwargs) + explorer = Etherscan(*args, **kwargs) + return Open(get_etherscan, _set_etherscan, explorer) def reset_env(): diff --git a/boa/explorer.py b/boa/explorer.py index 251050b0..c44f67fe 100644 --- a/boa/explorer.py +++ b/boa/explorer.py @@ -3,7 +3,6 @@ from typing import Optional from boa.rpc import json -from boa.util.open_ctx import Open try: from requests_cache import CachedSession @@ -100,11 +99,6 @@ def _set_etherscan(etherscan: Etherscan): _etherscan = etherscan -def set_from_args(*args, **kwargs): - explorer = Etherscan(*args, **kwargs) - return Open(get_etherscan, _set_etherscan, explorer) - - def _is_success_response(data: dict) -> bool: return data.get("status") == "1" From 1e13c6e39f0eebcbbcff98c62f20025c70868bad Mon Sep 17 00:00:00 2001 From: Charles Cooper Date: Wed, 11 Sep 2024 10:06:36 -0400 Subject: [PATCH 12/12] fix test setup --- tests/integration/fork/test_from_etherscan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/fork/test_from_etherscan.py b/tests/integration/fork/test_from_etherscan.py index 2d96fcb1..da07800f 100644 --- a/tests/integration/fork/test_from_etherscan.py +++ b/tests/integration/fork/test_from_etherscan.py @@ -16,7 +16,7 @@ @pytest.fixture(scope="module", autouse=True) def api_key(): - with boa.set_etherscan(os.environ["ETHERSCAN_API_KEY"]): + with boa.set_etherscan(api_key=os.environ["ETHERSCAN_API_KEY"]): yield