diff --git a/pdm.lock b/pdm.lock index 501e2a8..20f629a 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,11 +5,22 @@ groups = ["default", "dev"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:48ae2022d1d11a1f092dbf654eb0d3afb49f1ee13974370fd7b9cc528279a1a6" +content_hash = "sha256:fcd73d41a44f479d4d44815663f04e6dea4aab64ac56414b2247975179be0685" [[metadata.targets]] requires_python = ">=3.12,<3.13" +[[package]] +name = "aiofiles" +version = "24.1.0" +requires_python = ">=3.8" +summary = "File support for asyncio." +groups = ["default"] +files = [ + {file = "aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5"}, + {file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"}, +] + [[package]] name = "alabaster" version = "1.0.0" @@ -35,6 +46,23 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] +[[package]] +name = "anyio" +version = "4.6.2.post1" +requires_python = ">=3.9" +summary = "High level compatibility layer for multiple asynchronous event loop implementations" +groups = ["default"] +dependencies = [ + "exceptiongroup>=1.0.2; python_version < \"3.11\"", + "idna>=2.8", + "sniffio>=1.1", + "typing-extensions>=4.1; python_version < \"3.11\"", +] +files = [ + {file = "anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d"}, + {file = "anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c"}, +] + [[package]] name = "attrs" version = "24.2.0" @@ -136,7 +164,7 @@ name = "charset-normalizer" version = "3.4.0" requires_python = ">=3.7.0" summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -groups = ["default", "dev"] +groups = ["dev"] files = [ {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, @@ -386,6 +414,20 @@ files = [ {file = "future_fstrings-1.2.0.tar.gz", hash = "sha256:6cf41cbe97c398ab5a81168ce0dbb8ad95862d3caf23c21e4430627b90844089"}, ] +[[package]] +name = "h11" +version = "0.14.0" +requires_python = ">=3.7" +summary = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +groups = ["default"] +dependencies = [ + "typing-extensions; python_version < \"3.8\"", +] +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + [[package]] name = "hexbytes" version = "1.2.1" @@ -397,6 +439,69 @@ files = [ {file = "hexbytes-1.2.1.tar.gz", hash = "sha256:515f00dddf31053db4d0d7636dd16061c1d896c3109b8e751005db4ca46bcca7"}, ] +[[package]] +name = "hishel" +version = "0.0.33" +requires_python = ">=3.8" +summary = "Persistent cache implementation for httpx and httpcore" +groups = ["default"] +dependencies = [ + "httpx>=0.22.0", + "typing-extensions>=4.8.0", +] +files = [ + {file = "hishel-0.0.33-py3-none-any.whl", hash = "sha256:6e6c6cdaf432ff4c4981e7792ef7d1fa4c8ede58b9dbbcefb9ab3fc9770f2a07"}, + {file = "hishel-0.0.33.tar.gz", hash = "sha256:ab5b2661d5e2252f305fd0fb20e8c76bfab3ea73458f20f2591c53c37b270089"}, +] + +[[package]] +name = "httpcore" +version = "1.0.6" +requires_python = ">=3.8" +summary = "A minimal low-level HTTP client." +groups = ["default"] +dependencies = [ + "certifi", + "h11<0.15,>=0.13", +] +files = [ + {file = "httpcore-1.0.6-py3-none-any.whl", hash = "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f"}, + {file = "httpcore-1.0.6.tar.gz", hash = "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f"}, +] + +[[package]] +name = "httpx" +version = "0.27.2" +requires_python = ">=3.8" +summary = "The next generation HTTP client." +groups = ["default"] +dependencies = [ + "anyio", + "certifi", + "httpcore==1.*", + "idna", + "sniffio", +] +files = [ + {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, + {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, +] + +[[package]] +name = "httpx-file" +version = "0.2.0" +requires_python = ">=3.6.1" +summary = "File transport adapter for httpx." +groups = ["default"] +dependencies = [ + "aiofiles", + "httpx>=0.20", +] +files = [ + {file = "httpx-file-0.2.0.tar.gz", hash = "sha256:a00f1dd02c9ffb5e7e072205c30f7ae0d867c397318b045a40b3268f2cdfa932"}, + {file = "httpx_file-0.2.0-py3-none-any.whl", hash = "sha256:9a425b351bf65aa394c02096204dc3fa8b647573a289079f927d3e3abfa3c7c8"}, +] + [[package]] name = "identify" version = "2.6.1" @@ -499,6 +604,21 @@ files = [ {file = "lark-1.2.2.tar.gz", hash = "sha256:ca807d0162cd16cef15a8feecb862d7319e7a09bdb13aef927968e45040fed80"}, ] +[[package]] +name = "limiter" +version = "0.5.0" +requires_python = ">=3.10" +summary = "⏲️ Easy rate limiting for Python. Rate limiting async and thread-safe decorators and context managers that use a token bucket algorithm." +groups = ["default"] +dependencies = [ + "strenum<0.5.0,>=0.4.7", + "token-bucket<0.4.0,>=0.3.0", +] +files = [ + {file = "limiter-0.5.0-py2.py3-none-any.whl", hash = "sha256:920ee7587596b6421690ee2009a755a9970b743001567ae1005310d9b654985c"}, + {file = "limiter-0.5.0.tar.gz", hash = "sha256:71b9e972e04f1dcf3fa9b541ca3a16dcc1be6ce0d6a12407b25a0888a57e612f"}, +] + [[package]] name = "linkify-it-py" version = "2.0.3" @@ -1039,7 +1159,7 @@ name = "requests" version = "2.32.3" requires_python = ">=3.8" summary = "Python HTTP for Humans." -groups = ["default", "dev"] +groups = ["dev"] dependencies = [ "certifi>=2017.4.17", "charset-normalizer<4,>=2", @@ -1157,6 +1277,17 @@ files = [ {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, ] +[[package]] +name = "sniffio" +version = "1.3.1" +requires_python = ">=3.7" +summary = "Sniff out which async library your code is running under" +groups = ["default"] +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + [[package]] name = "snowballstemmer" version = "2.2.0" @@ -1377,6 +1508,16 @@ files = [ {file = "sphinxcontrib_typer-0.5.1.tar.gz", hash = "sha256:b455664c5820e5e6748466707fbac63aa864abf2b7006857dff4006772d3bbbf"}, ] +[[package]] +name = "strenum" +version = "0.4.15" +summary = "An Enum that inherits from str." +groups = ["default"] +files = [ + {file = "StrEnum-0.4.15-py3-none-any.whl", hash = "sha256:a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659"}, + {file = "StrEnum-0.4.15.tar.gz", hash = "sha256:878fb5ab705442070e4dd1929bb5e2249511c0bcf2b0eeacf3bcd80875c82eff"}, +] + [[package]] name = "termcolor" version = "2.5.0" @@ -1388,6 +1529,17 @@ files = [ {file = "termcolor-2.5.0.tar.gz", hash = "sha256:998d8d27da6d48442e8e1f016119076b690d962507531df4890fcd2db2ef8a6f"}, ] +[[package]] +name = "token-bucket" +version = "0.3.0" +requires_python = ">=3.5" +summary = "Very fast implementation of the token bucket algorithm." +groups = ["default"] +files = [ + {file = "token_bucket-0.3.0-py2.py3-none-any.whl", hash = "sha256:6df24309e3cf5b808ae5ef714a3191ec5b54f48c34ef959e4882eef140703369"}, + {file = "token_bucket-0.3.0.tar.gz", hash = "sha256:979571c99db2ff9e651f2b2146a62b2ebadf7de6c217a8781698282976cb675f"}, +] + [[package]] name = "toolz" version = "1.0.0" @@ -1476,7 +1628,7 @@ name = "urllib3" version = "2.2.3" requires_python = ">=3.8" summary = "HTTP library with thread-safe connection pooling, file post, and more." -groups = ["default", "dev"] +groups = ["dev"] files = [ {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, @@ -1498,3 +1650,14 @@ files = [ {file = "virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2"}, {file = "virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48"}, ] + +[[package]] +name = "xdg-base-dirs" +version = "6.0.2" +requires_python = "<4.0,>=3.10" +summary = "Variables defined by the XDG Base Directory Specification" +groups = ["default"] +files = [ + {file = "xdg_base_dirs-6.0.2-py3-none-any.whl", hash = "sha256:3c01d1b758ed4ace150ac960ac0bd13ce4542b9e2cdf01312dcda5012cfebabe"}, + {file = "xdg_base_dirs-6.0.2.tar.gz", hash = "sha256:950504e14d27cf3c9cb37744680a43bf0ac42efefc4ef4acf98dc736cab2bced"}, +] diff --git a/pyproject.toml b/pyproject.toml index 792a30c..07904e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,6 @@ classifiers = [ dependencies = [ "pydantic>=2.8.2", "eip712-clearsign>=3.0.1", - "requests>=2.32.3", "typer>=0.12.5", "eth-utils>=5.0.0", "jsonschema", @@ -30,6 +29,11 @@ dependencies = [ "typer", "lark>=1.2.2", "pydantic-string-url>=1.0.2", + "httpx>=0.27.2", + "httpx-file>=0.2.0", + "hishel>=0.0.33", + "xdg-base-dirs>=6.0.2", + "limiter>=0.5.0", ] [project.urls] diff --git a/src/erc7730/common/client.py b/src/erc7730/common/client.py index 7012535..e15e5da 100644 --- a/src/erc7730/common/client.py +++ b/src/erc7730/common/client.py @@ -1,141 +1,173 @@ +import json import os -from dataclasses import dataclass -from io import UnsupportedOperation -from typing import Any - -import requests -from pydantic import RootModel +from abc import ABC +from functools import cache +from typing import Any, TypeVar, final, override + +from hishel import CacheTransport, FileStorage +from httpx import URL, BaseTransport, Client, HTTPTransport, Request, Response +from httpx._content import IteratorByteStream +from httpx_file import FileTransport +from limiter import Limiter +from pydantic import ConfigDict, TypeAdapter from pydantic_string_url import FileUrl, HttpUrl +from xdg_base_dirs import xdg_cache_home -from erc7730.common.pydantic import _BaseModel from erc7730.model.abi import ABI +from erc7730.model.base import Model +from erc7730.model.types import Address + +ETHERSCAN = "api.etherscan.io" + +_T = TypeVar("_T") + + +class EtherscanChain(Model): + """Etherscan supported chain info.""" + + model_config = ConfigDict(strict=False, frozen=True, extra="ignore") + chainname: str + chainid: int + blockexplorer: HttpUrl + + +@cache +def get_supported_chains() -> list[EtherscanChain]: + """ + Get supported chains from Etherscan. + + :return: Etherscan supported chains, with name/chain id/block explorer URL + """ + return get(url=HttpUrl(f"https://{ETHERSCAN}/v2/chainlist"), model=list[EtherscanChain]) -@dataclass -class ScanSite: - host: str - api_key: str - url: str - - -SCAN_SITES = { - 1: ScanSite(host="api.etherscan.io", api_key="ETHERSCAN_API_KEY", url="https://etherscan.io"), - 56: ScanSite(host="api.bscscan.com", api_key="BSCSCAN_API_KEY", url="https://bscscan.com"), - 137: ScanSite(host="api.polygonscan.com", api_key="POLYGONSCAN_API_KEY", url="https://polygonscan.com"), - 1101: ScanSite( - host="api-zkevm.polygonscan.com", - api_key="POLYGONSKEVMSCAN_API_KEY", - url="https://zkevm.polygonscan.com", - ), - 42161: ScanSite(host="api.arbiscan.io", api_key="ARBISCAN_API_KEY", url="https://arbiscan.io"), - 8453: ScanSite(host="api.basescan.io", api_key="BASESCAN_API_KEY", url="https://basescan.io"), - 10: ScanSite( - host="api-optimistic.etherscan.io", - api_key="OPTIMISMSCAN_API_KEY", - url="https://optimistic.etherscan.io", - ), - 25: ScanSite(host="api.cronoscan.com", api_key="CRONOSCAN_API_KEY", url="https://cronoscan.com"), - 250: ScanSite(host="api.ftmscan.com", api_key="FANTOMSCAN_API_KEY", url="https://ftmscan.com"), - 284: ScanSite(host="api-moonbeam.moonscan.io", api_key="MOONSCAN_API_KEY", url="https://moonbeam.moonscan.io"), - 199: ScanSite(host="api.bttcscan.com", api_key="BTTCSCAN_API_KEY", url="https://bttcscan.com"), - 59144: ScanSite(host="api.lineascan.build", api_key="LINEASCAN_API_KEY", url="https://lineascan.build"), - 534352: ScanSite(host="api.scrollscan.com", api_key="SCROLLSCAN_API_KEY", url="https://scrollscan.com"), - 421614: ScanSite( - host="api-sepolia.arbiscan.io", api_key="ARBISCAN_SEPOLIA_API_KEY", url="https://sepolia.arbiscan.io" - ), - 84532: ScanSite( - host="api-sepolia.basescan.org", - api_key="BASESCAN_SEPOLIA_API_KEY", - url="https://sepolia.basescan.org", - ), - 11155111: ScanSite( - host="api-sepolia.etherscan.io", - api_key="ETHERSCAN_SEPOLIA_API_KEY", - url="https://sepolia.etherscan.io", - ), - 11155420: ScanSite( - host="api-sepolia-optimistic.etherscan.io", - api_key="OPTIMISMSCAN_SEPOLIA_API_KEY", - url="https://sepolia.optimistic.etherscan.io", - ), - 534351: ScanSite( - host="api-sepolia.scrollscan.com", - api_key="SCROLLSCAN_SEPOLIA_API_KEY", - url="https://sepolia.scrollscan.com", - ), -} - - -def get_contract_abis(chain_id: int, contract_address: str) -> list[ABI] | None: +def get_contract_abis(chain_id: int, contract_address: Address) -> list[ABI]: """ - Get contract ABIs from an etherscan-like site. + Get contract ABIs from Etherscan. :param chain_id: EIP-155 chain ID :param contract_address: EVM contract address :return: deserialized list of ABIs - :raises ValueError: if chain id not supported, API key not setup, or unexpected response + :raises NotImplementedError: if chain id not supported, API key not setup, or unexpected response """ - if (site := SCAN_SITES.get(chain_id)) is None: - raise UnsupportedOperation( - f"Chain ID {chain_id} is not supported, please report this to authors of " f"python-erc7730 library" - ) return get( - url=HttpUrl(f"https://{site.host}/api?module=contract&action=getabi&address={contract_address}"), - model=RootModel[list[ABI]], - ).root + url=HttpUrl(f"https://{ETHERSCAN}/v2/api"), + chainid=chain_id, + module="contract", + action="getabi", + address=contract_address, + model=list[ABI], + ) + +def get_contract_explorer_url(chain_id: int, contract_address: Address) -> HttpUrl: + """ + Get contract explorer site URL (for opening in a browser). -def get_contract_url(chain_id: int, contract_address: str) -> str: - if (site := SCAN_SITES.get(chain_id)) is None: - raise UnsupportedOperation( - f"Chain ID {chain_id} is not supported, please report this to authors of " f"python-erc7730 library" - ) - return f"{site.url}/address/{contract_address}#code" + :param chain_id: EIP-155 chain ID + :param contract_address: EVM contract address + :return: URL to the contract explorer site + :raises NotImplementedError: if chain id not supported + """ + for chain in get_supported_chains(): + if chain.chainid == chain_id: + return HttpUrl(f"{chain.blockexplorer}/address/{contract_address}#code") + raise NotImplementedError( + f"Chain ID {chain_id} is not supported, please report this to authors of python-erc7730 library" + ) -def get(url: FileUrl | HttpUrl, model: type[_BaseModel]) -> _BaseModel: +def get(model: type[_T], url: HttpUrl | FileUrl, **params: Any) -> _T: """ Fetch data from a file or an HTTP URL and deserialize it. This method implements some automated adaptations to handle user provided URLs: - - adaptation to "raw.githubusercontent.com" for GitHub URLs - - injection of API key parameters for etherscan-like sites - - unwrapping of "result" field for etherscan-like sites + - GitHub: adaptation to "raw.githubusercontent.com" + - Etherscan: rate limiting, API key parameter injection, "result" field unwrapping :param url: URL to get data from :param model: Pydantic model to deserialize the data :return: deserialized response - :raises ValueError: if URL type is not supported, API key not setup, or unexpected response + :raises Exception: if URL type is not supported, API key not setup, or unexpected response + """ + with _client() as client: + return TypeAdapter(model).validate_json(client.get(url, params=params).raise_for_status().content) + + +def _client() -> Client: + """ + Create a new HTTP client with GitHub and Etherscan specific transports. + :return: """ - # TODO add disk cache support - if isinstance(url, HttpUrl): - response = requests.get(_adapt_http_url(url), timeout=10) - response.raise_for_status() - data = _adapt_http_response(url, response.json()) - if isinstance(data, str): - return model.model_validate_json(data) - return model.model_validate(data) - if isinstance(url, FileUrl): - # TODO add support for file:// URLs - raise NotImplementedError("file:// URL support is not implemented") - raise ValueError(f"Unsupported URL type: {type(url)}") - - -def _adapt_http_url(url: HttpUrl) -> HttpUrl: - if url.startswith("https://github.com"): - return HttpUrl(url.replace("https://github.com/", "https://raw.githubusercontent.com/").replace("/blob/", "/")) - - for scan_site in SCAN_SITES.values(): - if url.startswith(f"https://{scan_site.host}"): - if (api_key := os.environ.get(scan_site.api_key)) is None: - raise ValueError(f"{scan_site.api_key} environment variable is required") - return HttpUrl(f"{url}&apikey={api_key}") - - return url - - -def _adapt_http_response(url: HttpUrl, response: Any) -> Any: - for scan_site in SCAN_SITES.values(): - if url.startswith(f"https://{scan_site.host}") and (result := response.get("result")) is not None: - return result - return response + cache_storage = FileStorage(base_path=xdg_cache_home() / "erc7730", ttl=7 * 24 * 3600, check_ttl_every=24 * 3600) + http_transport = HTTPTransport() + http_transport = GithubTransport(http_transport) + http_transport = EtherscanTransport(http_transport) + http_transport = CacheTransport(transport=http_transport, storage=cache_storage) + file_transport = FileTransport() + # TODO file storage: authorize relative paths only + transports = {"https://": http_transport, "file://": file_transport} + return Client(mounts=transports, timeout=10) + + +class DelegateTransport(ABC, BaseTransport): + """Base class for wrapping httpx transport.""" + + def __init__(self, delegate: BaseTransport) -> None: + self._delegate = delegate + + def handle_request(self, request: Request) -> Response: + return self._delegate.handle_request(request) + + def close(self) -> None: + self._delegate.close() + + +@final +class GithubTransport(DelegateTransport): + """GitHub specific transport for handling raw content requests.""" + + GITHUB, GITHUB_RAW = "github.com", "raw.githubusercontent.com" + + def __init__(self, delegate: BaseTransport) -> None: + super().__init__(delegate) + + @override + def handle_request(self, request: Request) -> Response: + if request.url.host != self.GITHUB: + return super().handle_request(request) + + # adapt URL + request.url = URL(str(request.url).replace(self.GITHUB, self.GITHUB_RAW).replace("/blob/", "/")) + request.headers.update({"Host": self.GITHUB_RAW}) + return super().handle_request(request) + + +@final +class EtherscanTransport(DelegateTransport): + """Etherscan specific transport for handling rate limiting, API key parameter injection, response unwrapping.""" + + ETHERSCAN_API_KEY = "ETHERSCAN_API_KEY" + + @Limiter(rate=5, capacity=5, consume=1) + @override + def handle_request(self, request: Request) -> Response: + if request.url.host != ETHERSCAN: + return super().handle_request(request) + + # add API key + if (api_key := os.environ.get(self.ETHERSCAN_API_KEY)) is None: + raise ValueError(f"{self.ETHERSCAN_API_KEY} environment variable is required") + request.url = request.url.copy_add_param("apikey", api_key) + + # read response + response = super().handle_request(request) + response.read() + response.close() + + # unwrap result, sometimes containing JSON directly, sometimes JSON in a string + if (result := response.json().get("result")) is not None: + data = result if isinstance(result, str) else json.dumps(result) + return Response(status_code=response.status_code, stream=IteratorByteStream([data.encode()])) + + raise Exception(f"Unexpected response from Etherscan: {response.content}") diff --git a/src/erc7730/convert/resolved/convert_erc7730_input_to_resolved.py b/src/erc7730/convert/resolved/convert_erc7730_input_to_resolved.py index f1af859..9cd325f 100644 --- a/src/erc7730/convert/resolved/convert_erc7730_input_to_resolved.py +++ b/src/erc7730/convert/resolved/convert_erc7730_input_to_resolved.py @@ -1,6 +1,5 @@ from typing import assert_never, final, override -from pydantic import RootModel from pydantic_string_url import HttpUrl from erc7730.common import client @@ -104,8 +103,14 @@ def _resolve_metadata(cls, metadata: InputMetadata, out: OutputAdder) -> Resolve @classmethod def _resolve_enum(cls, enum: HttpUrl | EnumDefinition, out: OutputAdder) -> dict[str, str] | None: match enum: - case HttpUrl(): - return client.get(enum, RootModel[EnumDefinition]).root + case HttpUrl() as url: + try: + return client.get(url=url, model=EnumDefinition) + except Exception as e: + return out.error( + title="Failed to fetch enum definition from URL", + message=f'Failed to fetch enum definition from URL "{url}": {e}', + ) case dict(): return enum case _: @@ -132,8 +137,14 @@ def _resolve_contract(cls, contract: InputContract, out: OutputAdder) -> Resolve @classmethod def _resolve_abis(cls, abis: list[ABI] | HttpUrl, out: OutputAdder) -> list[ABI] | None: match abis: - case HttpUrl(): - return client.get(abis, RootModel[list[ABI]]).root + case HttpUrl() as url: + try: + return client.get(url=url, model=list[ABI]) + except Exception as e: + return out.error( + title="Failed to fetch ABI from URL", + message=f'Failed to fetch ABI from URL "{url}": {e}', + ) case list(): return abis case _: @@ -173,8 +184,14 @@ def _resolve_schemas( @classmethod def _resolve_schema(cls, schema: EIP712JsonSchema | HttpUrl, out: OutputAdder) -> EIP712JsonSchema | None: match schema: - case HttpUrl(): - return client.get(schema, EIP712JsonSchema) + case HttpUrl() as url: + try: + return client.get(url=url, model=EIP712JsonSchema) + except Exception as e: + return out.error( + title="Failed to fetch EIP-712 schema from URL", + message=f'Failed to fetch EIP-712 schema from URL "{url}": {e}', + ) case EIP712JsonSchema(): return schema case _: diff --git a/tests/common/test_client.py b/tests/common/test_client.py index 2cd2084..f57d916 100644 --- a/tests/common/test_client.py +++ b/tests/common/test_client.py @@ -1,10 +1,90 @@ -import pytest +from pydantic_string_url import HttpUrl from erc7730.common import client +from erc7730.model.abi import ABI + + +def test_get_supported_chains() -> None: + result = client.get_supported_chains() + assert result is not None + assert len(result) >= 50 + names = {chain.chainname for chain in result} + assert "Ethereum Mainnet" in names + assert "Sepolia Testnet" in names + assert "Holesky Testnet" in names + assert "BNB Smart Chain Mainnet" in names + assert "BNB Smart Chain Testnet" in names + assert "Polygon Mainnet" in names + assert "Polygon Amoy Testnet" in names + assert "Polygon zkEVM Mainnet" in names + assert "Polygon zkEVM Cardona Testnet" in names + assert "Base Mainnet" in names + assert "Base Sepolia Testnet" in names + assert "Arbitrum One Mainnet" in names + assert "Arbitrum Nova Mainnet" in names + assert "Arbitrum Sepolia Testnet" in names + assert "Linea Mainnet" in names + assert "Linea Sepolia Testnet" in names + assert "Fantom Opera Mainnet" in names + assert "Fantom Testnet" in names + assert "Blast Mainnet" in names + assert "Blast Sepolia Testnet" in names + assert "OP Mainnet" in names + assert "OP Sepolia Testnet" in names + assert "Avalanche C-Chain" in names + assert "Avalanche Fuji Testnet" in names + assert "BitTorrent Chain Mainnet" in names + assert "BitTorrent Chain Testnet" in names + assert "Celo Mainnet" in names + assert "Celo Alfajores Testnet" in names + assert "Cronos Mainnet" in names + assert "Fraxtal Mainnet" in names + assert "Fraxtal Testnet" in names + assert "Gnosis" in names + assert "Kroma Mainnet" in names + assert "Kroma Sepolia Testnet" in names + assert "Mantle Mainnet" in names + assert "Mantle Sepolia Testnet" in names + assert "Moonbeam Mainnet" in names + assert "Moonriver Mainnet" in names + assert "Moonbase Alpha Testnet" in names + assert "opBNB Mainnet" in names + assert "opBNB Testnet" in names + assert "Scroll Mainnet" in names + assert "Scroll Sepolia Testnet" in names + assert "Taiko Mainnet" in names + assert "Taiko Hekla L2 Testnet" in names + assert "WEMIX3.0 Mainnet" in names + assert "WEMIX3.0 Testnet" in names + assert "zkSync Mainnet" in names + assert "zkSync Sepolia Testnet" in names + assert "Xai Mainnet" in names + assert "Xai Sepolia Testnet" in names -@pytest.mark.skip(reason="Secret management not implemented") def test_get_contract_abis() -> None: result = client.get_contract_abis(chain_id=1, contract_address="0x06012c8cf97bead5deae237070f9587f8e7a266d") assert result is not None assert len(result) > 0 + + +def test_get_from_github() -> None: + result1 = client.get( + url=HttpUrl( + "https://github.com/LedgerHQ/ledger-asset-dapps/blob/main" + "/ethereum/uniswap/abis/0x000000000022d473030f116ddee9f6b43ac78ba3.abi.json" + ), + model=list[ABI], + ) + result2 = client.get( + url=HttpUrl( + "https://raw.githubusercontent.com/LedgerHQ/ledger-asset-dapps/refs/heads/main" + "/ethereum/uniswap/abis/0x000000000022d473030f116ddee9f6b43ac78ba3.abi.json" + ), + model=list[ABI], + ) + assert result1 is not None + assert result2 is not None + assert len(result1) > 0 + assert len(result2) > 0 + assert result1 == result2