diff --git a/src/modules/ejector/ejector.py b/src/modules/ejector/ejector.py index e221b5497..98185d523 100644 --- a/src/modules/ejector/ejector.py +++ b/src/modules/ejector/ejector.py @@ -1,14 +1,13 @@ import logging import math -from functools import reduce from web3.exceptions import ContractCustomError from web3.types import Wei from src.constants import ( + EFFECTIVE_BALANCE_INCREMENT, FAR_FUTURE_EPOCH, MAX_WITHDRAWALS_PER_PAYLOAD, - MIN_ACTIVATION_BALANCE, MIN_VALIDATOR_WITHDRAWABILITY_DELAY, ) from src.metrics.prometheus.business import CONTRACT_ON_PAUSE @@ -29,11 +28,13 @@ from src.services.exit_order_v2.iterator import ValidatorExitIteratorV2 from src.services.prediction import RewardsPredictionService from src.services.validator_state import LidoValidatorStateService -from src.types import BlockStamp, EpochNumber, NodeOperatorGlobalIndex, ReferenceBlockStamp +from src.types import BlockStamp, EpochNumber, Gwei, NodeOperatorGlobalIndex, ReferenceBlockStamp from src.utils.cache import global_lru_cache as lru_cache from src.utils.validator_state import ( compute_activation_exit_epoch, - compute_exit_churn_limit, + get_activation_exit_churn_limit, + get_validator_churn_limit, + get_max_effective_balance, is_active_validator, is_fully_withdrawable_validator, is_partially_withdrawable_validator, @@ -117,7 +118,7 @@ def get_validators_to_eject(self, blockstamp: ReferenceBlockStamp) -> list[tuple EJECTOR_TO_WITHDRAW_WEI_AMOUNT.set(to_withdraw_amount) logger.info({'msg': 'Calculate to withdraw amount.', 'value': to_withdraw_amount}) - expected_balance = self._get_total_expected_balance(0, blockstamp) + expected_balance = self._get_total_expected_balance([], blockstamp) consensus_version = self.get_consensus_version(blockstamp) validators_iterator = iter(self.get_validators_iterator(consensus_version, blockstamp)) @@ -129,8 +130,11 @@ def get_validators_to_eject(self, blockstamp: ReferenceBlockStamp) -> list[tuple while expected_balance < to_withdraw_amount: gid, next_validator = next(validators_iterator) validators_to_eject.append((gid, next_validator)) - validator_to_eject_balance_sum += self._get_predicted_withdrawable_balance(next_validator) - expected_balance = self._get_total_expected_balance(len(validators_to_eject), blockstamp) + validator_to_eject_balance_sum + validator_to_eject_balance_sum += self.w3.to_wei(self._get_predicted_withdrawable_balance(next_validator), "gwei") + expected_balance = ( + self._get_total_expected_balance([v for (_, v) in validators_to_eject], blockstamp) + + validator_to_eject_balance_sum + ) except StopIteration: pass @@ -149,7 +153,7 @@ def get_validators_to_eject(self, blockstamp: ReferenceBlockStamp) -> list[tuple return validators_to_eject - def _get_total_expected_balance(self, vals_to_exit: int, blockstamp: ReferenceBlockStamp): + def _get_total_expected_balance(self, vals_to_exit: list[Validator], blockstamp: ReferenceBlockStamp): chain_config = self.get_chain_config(blockstamp) validators_going_to_exit = self.validators_state_service.get_recently_requested_but_not_exited_validators(blockstamp, chain_config) @@ -165,7 +169,7 @@ def _get_total_expected_balance(self, vals_to_exit: int, blockstamp: ReferenceBl rewards_speed_per_epoch = self.prediction_service.get_rewards_per_epoch(blockstamp, chain_config) logger.info({'msg': 'Calculate average rewards speed per epoch.', 'value': rewards_speed_per_epoch}) - withdrawal_epoch = self._get_predicted_withdrawable_epoch(blockstamp, len(validators_going_to_exit) + vals_to_exit + 1) + withdrawal_epoch = self._get_predicted_withdrawable_epoch(blockstamp, validators_going_to_exit + vals_to_exit) logger.info({'msg': 'Withdrawal epoch', 'value': withdrawal_epoch}) EJECTOR_MAX_WITHDRAWAL_EPOCH.set(withdrawal_epoch) @@ -211,8 +215,8 @@ def _get_withdrawable_lido_validators_balance(self, on_epoch: EpochNumber, block ) ) - def _get_predicted_withdrawable_balance(self, validator: Validator) -> Wei: - return self.w3.to_wei(min(int(validator.balance), MIN_ACTIVATION_BALANCE), 'gwei') + def _get_predicted_withdrawable_balance(self, validator: Validator) -> Gwei: + return Gwei(min(int(validator.balance), get_max_effective_balance(validator))) @lru_cache(maxsize=1) def _get_total_el_balance(self, blockstamp: BlockStamp) -> Wei: @@ -225,11 +229,23 @@ def _get_total_el_balance(self, blockstamp: BlockStamp) -> Wei: def _get_predicted_withdrawable_epoch( self, blockstamp: ReferenceBlockStamp, - validators_to_eject_count: int, + validators_to_eject: list[Validator], ) -> EpochNumber: """ Returns epoch when all validators in queue and validators_to_eject will be withdrawn. """ + spec = self.w3.cc.get_config_spec() + + if blockstamp.ref_epoch < int(spec.ELECTRA_FORK_EPOCH): + return self._get_predicted_withdrawable_epoch_pre_electra(blockstamp, validators_to_eject) + + return self._get_predicted_withdrawable_epoch_post_electra(blockstamp, validators_to_eject) + + def _get_predicted_withdrawable_epoch_pre_electra( + self, + blockstamp: ReferenceBlockStamp, + validators_to_eject: list[Validator], + ) -> EpochNumber: max_exit_epoch_number, latest_to_exit_validators_count = self._get_latest_exit_epoch(blockstamp) activation_exit_epoch = compute_activation_exit_epoch(blockstamp.ref_epoch) @@ -240,10 +256,32 @@ def _get_predicted_withdrawable_epoch( churn_limit = self._get_churn_limit(blockstamp) - epochs_required_to_exit_validators = (validators_to_eject_count + latest_to_exit_validators_count) // churn_limit + epochs_required_to_exit_validators = (len(validators_to_eject) + 1 + latest_to_exit_validators_count) // churn_limit return EpochNumber(max_exit_epoch_number + epochs_required_to_exit_validators + MIN_VALIDATOR_WITHDRAWABILITY_DELAY) + def _get_predicted_withdrawable_epoch_post_electra( + self, + blockstamp: ReferenceBlockStamp, + validators_to_eject: list[Validator], + ) -> EpochNumber: + per_epoch_churn = get_activation_exit_churn_limit(self._get_total_active_balance(blockstamp)) + activation_exit_epoch = compute_activation_exit_epoch(blockstamp.ref_epoch) + state_view = self.w3.cc.get_state_view(blockstamp.state_root) + + if state_view.earliest_exit_epoch < activation_exit_epoch: + earliest_exit_epoch = activation_exit_epoch + exit_balance_to_consume = per_epoch_churn + else: + earliest_exit_epoch = state_view.earliest_exit_epoch + exit_balance_to_consume = state_view.exit_balance_to_consume + + exit_balance = sum(self._get_predicted_withdrawable_balance(v) for v in validators_to_eject) + balance_to_process = max(0, exit_balance - exit_balance_to_consume) + additional_epochs = math.ceil(balance_to_process / per_epoch_churn) + + return EpochNumber(earliest_exit_epoch + additional_epochs + MIN_VALIDATOR_WITHDRAWABILITY_DELAY) + @lru_cache(maxsize=1) def _get_latest_exit_epoch(self, blockstamp: ReferenceBlockStamp) -> tuple[EpochNumber, int]: """ @@ -314,19 +352,20 @@ def _get_withdrawable_validators(self, blockstamp: ReferenceBlockStamp) -> list[ @lru_cache(maxsize=1) def _get_churn_limit(self, blockstamp: ReferenceBlockStamp) -> int: - total_active_validators = self._get_total_active_validators(blockstamp) - churn_limit = compute_exit_churn_limit(total_active_validators) + total_active_validators = len(self._get_active_validators(blockstamp)) + logger.info({'msg': 'Calculate total active validators.', 'value': total_active_validators}) + churn_limit = get_validator_churn_limit(total_active_validators) logger.info({'msg': 'Calculate churn limit.', 'value': churn_limit}) return churn_limit - def _get_total_active_validators(self, blockstamp: ReferenceBlockStamp) -> int: - total_active_validators = reduce( - lambda total, validator: total + int(is_active_validator(validator, blockstamp.ref_epoch)), - self.w3.cc.get_validators(blockstamp), - 0, - ) - logger.info({'msg': 'Calculate total active validators.', 'value': total_active_validators}) - return total_active_validators + # https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#get_total_active_balance + def _get_total_active_balance(self, blockstamp: ReferenceBlockStamp) -> Gwei: + active_validators = self._get_active_validators(blockstamp) + return Gwei(max(EFFECTIVE_BALANCE_INCREMENT, sum(int(v.validator.effective_balance) for v in active_validators))) + + @lru_cache(maxsize=1) + def _get_active_validators(self, blockstamp: ReferenceBlockStamp) -> list[Validator]: + return [v for v in self.w3.cc.get_validators(blockstamp) if is_active_validator(v, blockstamp.ref_epoch)] def is_main_data_submitted(self, blockstamp: BlockStamp) -> bool: processing_state = self._get_processing_state(blockstamp) diff --git a/src/providers/consensus/client.py b/src/providers/consensus/client.py index bb6fbf7e7..0bb7a132c 100644 --- a/src/providers/consensus/client.py +++ b/src/providers/consensus/client.py @@ -1,11 +1,12 @@ from http import HTTPStatus from typing import Literal, cast -from json_stream.base import TransientStreamingJSONObject # type: ignore +from json_stream.base import TransientAccessException, TransientStreamingJSONObject # type: ignore from src.metrics.logging import logging from src.metrics.prometheus.basic import CL_REQUESTS_DURATION from src.providers.consensus.types import ( + BeaconStateView, BlockAttestation, BlockAttestationResponse, BlockDetailsResponse, @@ -18,7 +19,7 @@ SlotAttestationCommittee, ) from src.providers.http_provider import HTTPProvider, NotOkResponse -from src.types import BlockRoot, BlockStamp, SlotNumber, EpochNumber +from src.types import BlockRoot, BlockStamp, SlotNumber, EpochNumber, StateRoot from src.utils.dataclass import list_of_dataclasses from src.utils.cache import global_lru_cache as lru_cache @@ -52,7 +53,7 @@ class ConsensusClient(HTTPProvider): API_GET_SPEC = 'eth/v1/config/spec' API_GET_GENESIS = 'eth/v1/beacon/genesis' - def get_config_spec(self): + def get_config_spec(self) -> BeaconSpecResponse: """Spec: https://ethereum.github.io/beacon-APIs/#/Config/getSpec""" data, _ = self._get(self.API_GET_SPEC) if not isinstance(data, dict): @@ -160,6 +161,25 @@ def get_state_block_roots(self, state_id: SlotNumber) -> list[BlockRoot]: )) return list(streamed_json['data']['block_roots']) + @lru_cache(maxsize=1) + def get_state_view(self, state_id: SlotNumber | StateRoot) -> BeaconStateView: + """Spec: https://ethereum.github.io/beacon-APIs/#/Debug/getStateV2""" + streamed_json = cast(TransientStreamingJSONObject, self._get( + self.API_GET_STATE, + path_params=(state_id,), + stream=True, + )) + view = {} + data = streamed_json['data'] + try: + # NOTE: Keep in mind: the order is important, see TransientStreamingJSONObject. + view['slot'] = int(data['slot']) + view['exit_balance_to_consume'] = int(data['exit_balance_to_consume']) + view['earliest_exit_epoch'] = int(data['earliest_exit_epoch']) + except TransientAccessException: + pass + return BeaconStateView.from_response(**view) + @lru_cache(maxsize=1) def get_validators(self, blockstamp: BlockStamp) -> list[Validator]: """Spec: https://ethereum.github.io/beacon-APIs/#/Beacon/getStateValidators""" diff --git a/src/providers/consensus/types.py b/src/providers/consensus/types.py index 7da932df6..60ad7b97b 100644 --- a/src/providers/consensus/types.py +++ b/src/providers/consensus/types.py @@ -2,8 +2,9 @@ from enum import Enum from typing import Literal, Protocol -from src.types import BlockHash, BlockRoot, StateRoot -from src.utils.dataclass import FromResponse, Nested +from src.types import BlockHash, BlockRoot, Gwei, SlotNumber, StateRoot +from src.utils.dataclass import Nested, FromResponse +from src.constants import FAR_FUTURE_EPOCH @dataclass @@ -13,6 +14,7 @@ class BeaconSpecResponse(FromResponse): SECONDS_PER_SLOT: str DEPOSIT_CONTRACT_ADDRESS: str SLOTS_PER_HISTORICAL_ROOT: str + ELECTRA_FORK_EPOCH: str = str(FAR_FUTURE_EPOCH) @dataclass @@ -164,3 +166,13 @@ class SlotAttestationCommittee(FromResponse): index: str slot: str validators: list[str] + + +@dataclass +class BeaconStateView(Nested, FromResponse): + """A view to BeaconState with only the required keys presented""" + + slot: SlotNumber + # This fields are new in Electra, so here are default values for backward compatibility. + exit_balance_to_consume: Gwei = Gwei(0) + earliest_exit_epoch: int = 0 diff --git a/src/utils/blockstamp.py b/src/utils/blockstamp.py index 897847806..01234076a 100644 --- a/src/utils/blockstamp.py +++ b/src/utils/blockstamp.py @@ -31,5 +31,5 @@ def _build_blockstamp_data( "state_root": slot_details.message.state_root, "block_number": BlockNumber(int(execution_payload.block_number)), "block_hash": execution_payload.block_hash, - "block_timestamp": Timestamp(int(execution_payload.timestamp)) + "block_timestamp": Timestamp(int(execution_payload.timestamp)), } diff --git a/src/utils/types.py b/src/utils/types.py index e501e5be0..b1e3457c8 100644 --- a/src/utils/types.py +++ b/src/utils/types.py @@ -7,3 +7,13 @@ def bytes_to_hex_str(b: bytes) -> HexStr: def hex_str_to_bytes(hex_str: str) -> bytes: return bytes.fromhex(hex_str[2:]) if hex_str.startswith("0x") else bytes.fromhex(hex_str) + + +def is_4bytes_hex(s: str) -> bool: + if not s.startswith("0x"): + return False + + try: + return len(bytes.fromhex(s[2:])) == 4 + except ValueError: + return False diff --git a/src/utils/validator_state.py b/src/utils/validator_state.py index 5b354b959..04ac7e4b6 100644 --- a/src/utils/validator_state.py +++ b/src/utils/validator_state.py @@ -1,16 +1,18 @@ from typing import Sequence from src.constants import ( - ETH1_ADDRESS_WITHDRAWAL_PREFIX, - SHARD_COMMITTEE_PERIOD, - FAR_FUTURE_EPOCH, - EFFECTIVE_BALANCE_INCREMENT, - MAX_SEED_LOOKAHEAD, - MIN_PER_EPOCH_CHURN_LIMIT, CHURN_LIMIT_QUOTIENT, COMPOUNDING_WITHDRAWAL_PREFIX, + EFFECTIVE_BALANCE_INCREMENT, + ETH1_ADDRESS_WITHDRAWAL_PREFIX, + FAR_FUTURE_EPOCH, MAX_EFFECTIVE_BALANCE_ELECTRA, + MAX_PER_EPOCH_ACTIVATION_EXIT_CHURN_LIMIT, + MAX_SEED_LOOKAHEAD, MIN_ACTIVATION_BALANCE, + MIN_PER_EPOCH_CHURN_LIMIT, + MIN_PER_EPOCH_CHURN_LIMIT_ELECTRA, + SHARD_COMMITTEE_PERIOD, ) from src.providers.consensus.types import Validator from src.types import EpochNumber, Gwei @@ -134,10 +136,21 @@ def compute_activation_exit_epoch(ref_epoch: EpochNumber): return ref_epoch + 1 + MAX_SEED_LOOKAHEAD -def compute_exit_churn_limit(active_validators_count: int): +def get_validator_churn_limit(active_validators_count: int): return max(MIN_PER_EPOCH_CHURN_LIMIT, active_validators_count // CHURN_LIMIT_QUOTIENT) +# @see https://github.com/ethereum/consensus-specs/blob/dev/specs/electra/beacon-chain.md#new-get_activation_exit_churn_limit +def get_activation_exit_churn_limit(total_active_balance: Gwei) -> Gwei: + return min(MAX_PER_EPOCH_ACTIVATION_EXIT_CHURN_LIMIT, get_balance_churn_limit(total_active_balance)) + + +# @see https://github.com/ethereum/consensus-specs/blob/dev/specs/electra/beacon-chain.md#new-get_balance_churn_limit +def get_balance_churn_limit(total_active_balance: Gwei) -> Gwei: + churn = max(MIN_PER_EPOCH_CHURN_LIMIT_ELECTRA, total_active_balance // CHURN_LIMIT_QUOTIENT) + return Gwei(churn - churn % EFFECTIVE_BALANCE_INCREMENT) + + def get_max_effective_balance(validator: Validator) -> Gwei: """ Get max effective balance for ``validator``. diff --git a/tests/factory/no_registry.py b/tests/factory/no_registry.py index 27ac9bc9b..785c633fa 100644 --- a/tests/factory/no_registry.py +++ b/tests/factory/no_registry.py @@ -6,7 +6,7 @@ from hexbytes import HexBytes from pydantic_factories import Use -from src.constants import EFFECTIVE_BALANCE_INCREMENT, FAR_FUTURE_EPOCH, MIN_ACTIVATION_BALANCE +from src.constants import EFFECTIVE_BALANCE_INCREMENT, FAR_FUTURE_EPOCH, MAX_EFFECTIVE_BALANCE, MIN_ACTIVATION_BALANCE from src.providers.consensus.types import Validator, ValidatorState from src.providers.keys.types import LidoKey from src.types import Gwei @@ -119,11 +119,12 @@ def build_exit_vals(cls, epoch, **kwargs: Any): ) @classmethod - def build_with_balance(cls, balance: Gwei, **kwargs: Any): + def build_with_balance(cls, balance: float, meb: int = MAX_EFFECTIVE_BALANCE, **kwargs: Any): return cls.build( balance=balance, validator=ValidatorStateFactory.build( - effective_balance=min(balance - balance % EFFECTIVE_BALANCE_INCREMENT, MIN_ACTIVATION_BALANCE), + effective_balance=min(balance - balance % EFFECTIVE_BALANCE_INCREMENT, meb), + withdrawal_credentials="0x01" if meb == MAX_EFFECTIVE_BALANCE else "0x02", ), **kwargs, ) diff --git a/tests/modules/ejector/test_ejector.py b/tests/modules/ejector/test_ejector.py index 6a5bffdbf..8c6792646 100644 --- a/tests/modules/ejector/test_ejector.py +++ b/tests/modules/ejector/test_ejector.py @@ -5,13 +5,21 @@ from web3.exceptions import ContractCustomError from src import constants -from src.constants import MAX_EFFECTIVE_BALANCE +from src.constants import ( + EFFECTIVE_BALANCE_INCREMENT, + MAX_EFFECTIVE_BALANCE, + MAX_EFFECTIVE_BALANCE_ELECTRA, + MAX_SEED_LOOKAHEAD, + MIN_ACTIVATION_BALANCE, + MIN_VALIDATOR_WITHDRAWABILITY_DELAY, +) from src.modules.ejector import ejector as ejector_module from src.modules.ejector.ejector import Ejector from src.modules.ejector.ejector import logger as ejector_logger from src.modules.ejector.types import EjectorProcessingState from src.modules.submodules.oracle_module import ModuleExecuteDelay from src.modules.submodules.types import ChainConfig, CurrentFrame +from src.providers.consensus.types import BeaconStateView from src.types import BlockStamp, Gwei, ReferenceBlockStamp from src.utils import validator_state from src.web3py.extensions.contracts import LidoContracts @@ -223,17 +231,97 @@ def test_is_contract_reportable(ejector: Ejector, blockstamp: BlockStamp) -> Non @pytest.mark.unit -def test_get_predicted_withdrawable_epoch(ejector: Ejector) -> None: +def test_get_predicted_withdrawable_epoch_pre_electra(ejector: Ejector) -> None: + ejector.w3.cc = Mock() + ejector.w3.cc.get_config_spec = Mock(return_value=Mock(ELECTRA_FORK_EPOCH=FAR_FUTURE_EPOCH)) ejector._get_latest_exit_epoch = Mock(return_value=[1, 32]) ejector._get_churn_limit = Mock(return_value=2) ref_blockstamp = ReferenceBlockStampFactory.build(ref_epoch=3546) - result = ejector._get_predicted_withdrawable_epoch(ref_blockstamp, 2) + result = ejector._get_predicted_withdrawable_epoch(ref_blockstamp, [Mock()] * 2) assert result == 3808, "Unexpected predicted withdrawable epoch" - result = ejector._get_predicted_withdrawable_epoch(ref_blockstamp, 4) + result = ejector._get_predicted_withdrawable_epoch(ref_blockstamp, [Mock()] * 4) assert result == 3809, "Unexpected predicted withdrawable epoch" +class TestPredictedWithdrawableEpochPostElectra: + @pytest.fixture + def ref_blockstamp(self) -> ReferenceBlockStamp: + return ReferenceBlockStampFactory.build( + ref_slot=10_000_000, + ref_epoch=10_000_000 // 32, + ) + + @pytest.mark.unit + def test_earliest_exit_epoch_is_old(self, ejector: Ejector, ref_blockstamp: ReferenceBlockStamp) -> None: + ejector._get_total_active_balance = Mock(return_value=int(2048e9)) + ejector.w3.cc.get_state_view = Mock( + return_value=BeaconStateView( + slot=ref_blockstamp.slot_number, + earliest_exit_epoch=ref_blockstamp.ref_epoch, + exit_balance_to_consume=Gwei(0), + ) + ) + result = ejector._get_predicted_withdrawable_epoch( + ref_blockstamp, + [LidoValidatorFactory.build_with_balance(MIN_ACTIVATION_BALANCE)] * 1, + ) + assert result == ref_blockstamp.ref_epoch + (1 + MAX_SEED_LOOKAHEAD) + MIN_VALIDATOR_WITHDRAWABILITY_DELAY + + @pytest.mark.unit + def test_exit_fits_exit_balance_to_consume(self, ejector: Ejector, ref_blockstamp: ReferenceBlockStamp) -> None: + ejector._get_total_active_balance = Mock(return_value=int(2048e9)) + ejector.w3.cc.get_state_view = Mock( + return_value=BeaconStateView( + slot=ref_blockstamp.slot_number, + earliest_exit_epoch=ref_blockstamp.ref_epoch + 10_000, + exit_balance_to_consume=Gwei(int(256e9)), + ) + ) + result = ejector._get_predicted_withdrawable_epoch( + ref_blockstamp, + [LidoValidatorFactory.build_with_balance(129e9, meb=MAX_EFFECTIVE_BALANCE_ELECTRA)] * 1, + ) + assert result == ref_blockstamp.ref_epoch + 10_000 + MIN_VALIDATOR_WITHDRAWABILITY_DELAY + + @pytest.mark.unit + def test_exit_exceeds_balance_to_consume(self, ejector: Ejector, ref_blockstamp: ReferenceBlockStamp) -> None: + ejector._get_total_active_balance = Mock(return_value=2048e9) + ejector.w3.cc.get_state_view = Mock( + return_value=BeaconStateView( + slot=ref_blockstamp.slot_number, + earliest_exit_epoch=ref_blockstamp.ref_epoch + 10_000, + exit_balance_to_consume=Gwei(1), + ) + ) + result = ejector._get_predicted_withdrawable_epoch( + ref_blockstamp, + [LidoValidatorFactory.build_with_balance(512e9, meb=MAX_EFFECTIVE_BALANCE_ELECTRA)] * 1, + ) + assert result == ref_blockstamp.ref_epoch + 10_000 + 4 + MIN_VALIDATOR_WITHDRAWABILITY_DELAY + + @pytest.mark.unit + def test_exit_exceeds_churn_limit(self, ejector: Ejector, ref_blockstamp: ReferenceBlockStamp) -> None: + ejector._get_total_active_balance = Mock(return_value=2048e9) + ejector.w3.cc.get_state_view = Mock( + return_value=BeaconStateView( + slot=ref_blockstamp.slot_number, + earliest_exit_epoch=ref_blockstamp.ref_epoch, + exit_balance_to_consume=Gwei(0), + ) + ) + result = ejector._get_predicted_withdrawable_epoch( + ref_blockstamp, + [LidoValidatorFactory.build_with_balance(512e9, meb=MAX_EFFECTIVE_BALANCE_ELECTRA)] * 1, + ) + assert result == ref_blockstamp.ref_epoch + (1 + MAX_SEED_LOOKAHEAD) + 3 + MIN_VALIDATOR_WITHDRAWABILITY_DELAY + + @pytest.fixture(autouse=True) + def _patch_ejector(self, ejector: Ejector): + ejector.w3.cc = Mock() + ejector.w3.cc.get_config_spec = Mock(return_value=Mock(ELECTRA_FORK_EPOCH=0)) + + @pytest.mark.unit def test_get_total_active_validators(ejector: Ejector) -> None: ref_blockstamp = ReferenceBlockStampFactory.build(ref_epoch=3546) @@ -246,7 +334,34 @@ def test_get_total_active_validators(ejector: Ejector) -> None: ] ) - assert ejector._get_total_active_validators(ref_blockstamp) == 100 + assert len(ejector._get_active_validators(ref_blockstamp)) == 100 + + +@pytest.mark.unit +def test_get_total_active_balance(ejector: Ejector) -> None: + ejector._get_active_validators = Mock(return_value=[]) + assert ejector._get_total_active_balance(Mock()) == EFFECTIVE_BALANCE_INCREMENT + ejector._get_active_validators.assert_called_once() + + ejector._get_active_validators = Mock( + return_value=[ + LidoValidatorFactory.build_with_balance(Gwei(32 * 10**9)), + LidoValidatorFactory.build_with_balance(Gwei(33 * 10**9)), + LidoValidatorFactory.build_with_balance(Gwei(31 * 10**9)), + ] + ) + assert ejector._get_total_active_balance(Mock()) == Gwei(95 * 10**9) + ejector._get_active_validators.assert_called_once() + + ejector._get_active_validators = Mock( + return_value=[ + LidoValidatorFactory.build_with_balance(Gwei(32 * 10**9)), + LidoValidatorFactory.build_with_balance(Gwei(31 * 10**9)), + LidoValidatorFactory.build_with_balance(Gwei(99 * 10**9), meb=MAX_EFFECTIVE_BALANCE_ELECTRA), + ] + ) + assert ejector._get_total_active_balance(Mock()) == Gwei(162 * 10**9) + ejector._get_active_validators.assert_called_once() @pytest.mark.unit @@ -273,7 +388,7 @@ def test_get_withdrawable_lido_validators_balance( ) result = ejector._get_withdrawable_lido_validators_balance(42, ref_blockstamp) - assert result == 42 * 10**9, "Unexpected withdrawable amount" + assert result == 42, "Unexpected withdrawable amount" ejector._get_withdrawable_lido_validators_balance(42, ref_blockstamp) ejector.w3.lido_validators.get_lido_validators.assert_called_once() @@ -281,17 +396,24 @@ def test_get_withdrawable_lido_validators_balance( @pytest.mark.unit def test_get_predicted_withdrawable_balance(ejector: Ejector) -> None: - validator = LidoValidatorFactory.build(balance="0") + validator = LidoValidatorFactory.build_with_balance(Gwei(0)) result = ejector._get_predicted_withdrawable_balance(validator) assert result == 0, "Expected zero" - validator = LidoValidatorFactory.build(balance="42") + validator = LidoValidatorFactory.build_with_balance(Gwei(42)) result = ejector._get_predicted_withdrawable_balance(validator) - assert result == 42 * 10**9, "Expected validator's balance in gwei" + assert result == 42, "Expected validator's balance in gwei" - validator = LidoValidatorFactory.build(balance=str(MAX_EFFECTIVE_BALANCE + 1)) + validator = LidoValidatorFactory.build_with_balance(Gwei(MAX_EFFECTIVE_BALANCE + 1)) + result = ejector._get_predicted_withdrawable_balance(validator) + assert result == MAX_EFFECTIVE_BALANCE, "Expect MAX_EFFECTIVE_BALANCE" + + validator = LidoValidatorFactory.build_with_balance( + Gwei(MAX_EFFECTIVE_BALANCE + 1), + meb=MAX_EFFECTIVE_BALANCE_ELECTRA, + ) result = ejector._get_predicted_withdrawable_balance(validator) - assert result == MAX_EFFECTIVE_BALANCE * 10**9, "Expect MAX_EFFECTIVE_BALANCE" + assert result == MAX_EFFECTIVE_BALANCE + 1, "Expect MAX_EFFECTIVE_BALANCE + 1" @pytest.mark.unit diff --git a/tests/providers/consensus/test_consensus_client.py b/tests/providers/consensus/test_consensus_client.py index c58aaf4f0..770fd8f67 100644 --- a/tests/providers/consensus/test_consensus_client.py +++ b/tests/providers/consensus/test_consensus_client.py @@ -71,6 +71,19 @@ def test_get_validators(consensus_client: ConsensusClient): assert validator_by_pub_key[0] == validator +@pytest.mark.integration +@pytest.mark.skip(reason="Too long to complete in CI") +def test_get_state_view(consensus_client: ConsensusClient): + state_view = consensus_client.get_state_view("head") + assert state_view.slot > 0 + + spec = consensus_client.get_config_spec() + epoch = state_view.slot // 32 + if epoch >= int(spec.ELECTRA_FORK_EPOCH): + assert state_view.earliest_exit_epoch != 0 + assert state_view.exit_balance_to_consume >= 0 + + @pytest.mark.unit def test_get_returns_nor_dict_nor_list(consensus_client: ConsensusClient): consensus_client._get_without_fallbacks = Mock(return_value=(1, None)) diff --git a/tests/utils/test_types.py b/tests/utils/test_types.py index 6b30738f4..d0339f5ee 100644 --- a/tests/utils/test_types.py +++ b/tests/utils/test_types.py @@ -1,6 +1,6 @@ import pytest -from src.utils.types import bytes_to_hex_str, hex_str_to_bytes +from src.utils.types import bytes_to_hex_str, hex_str_to_bytes, is_4bytes_hex @pytest.mark.unit @@ -12,9 +12,26 @@ def test_bytes_to_hex_str(): @pytest.mark.unit def test_hex_str_to_bytes(): - assert hex_str_to_bytes("0x") == b"" - assert hex_str_to_bytes("0x00") == b"\x00" - assert hex_str_to_bytes("0x000102") == b"\x00\x01\x02" assert hex_str_to_bytes("") == b"" assert hex_str_to_bytes("00") == b"\x00" assert hex_str_to_bytes("000102") == b"\x00\x01\x02" + assert hex_str_to_bytes("0x") == b"" + assert hex_str_to_bytes("0x00") == b"\x00" + assert hex_str_to_bytes("0x000102") == b"\x00\x01\x02" + + +@pytest.mark.unit +def test_is_4bytes_hex(): + assert is_4bytes_hex("0x00000000") + assert is_4bytes_hex("0x02000000") + assert is_4bytes_hex("0x02000000") + assert is_4bytes_hex("0x30637624") + + assert not is_4bytes_hex("") + assert not is_4bytes_hex("0x") + assert not is_4bytes_hex("0x00") + assert not is_4bytes_hex("0x01") + assert not is_4bytes_hex("0x01") + assert not is_4bytes_hex("0xgg") + assert not is_4bytes_hex("0x111") + assert not is_4bytes_hex("0x02000000ff") diff --git a/tests/utils/test_validator_state_utils.py b/tests/utils/test_validator_state_utils.py index 8b52d270b..b0553cddf 100644 --- a/tests/utils/test_validator_state_utils.py +++ b/tests/utils/test_validator_state_utils.py @@ -1,29 +1,31 @@ -from pydantic.class_validators import validator import pytest +from pydantic.class_validators import validator from src.constants import ( - FAR_FUTURE_EPOCH, EFFECTIVE_BALANCE_INCREMENT, + FAR_FUTURE_EPOCH, MAX_EFFECTIVE_BALANCE_ELECTRA, MIN_ACTIVATION_BALANCE, ) -from src.providers.consensus.types import Validator, ValidatorStatus, ValidatorState +from src.providers.consensus.types import Validator, ValidatorState, ValidatorStatus from src.types import EpochNumber, Gwei from src.utils.validator_state import ( - calculate_total_active_effective_balance, - is_on_exit, - get_validator_age, calculate_active_effective_balance_sum, - is_validator_eligible_to_exit, - is_fully_withdrawable_validator, - is_partially_withdrawable_validator, - has_eth1_withdrawal_credential, - is_exited_validator, - is_active_validator, + calculate_total_active_effective_balance, compute_activation_exit_epoch, + get_balance_churn_limit, + get_max_effective_balance, + get_validator_age, has_compounding_withdrawal_credential, + has_eth1_withdrawal_credential, has_execution_withdrawal_credential, - get_max_effective_balance, + is_active_validator, + is_exited_validator, + is_fully_withdrawable_validator, + is_on_exit, + is_partially_withdrawable_validator, + is_validator_eligible_to_exit, + get_activation_exit_churn_limit, ) from tests.factory.no_registry import ValidatorFactory from tests.modules.accounting.bunker.test_bunker_abnormal_cl_rebase import simple_validators @@ -367,3 +369,41 @@ def test_skip_ongoing(self, validators: list[Validator]): def test_compute_activation_exit_epoch(): ref_epoch = 3455 assert 3460 == compute_activation_exit_epoch(ref_epoch) + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("total_active_balance", "expected_limit"), + ( + (0, 128e9), + (32e9, 128e9), + (2 * 32e9, 128e9), + (1024 * 32e9, 128e9), + (512 * 1024 * 32e9, 256e9), + (1024 * 1024 * 32e9, 512e9), + (2000 * 1024 * 32e9, 1000e9), + (3300 * 1024 * 32e9, 1650e9), + ), +) +def test_get_balance_churn_limit(total_active_balance: Gwei, expected_limit: Gwei): + actual_limit = get_balance_churn_limit(total_active_balance) + assert actual_limit == expected_limit, "Unexpected balance churn limit" + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("total_active_balance", "expected_limit"), + ( + (0, 128e9), + (32e9, 128e9), + (2 * 32e9, 128e9), + (1024 * 32e9, 128e9), + (512 * 1024 * 32e9, 256e9), + (1024 * 1024 * 32e9, 256e9), + (2000 * 1024 * 32e9, 256e9), + (3300 * 1024 * 32e9, 256e9), + ), +) +def test_compute_exit_balance_churn_limit(total_active_balance: Gwei, expected_limit: Gwei): + actual_limit = get_activation_exit_churn_limit(total_active_balance) + assert actual_limit == expected_limit, "Unexpected exit churn limit"