From bfc6e7b940a26f7c19c6ecf56b4acc534429c7b7 Mon Sep 17 00:00:00 2001 From: Isabella Smallcombe Date: Tue, 15 Aug 2023 16:49:54 -0400 Subject: [PATCH 1/3] feat: add async json rpc + test --- offchain/web3/jsonrpc.py | 73 ++++++++++++++ poetry.lock | 113 +++++++++++++++++++++- pyproject.toml | 2 + tests/metadata/web3/test_async_jsonrpc.py | 17 ++++ 4 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 tests/metadata/web3/test_async_jsonrpc.py diff --git a/offchain/web3/jsonrpc.py b/offchain/web3/jsonrpc.py index 004ac4f..6977e4a 100644 --- a/offchain/web3/jsonrpc.py +++ b/offchain/web3/jsonrpc.py @@ -2,6 +2,8 @@ import requests import requests.adapters +import asyncio +import aiohttp from offchain.concurrency import parmap from offchain.constants.providers import RPCProvider @@ -94,3 +96,74 @@ def call_batch_chunked( results = parmap(lambda chunk: self.call_batch(method, chunk), chunks) return [i for res in results for i in res] + + +class AsyncEthereumJSONRPC: + def __init__( + self, + provider_url: Optional[str] = None, + ) -> None: + self.sess = aiohttp.ClientSession() + self.url = provider_url or RPCProvider.LLAMA_NODES_MAINNET + + def __payload_factory(self, method: str, params: list[Any], id: int) -> RPCPayload: + return {"method": method, "params": params, "id": id, "jsonrpc": "2.0"} + + @retry( + stop=stop_after_attempt(2), + wait=wait_exponential(multiplier=1, min=1, max=5), + ) + async def call(self, method: str, params: list[dict]) -> dict: + try: + payload = self.__payload_factory(method, params, 1) + async with self.sess.post(self.url, json=payload) as resp: + resp.raise_for_status() + return await resp.json() + except Exception as e: + logger.error( + f"Caught exception while making rpc call. Method: {method}. Params: {params}. Retrying. Error: {e}" + ) + raise + + @retry( + stop=stop_after_attempt(2), + wait=wait_exponential(multiplier=1, min=1, max=5), + ) + async def call_batch(self, method: str, params: list[list[Any]]) -> list[dict]: + try: + payload = [self.__payload_factory(method, param, i) for i, param in enumerate(params)] + async with self.sess.post(self.url, json=payload) as resp: + resp.raise_for_status() + return await resp.json() + except Exception as e: + logger.error( + f"Caught exception while making batch rpc call. " + f"Method: {method}. Params: {params}. Retrying. Error: {e}" + # noqa + ) + raise + + @retry( + stop=stop_after_attempt(2), + wait=wait_exponential(multiplier=1, min=1, max=5), + ) + async def call_batch_chunked( + self, + method: str, + params: list[list[Any]], + chunk_size: Optional[int] = MAX_REQUEST_BATCH_SIZE, + ) -> list[dict]: + size = len(params) + if size < chunk_size: + return await self.call_batch(method, params) + + prev_offset, curr_offset = 0, chunk_size + + chunks = [] + while prev_offset < size: + chunks.append(params[prev_offset:curr_offset]) + prev_offset = curr_offset + curr_offset = min(curr_offset + chunk_size, size) + + results = await asyncio.gather(*[self.call_batch(method, chunk) for chunk in chunks]) + return [i for res in results for i in res] diff --git a/poetry.lock b/poetry.lock index 134e9cf..4236569 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,9 +1,10 @@ -# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +# This file is automatically @generated by Poetry and should not be changed by hand. [[package]] name = "aiohttp" version = "3.8.5" description = "Async http client/server framework (asyncio)" +category = "main" optional = false python-versions = ">=3.6" files = [ @@ -112,6 +113,7 @@ speedups = ["Brotli", "aiodns", "cchardet"] name = "aiosignal" version = "1.3.1" description = "aiosignal: a list of registered asynchronous callbacks" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -126,6 +128,7 @@ frozenlist = ">=1.1.0" name = "async-timeout" version = "4.0.2" description = "Timeout context manager for asyncio programs" +category = "main" optional = false python-versions = ">=3.6" files = [ @@ -137,6 +140,7 @@ files = [ name = "attrs" version = "23.1.0" description = "Classes Without Boilerplate" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -155,6 +159,7 @@ tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pyte name = "base58" version = "2.1.1" description = "Base58 and Base58Check implementation." +category = "main" optional = false python-versions = ">=3.5" files = [ @@ -169,6 +174,7 @@ tests = ["PyHamcrest (>=2.0.2)", "mypy", "pytest (>=4.6)", "pytest-benchmark", " name = "bitarray" version = "2.8.1" description = "efficient arrays of booleans -- C extension" +category = "main" optional = false python-versions = "*" files = [ @@ -280,6 +286,7 @@ files = [ name = "black" version = "22.12.0" description = "The uncompromising code formatter." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -315,6 +322,7 @@ uvloop = ["uvloop (>=0.15.2)"] name = "certifi" version = "2023.7.22" description = "Python package for providing Mozilla's CA Bundle." +category = "main" optional = false python-versions = ">=3.6" files = [ @@ -326,6 +334,7 @@ files = [ name = "cfgv" version = "3.3.1" description = "Validate configuration and produce human readable error messages." +category = "dev" optional = false python-versions = ">=3.6.1" files = [ @@ -337,6 +346,7 @@ files = [ name = "charset-normalizer" version = "3.2.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" optional = false python-versions = ">=3.7.0" files = [ @@ -421,6 +431,7 @@ files = [ name = "click" version = "8.1.6" description = "Composable command line interface toolkit" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -435,6 +446,7 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." +category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -446,6 +458,7 @@ files = [ name = "cytoolz" version = "0.12.2" description = "Cython implementation of Toolz: High performance functional utilities" +category = "main" optional = false python-versions = ">=3.6" files = [ @@ -554,6 +567,7 @@ cython = ["cython"] name = "distlib" version = "0.3.7" description = "Distribution utilities" +category = "dev" optional = false python-versions = "*" files = [ @@ -565,6 +579,7 @@ files = [ name = "eth-abi" version = "2.2.0" description = "eth_abi: Python utilities for working with Ethereum ABI definitions, especially encoding and decoding" +category = "main" optional = false python-versions = ">=3.6, <4" files = [ @@ -588,6 +603,7 @@ tools = ["hypothesis (>=4.18.2,<5.0.0)"] name = "eth-account" version = "0.5.9" description = "eth-account: Sign Ethereum transactions and messages with local private keys" +category = "main" optional = false python-versions = ">=3.6, <4" files = [ @@ -615,6 +631,7 @@ test = ["hypothesis (>=4.18.0,<5)", "pytest (>=6.2.5,<7)", "pytest-xdist", "tox name = "eth-hash" version = "0.3.3" description = "eth-hash: The Ethereum hashing function, keccak256, sometimes (erroneously) called sha3" +category = "main" optional = false python-versions = ">=3.5, <4" files = [ @@ -637,6 +654,7 @@ test = ["pytest (==5.4.1)", "pytest-xdist", "tox (==3.14.6)"] name = "eth-keyfile" version = "0.5.1" description = "A library for handling the encrypted keyfiles used to store ethereum private keys." +category = "main" optional = false python-versions = "*" files = [ @@ -654,6 +672,7 @@ pycryptodome = ">=3.4.7,<4.0.0" name = "eth-keys" version = "0.3.4" description = "Common API for Ethereum key operations." +category = "main" optional = false python-versions = "*" files = [ @@ -676,6 +695,7 @@ test = ["asn1tools (>=0.146.2,<0.147)", "eth-hash[pycryptodome]", "eth-hash[pysh name = "eth-rlp" version = "0.2.1" description = "eth-rlp: RLP definitions for common Ethereum objects in Python" +category = "main" optional = false python-versions = ">=3.6, <4" files = [ @@ -698,6 +718,7 @@ test = ["eth-hash[pycryptodome]", "pytest (==5.4.1)", "pytest-xdist", "tox (==3. name = "eth-typing" version = "2.3.0" description = "eth-typing: Common type annotations for ethereum python packages" +category = "main" optional = false python-versions = ">=3.5, <4" files = [ @@ -715,6 +736,7 @@ test = ["pytest (>=4.4,<4.5)", "pytest-xdist", "tox (>=2.9.1,<3)"] name = "eth-utils" version = "1.10.0" description = "eth-utils: Common utility functions for python code that interacts with Ethereum" +category = "main" optional = false python-versions = ">=3.5,!=3.5.2,<4" files = [ @@ -738,6 +760,7 @@ test = ["hypothesis (>=4.43.0,<5.0.0)", "pytest (==5.4.1)", "pytest-xdist", "tox name = "exceptiongroup" version = "1.1.2" description = "Backport of PEP 654 (exception groups)" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -752,6 +775,7 @@ test = ["pytest (>=6)"] name = "filelock" version = "3.12.2" description = "A platform independent file lock." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -767,6 +791,7 @@ testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "p name = "flake8" version = "5.0.4" description = "the modular source code checker: pep8 pyflakes and co" +category = "dev" optional = false python-versions = ">=3.6.1" files = [ @@ -783,6 +808,7 @@ pyflakes = ">=2.5.0,<2.6.0" name = "frozenlist" version = "1.4.0" description = "A list-like structure which implements collections.abc.MutableSequence" +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -853,6 +879,7 @@ files = [ name = "ghp-import" version = "2.1.0" description = "Copy your docs directly to the gh-pages branch." +category = "dev" optional = false python-versions = "*" files = [ @@ -870,6 +897,7 @@ dev = ["flake8", "markdown", "twine", "wheel"] name = "griffe" version = "0.32.3" description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -884,6 +912,7 @@ colorama = ">=0.4" name = "hexbytes" version = "0.3.1" description = "hexbytes: Python `bytes` subclass that decodes hex, with a readable console output" +category = "main" optional = false python-versions = ">=3.7, <4" files = [ @@ -901,6 +930,7 @@ test = ["eth-utils (>=1.0.1,<3)", "hypothesis (>=3.44.24,<=6.31.6)", "pytest (>= name = "identify" version = "2.5.26" description = "File identification library for Python" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -915,6 +945,7 @@ license = ["ukkonen"] name = "idna" version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" optional = false python-versions = ">=3.5" files = [ @@ -926,6 +957,7 @@ files = [ name = "importlib-metadata" version = "6.8.0" description = "Read metadata from Python packages" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -945,6 +977,7 @@ testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -956,6 +989,7 @@ files = [ name = "ipfshttpclient" version = "0.8.0a2" description = "Python IPFS HTTP CLIENT library" +category = "main" optional = false python-versions = ">=3.6.2,!=3.7.0,!=3.7.1" files = [ @@ -971,6 +1005,7 @@ requests = ">=2.11" name = "jinja2" version = "3.1.2" description = "A very fast and expressive template engine." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -988,6 +1023,7 @@ i18n = ["Babel (>=2.7)"] name = "jsonschema" version = "4.19.0" description = "An implementation of JSON Schema validation for Python" +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1009,6 +1045,7 @@ format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339- name = "jsonschema-specifications" version = "2023.7.1" description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1023,6 +1060,7 @@ referencing = ">=0.28.0" name = "lru-dict" version = "1.2.0" description = "An Dict like LRU container." +category = "main" optional = false python-versions = "*" files = [ @@ -1117,6 +1155,7 @@ test = ["pytest"] name = "markdown" version = "3.4.4" description = "Python implementation of John Gruber's Markdown." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1135,6 +1174,7 @@ testing = ["coverage", "pyyaml"] name = "markupsafe" version = "2.1.3" description = "Safely add untrusted strings to HTML/XML markup." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1194,6 +1234,7 @@ files = [ name = "mccabe" version = "0.7.0" description = "McCabe checker, plugin for flake8" +category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1205,6 +1246,7 @@ files = [ name = "mergedeep" version = "1.3.4" description = "A deep merge function for 🐍." +category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1216,6 +1258,7 @@ files = [ name = "mkdocs" version = "1.5.2" description = "Project documentation with Markdown." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1247,6 +1290,7 @@ min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-imp name = "mkdocs-autorefs" version = "0.5.0" description = "Automatically link across pages in MkDocs." +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1262,6 +1306,7 @@ mkdocs = ">=1.1" name = "mkdocs-material" version = "8.5.11" description = "Documentation that simply works" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1282,6 +1327,7 @@ requests = ">=2.26" name = "mkdocs-material-extensions" version = "1.1.1" description = "Extension pack for Python Markdown and MkDocs Material." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1293,6 +1339,7 @@ files = [ name = "mkdocstrings" version = "0.19.1" description = "Automatic documentation from sources, for MkDocs." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1317,6 +1364,7 @@ python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] name = "mkdocstrings-python" version = "0.7.1" description = "A Python handler for mkdocstrings." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1332,6 +1380,7 @@ mkdocstrings = ">=0.19" name = "multiaddr" version = "0.0.9" description = "Python implementation of jbenet's multiaddr" +category = "main" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" files = [ @@ -1349,6 +1398,7 @@ varint = "*" name = "multidict" version = "6.0.4" description = "multidict implementation" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1432,6 +1482,7 @@ files = [ name = "mypy-extensions" version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." +category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -1443,6 +1494,7 @@ files = [ name = "netaddr" version = "0.8.0" description = "A network address manipulation library for Python" +category = "main" optional = false python-versions = "*" files = [ @@ -1454,6 +1506,7 @@ files = [ name = "nodeenv" version = "1.8.0" description = "Node.js virtual environment builder" +category = "dev" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" files = [ @@ -1468,6 +1521,7 @@ setuptools = "*" name = "packaging" version = "23.1" description = "Core utilities for Python packages" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1479,6 +1533,7 @@ files = [ name = "parsimonious" version = "0.8.1" description = "(Soon to be) the fastest pure-Python PEG parser I could muster" +category = "main" optional = false python-versions = "*" files = [ @@ -1492,6 +1547,7 @@ six = ">=1.9.0" name = "pathspec" version = "0.11.2" description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1503,6 +1559,7 @@ files = [ name = "platformdirs" version = "3.10.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1518,6 +1575,7 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-co name = "pluggy" version = "1.2.0" description = "plugin and hook calling mechanisms for python" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1533,6 +1591,7 @@ testing = ["pytest", "pytest-benchmark"] name = "pre-commit" version = "2.21.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1551,6 +1610,7 @@ virtualenv = ">=20.10.0" name = "protobuf" version = "3.19.5" description = "Protocol Buffers" +category = "main" optional = false python-versions = ">=3.5" files = [ @@ -1585,6 +1645,7 @@ files = [ name = "pycodestyle" version = "2.9.1" description = "Python style guide checker" +category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1596,6 +1657,7 @@ files = [ name = "pycryptodome" version = "3.18.0" description = "Cryptographic library for Python" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -1637,6 +1699,7 @@ files = [ name = "pydantic" version = "1.10.12" description = "Data validation and settings management using python type hints" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1689,6 +1752,7 @@ email = ["email-validator (>=1.0.3)"] name = "pyflakes" version = "2.5.0" description = "passive checker of Python programs" +category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1700,6 +1764,7 @@ files = [ name = "pygments" version = "2.16.1" description = "Pygments is a syntax highlighting package written in Python." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1714,6 +1779,7 @@ plugins = ["importlib-metadata"] name = "pymdown-extensions" version = "10.1" description = "Extension pack for Python Markdown." +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1729,6 +1795,7 @@ pyyaml = "*" name = "pytest" version = "7.4.0" description = "pytest: simple powerful testing with Python" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1747,10 +1814,30 @@ tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-asyncio" +version = "0.21.1" +description = "Pytest support for asyncio" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-asyncio-0.21.1.tar.gz", hash = "sha256:40a7eae6dded22c7b604986855ea48400ab15b069ae38116e8c01238e9eeb64d"}, + {file = "pytest_asyncio-0.21.1-py3-none-any.whl", hash = "sha256:8666c1c8ac02631d7c51ba282e0c69a8a452b211ffedf2599099845da5c5c37b"}, +] + +[package.dependencies] +pytest = ">=7.0.0" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] + [[package]] name = "python-dateutil" version = "2.8.2" description = "Extensions to the standard Python datetime module" +category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ @@ -1765,6 +1852,7 @@ six = ">=1.5" name = "python-json-logger" version = "2.0.7" description = "A python library adding a json log formatter" +category = "main" optional = false python-versions = ">=3.6" files = [ @@ -1776,6 +1864,7 @@ files = [ name = "pywin32" version = "306" description = "Python for Window Extensions" +category = "main" optional = false python-versions = "*" files = [ @@ -1799,6 +1888,7 @@ files = [ name = "pyyaml" version = "6.0.1" description = "YAML parser and emitter for Python" +category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1848,6 +1938,7 @@ files = [ name = "pyyaml-env-tag" version = "0.1" description = "A custom YAML tag for referencing environment variables in YAML files. " +category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1862,6 +1953,7 @@ pyyaml = "*" name = "referencing" version = "0.30.2" description = "JSON Referencing + Python" +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1877,6 +1969,7 @@ rpds-py = ">=0.7.0" name = "requests" version = "2.31.0" description = "Python HTTP for Humans." +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1898,6 +1991,7 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] name = "rlp" version = "2.0.1" description = "A package for Recursive Length Prefix encoding and decoding" +category = "main" optional = false python-versions = "*" files = [ @@ -1919,6 +2013,7 @@ test = ["hypothesis (==5.19.0)", "pytest (==5.4.3)", "tox (>=2.9.1,<3)"] name = "rpds-py" version = "0.9.2" description = "Python bindings to Rust's persistent data structures (rpds)" +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -2025,6 +2120,7 @@ files = [ name = "setuptools" version = "68.0.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2041,6 +2137,7 @@ testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs ( name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -2052,6 +2149,7 @@ files = [ name = "tenacity" version = "8.2.2" description = "Retry code until it succeeds" +category = "main" optional = false python-versions = ">=3.6" files = [ @@ -2066,6 +2164,7 @@ doc = ["reno", "sphinx", "tornado (>=4.5)"] name = "tomli" version = "2.0.1" description = "A lil' TOML parser" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2077,6 +2176,7 @@ files = [ name = "toolz" version = "0.12.0" description = "List processing tools and functional utilities" +category = "main" optional = false python-versions = ">=3.5" files = [ @@ -2088,6 +2188,7 @@ files = [ name = "typing-extensions" version = "4.7.1" description = "Backported and Experimental Type Hints for Python 3.7+" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2099,6 +2200,7 @@ files = [ name = "urllib3" version = "1.26.16" description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" files = [ @@ -2115,6 +2217,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] name = "varint" version = "1.0.2" description = "Simple python varint implementation" +category = "main" optional = false python-versions = "*" files = [ @@ -2125,6 +2228,7 @@ files = [ name = "virtualenv" version = "20.24.2" description = "Virtual Python Environment builder" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2145,6 +2249,7 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess name = "watchdog" version = "3.0.0" description = "Filesystem events monitoring" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2184,6 +2289,7 @@ watchmedo = ["PyYAML (>=3.10)"] name = "web3" version = "5.31.4" description = "Web3.py" +category = "main" optional = false python-versions = ">=3.6,<4" files = [ @@ -2218,6 +2324,7 @@ tester = ["eth-tester[py-evm] (==v0.6.0-b.7)", "py-geth (>=3.9.1,<4)"] name = "websockets" version = "9.1" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +category = "main" optional = false python-versions = ">=3.6.1" files = [ @@ -2260,6 +2367,7 @@ files = [ name = "yarl" version = "1.9.2" description = "Yet another URL library" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2347,6 +2455,7 @@ multidict = ">=4.0" name = "zipp" version = "3.16.2" description = "Backport of pathlib-compatible object wrapper for zip files" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -2361,4 +2470,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = ">=3.9 <4" -content-hash = "1444bc2e5b8c919a2047a80c314ed42342be44cb8c6f0ce0200426759a0e2bc5" +content-hash = "de2242f7a0167678f3ad66c5e29b4851d7cac6a167fabd3765e03935408f5de5" diff --git a/pyproject.toml b/pyproject.toml index 92bff18..07141e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,8 @@ python-json-logger = "^2.0.4" urllib3 = "^1.26.11" web3 = "^5.30.0" tenacity = "^8.0.1" +aiohttp = "^3.8.5" +pytest-asyncio = "^0.21.1" [tool.poetry.dev-dependencies] pre-commit = "^2.20.0" diff --git a/tests/metadata/web3/test_async_jsonrpc.py b/tests/metadata/web3/test_async_jsonrpc.py new file mode 100644 index 0000000..843ba0d --- /dev/null +++ b/tests/metadata/web3/test_async_jsonrpc.py @@ -0,0 +1,17 @@ +import pytest + +from unittest.mock import AsyncMock +from offchain.web3.jsonrpc import AsyncEthereumJSONRPC + + +@pytest.mark.asyncio +async def test_chunking_batch_calls(): + rpc = AsyncEthereumJSONRPC() + rpc.call_batch = AsyncMock() + params = [i for i in range(5)] + await rpc.call_batch_chunked("test", params, chunk_size=1) + assert rpc.call_batch.call_count == 5 + assert ( + str(rpc.call_batch.call_args_list) + == "[call('test', [0]),\n call('test', [1]),\n call('test', [2]),\n call('test', [3]),\n call('test', [4])]" + ) From 15c5a8e9a3bf0c3cb8cfac7a999925c4a2a16d2d Mon Sep 17 00:00:00 2001 From: Isabella Smallcombe Date: Tue, 15 Aug 2023 16:57:49 -0400 Subject: [PATCH 2/3] fix: whitespace --- offchain/web3/jsonrpc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/offchain/web3/jsonrpc.py b/offchain/web3/jsonrpc.py index 6977e4a..5374ad8 100644 --- a/offchain/web3/jsonrpc.py +++ b/offchain/web3/jsonrpc.py @@ -105,7 +105,7 @@ def __init__( ) -> None: self.sess = aiohttp.ClientSession() self.url = provider_url or RPCProvider.LLAMA_NODES_MAINNET - + def __payload_factory(self, method: str, params: list[Any], id: int) -> RPCPayload: return {"method": method, "params": params, "id": id, "jsonrpc": "2.0"} From cc1b5e2edb2a6468a94d2b4328d8d51c5e5d5c0a Mon Sep 17 00:00:00 2001 From: Isabella Smallcombe Date: Tue, 15 Aug 2023 18:19:56 -0400 Subject: [PATCH 3/3] feat: add async contract caller + test --- offchain/web3/contract_caller.py | 179 ++++++++++++++++++++- tests/metadata/web3/test_async_batching.py | 28 ++++ 2 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 tests/metadata/web3/test_async_batching.py diff --git a/offchain/web3/contract_caller.py b/offchain/web3/contract_caller.py index 39cd573..dad0238 100644 --- a/offchain/web3/contract_caller.py +++ b/offchain/web3/contract_caller.py @@ -1,3 +1,5 @@ +import asyncio +from logging import Logger from typing import Optional, Any from eth_abi import encode_abi, decode_abi @@ -5,7 +7,7 @@ from offchain.concurrency import parmap from offchain.web3.contract_utils import function_signature_to_sighash -from offchain.web3.jsonrpc import EthereumJSONRPC +from offchain.web3.jsonrpc import EthereumJSONRPC, AsyncEthereumJSONRPC CHUNK_SIZE = 500 @@ -183,3 +185,178 @@ def decode_response(self, response: dict, return_types: list[str]) -> Optional[A except Exception: return None + + +class AsyncContractCaller: + def __init__(self, rpc: Optional[AsyncEthereumJSONRPC] = None) -> None: + self.rpc = rpc or AsyncEthereumJSONRPC() + + async def single_address_single_fn_many_args( + self, + address: str, + function_sig: str, + return_type: list[str], + args: list[list[Any]], + block_tag: Optional[str] = "latest", + chunk_size: int = CHUNK_SIZE, + **kwargs, + ) -> list[Optional[Any]]: + """Call a single function on a single address with many different permutations of arguments + + Args: + address (str): address to call function on + function_sig (str): function signature (ex: "totalSupply()") + return_type (list[str]): return function signature (ex: ["uint256"]) + args (list[list[Any]]): list of arguments passed in each fn call (ex: [[1], [2], [3]]) + chunk_size (int, optional): number of calls to group in a single req. Defaults to 500. + + Returns: + list[Optional[Any]]: list of returned values, mapped 1-1 with args + """ + + req_params = [ + self.request_builder(address, function_sig, args[i], block_tag, **kwargs) for i in range(len(args)) + ] + res = await self._call_batch_chunked(req_params, chunk_size) + return list(map(lambda r: self.decode_response(r, return_type), res)) + + async def single_address_many_fns_many_args( + self, + address: str, + function_sigs: list[str], + return_types: list[list[str]], + args: list[list[Any]], + block_tag: Optional[str] = "latest", + chunk_size: int = CHUNK_SIZE, + ) -> dict[str, Optional[Any]]: + """Call many functions on a single addresses with differnt arguments per function + + Args: + address (str): address to call function on + function_sigs (list[str]): list of fn signature (ex: ["totalSupply()", "symbol()"]) + return_types (list[list[str]]): list of return function signature (ex: [["uint256"]]) + args (list[list[Any]]): list of arguments passed in each fn call (ex: [[1], [2], [3]]) + chunk_size (int, optional): [description]. Defaults to 500. + + Returns: + dict[str, Optional[Any]]: dicts with fn names as keys (ex: {"totalSupply()": 1234}) + """ + assert len(function_sigs) == len(args) and len(args) == len( + return_types + ), "function names, return types, args must all be the same length" + req_params = [self.request_builder(address, function_sigs[i], args[i], block_tag) for i in range(len(args))] + res = await self._call_batch_chunked(req_params, chunk_size) + cleaned = list(map(lambda i: self.decode_response(res[i], return_types[i]), range(len(res)))) + return {k: v for k, v in zip(function_sigs, cleaned)} + + async def _call_batch_chunked(self, request_params: list[list[Any]], chunk_size: int = CHUNK_SIZE) -> list[Any]: + """Perform concurrent batched requests by splitting a large batch into smaller chunks + + Args: + request_params (list[list[Any]]): list of request parameters + chunk_size (int, optional): size at which to split requests. Defaults to 500. + + Returns: + list[Any]: merged list of all data from the many requests + """ + + async def call(params: list[list[Any]]) -> list[Any]: + return await self.rpc.call_batch_chunked("eth_call", params) + + size = len(request_params) + if size < chunk_size: + return await call(request_params) + + prev_offset, curr_offest = 0, chunk_size + + chunks = [] + while prev_offset < size: + chunks.append(request_params[prev_offset:curr_offest]) + prev_offset = curr_offest + curr_offest = min(curr_offest + chunk_size, size) + + results = await asyncio.gather(*[call(chunk) for chunk in chunks]) + return [i for res in results for i in res] + + def request_builder( + self, + address: str, + function_sig: str, + args: Optional[list] = None, + block_tag: Optional[str] = "latest", + **kwargs, + ): + """Request generation function. Can be overloaded via inheritance for custom RPC requests. + + Args: + address (str): address to call + function_sig (str): function signature + args (Optional[list], optional): arguments for function if present + + Returns: + [type]: [description] + """ + data = self.encode_params(function_sig, args, **kwargs) + return [{"to": address, "data": data}, block_tag] + + def encode_params( + self, + function_sig: str, + args: Optional[list] = None, + arg_types: Optional[list] = None, + **kwargs, + ) -> str: + """Encode eth_call data by first taking the function sighash, then adding the encoded data + + Args:w + function_sig (str): function signature + args (Optional[list], optional): arguments to pass. Defaults to None. + + Returns: + str: [description] + """ + b = bytes.fromhex(function_signature_to_sighash(function_sig)[2:]) + + if args is not None: + if arg_types is None: + start = function_sig.find("(") + arg_types = function_sig[start:].strip("()").split(",") + + if type(arg_types) == str: + arg_types = [arg_types] + + b += encode_abi(arg_types, args) + + return to_hex(b) + + def decode_response(self, response: dict, return_types: list[str]) -> Optional[Any]: + """Decode responses, filling None for any errored requests + + Args: + response (dict): [description] + return_types (list[str]): [description] + + Returns: + Optional[Any]: [description] + """ + try: + data = response.get("result") + if data is None: + return None + + trimmed = data[2:] + if trimmed == "": + return None + + parsed = decode_abi(return_types, bytes.fromhex(trimmed)) + n_expected, n_received = len(return_types), len(parsed) + + if n_expected == 1 or n_received == 1: + return parsed[0] + elif n_expected < n_received: + return parsed[:n_expected] + else: + return parsed + + except Exception: + return None diff --git a/tests/metadata/web3/test_async_batching.py b/tests/metadata/web3/test_async_batching.py new file mode 100644 index 0000000..f021c9a --- /dev/null +++ b/tests/metadata/web3/test_async_batching.py @@ -0,0 +1,28 @@ +import pytest + +from offchain.web3.contract_caller import AsyncContractCaller +from offchain.web3.jsonrpc import AsyncEthereumJSONRPC + +ADDRESS = "0x335eeef8e93a7a757d9e7912044d9cd264e2b2d8" + +@pytest.mark.asyncio +async def test__single_address_single_fn_many_args(): + batcher = AsyncContractCaller(rpc=AsyncEthereumJSONRPC()) + ids = await batcher.single_address_single_fn_many_args( + ADDRESS, "tokenByIndex(uint256)", ["uint256"], [[0], [2], [3]] + ) + assert ids == [1, 3, 4] + +@pytest.mark.asyncio +async def test__single_address_many_fns_many_args(): + batcher = AsyncContractCaller(rpc=AsyncEthereumJSONRPC()) + results = await batcher.single_address_many_fns_many_args( + ADDRESS, + function_sigs=["tokenByIndex(uint256)", "tokenURI(uint256)"], + return_types=[["uint256"], ["string"]], + args=[[0], [8403]], + ) + assert results == { + "tokenByIndex(uint256)": 1, + "tokenURI(uint256)": "https://meta.sadgirlsbar.io/8403.json", + }