diff --git a/poetry.lock b/poetry.lock index b2edde3..702008f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2329,23 +2329,23 @@ files = [ [[package]] name = "virtualenv" -version = "20.24.1" +version = "20.24.2" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.24.1-py3-none-any.whl", hash = "sha256:01aacf8decd346cf9a865ae85c0cdc7f64c8caa07ff0d8b1dfc1733d10677442"}, - {file = "virtualenv-20.24.1.tar.gz", hash = "sha256:2ef6a237c31629da6442b0bcaa3999748108c7166318d1f55cc9f8d7294e97bd"}, + {file = "virtualenv-20.24.2-py3-none-any.whl", hash = "sha256:43a3052be36080548bdee0b42919c88072037d50d56c28bd3f853cbe92b953ff"}, + {file = "virtualenv-20.24.2.tar.gz", hash = "sha256:fd8a78f46f6b99a67b7ec5cf73f92357891a7b3a40fd97637c27f854aae3b9e0"}, ] [package.dependencies] -distlib = ">=0.3.6,<1" -filelock = ">=3.12,<4" -platformdirs = ">=3.5.1,<4" +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<4" [package.extras] docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.3.1)", "pytest-env (>=0.8.1)", "pytest-freezer (>=0.4.6)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=67.8)", "time-machine (>=2.9)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] [[package]] name = "web3" diff --git a/pyproject.toml b/pyproject.toml index f0a5674..7f1d0d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "sw-utils" -version = "0.3.16" +version = "0.3.17" description = "StakeWise Python utils" authors = ["StakeWise Labs "] license = "GPL-3.0-or-later" diff --git a/sw_utils/consensus.py b/sw_utils/consensus.py index 8c9d3d1..6839752 100644 --- a/sw_utils/consensus.py +++ b/sw_utils/consensus.py @@ -3,14 +3,18 @@ from typing import TYPE_CHECKING, Any import aiohttp -from eth_typing import URI, HexStr +from aiohttp import ClientResponseError +from eth_typing import URI, BlockNumber, HexStr +from web3 import Web3 from web3._utils.request import async_json_make_get_request from web3.beacon import AsyncBeacon from web3.beacon.api_endpoints import GET_VOLUNTARY_EXITS +from web3.types import Timestamp from sw_utils.common import urljoin from sw_utils.decorators import retry_aiohttp_errors from sw_utils.exceptions import AiohttpRecoveredErrors +from sw_utils.typings import ChainHead, ConsensusFork logger = logging.getLogger(__name__) @@ -95,6 +99,38 @@ async def submit_voluntary_exit( raise error logger.error('%s: %s', url, repr(error)) + async def get_chain_finalized_head(self, slots_per_epoch: int) -> ChainHead: + """Fetches the fork safe chain head.""" + checkpoints = await self.get_finality_checkpoint() + epoch: int = int(checkpoints['data']['finalized']['epoch']) + last_slot_id: int = (epoch * slots_per_epoch) + slots_per_epoch - 1 + for i in range(slots_per_epoch): + try: + slot = await self.get_block(str(last_slot_id - i)) + except ClientResponseError as e: + if hasattr(e, 'status') and e.status == 404: + # slot was not proposed, try the previous one + continue + raise e + + execution_payload = slot['data']['message']['body']['execution_payload'] + return ChainHead( + epoch=epoch, + consensus_block=last_slot_id - i, + execution_block=BlockNumber(int(execution_payload['block_number'])), + execution_ts=Timestamp(int(execution_payload['timestamp'])), + ) + + raise RuntimeError(f'Failed to fetch slot for epoch {epoch}') + + async def get_consensus_fork(self, state_id: str = 'head') -> ConsensusFork: + """Fetches current fork data.""" + fork_data = (await self.get_fork_data(state_id))['data'] + return ConsensusFork( + version=Web3.to_bytes(hexstr=fork_data['current_version']), + epoch=int(fork_data['epoch']), + ) + async def _async_make_get_request(self, endpoint_uri: str) -> dict[str, Any]: if self.retry_timeout: diff --git a/sw_utils/decorators.py b/sw_utils/decorators.py index 3725ea2..e50b5ca 100644 --- a/sw_utils/decorators.py +++ b/sw_utils/decorators.py @@ -3,7 +3,15 @@ import typing import aiohttp -from tenacity import retry, retry_if_exception, stop_after_delay, wait_exponential +from tenacity import ( + retry, + retry_if_exception, + retry_if_exception_type, + stop_after_delay, + wait_exponential, +) + +from .ipfs import IpfsException default_logger = logging.getLogger(__name__) @@ -40,3 +48,12 @@ def retry_aiohttp_errors(delay: int = 60, log_func=custom_before_log): stop=stop_after_delay(delay), before=log_func(default_logger, logging.INFO), ) + + +def retry_ipfs_exception(delay: int): + return retry( + retry=retry_if_exception_type(IpfsException), + wait=wait_exponential(multiplier=1, min=1, max=delay // 2), + stop=stop_after_delay(delay), + before=custom_before_log(default_logger, logging.INFO), + ) diff --git a/sw_utils/typings.py b/sw_utils/typings.py index 57e97cd..408b6d2 100644 --- a/sw_utils/typings.py +++ b/sw_utils/typings.py @@ -1,6 +1,9 @@ from dataclasses import dataclass from typing import NewType +from eth_typing import BlockNumber +from web3.types import Timestamp + Bytes32 = NewType('Bytes32', bytes) @@ -8,3 +11,11 @@ class ConsensusFork: version: bytes epoch: int + + +@dataclass +class ChainHead: + epoch: int + consensus_block: int + execution_block: BlockNumber + execution_ts: Timestamp