diff --git a/src/constants.py b/src/constants.py index 743cc90e8..0d4afe7f1 100644 --- a/src/constants.py +++ b/src/constants.py @@ -1,41 +1,44 @@ from src.types import Gwei # https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#misc -FAR_FUTURE_EPOCH = 2 ** 64 - 1 +FAR_FUTURE_EPOCH = 2**64 - 1 # https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#time-parameters-1 -MIN_VALIDATOR_WITHDRAWABILITY_DELAY = 2 ** 8 +MIN_VALIDATOR_WITHDRAWABILITY_DELAY = 2**8 SHARD_COMMITTEE_PERIOD = 256 MAX_SEED_LOOKAHEAD = 4 # https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#state-list-lengths -EPOCHS_PER_SLASHINGS_VECTOR = 2 ** 13 +EPOCHS_PER_SLASHINGS_VECTOR = 2**13 # https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#rewards-and-penalties PROPORTIONAL_SLASHING_MULTIPLIER_BELLATRIX = 3 # https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#gwei-values -EFFECTIVE_BALANCE_INCREMENT = 2 ** 0 * 10 ** 9 -MAX_EFFECTIVE_BALANCE = Gwei(32 * 10 ** 9) +EFFECTIVE_BALANCE_INCREMENT = 2**0 * 10**9 +MAX_EFFECTIVE_BALANCE = Gwei(32 * 10**9) # https://github.com/ethereum/consensus-specs/blob/dev/specs/capella/beacon-chain.md#execution -MAX_WITHDRAWALS_PER_PAYLOAD = 2 ** 4 +MAX_WITHDRAWALS_PER_PAYLOAD = 2**4 # https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#withdrawal-prefixes ETH1_ADDRESS_WITHDRAWAL_PREFIX = '0x01' # https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#withdrawal-prefixes COMPOUNDING_WITHDRAWAL_PREFIX = '0x02' # https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#validator-cycle -MIN_PER_EPOCH_CHURN_LIMIT = 2 ** 2 -CHURN_LIMIT_QUOTIENT = 2 ** 16 +MIN_PER_EPOCH_CHURN_LIMIT = 2**2 +CHURN_LIMIT_QUOTIENT = 2**16 +# https://github.com/ethereum/consensus-specs/blob/dev/specs/electra/beacon-chain.md#validator-cycle +MIN_PER_EPOCH_CHURN_LIMIT_ELECTRA = Gwei(2**7 * 10**9) +MAX_PER_EPOCH_ACTIVATION_EXIT_CHURN_LIMIT = Gwei(2**8 * 10**9) # https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#time-parameters SLOTS_PER_HISTORICAL_ROOT = 8192 # https://github.com/ethereum/consensus-specs/blob/dev/specs/electra/beacon-chain.md#gwei-values -MIN_ACTIVATION_BALANCE = Gwei(2 ** 5 * 10 ** 9) -MAX_EFFECTIVE_BALANCE_ELECTRA = Gwei(2 ** 11 * 10 ** 9) +MIN_ACTIVATION_BALANCE = Gwei(2**5 * 10**9) +MAX_EFFECTIVE_BALANCE_ELECTRA = Gwei(2**11 * 10**9) LIDO_DEPOSIT_AMOUNT = MIN_ACTIVATION_BALANCE # Local constants -GWEI_TO_WEI = 10 ** 9 -SHARE_RATE_PRECISION_E27 = 10 ** 27 +GWEI_TO_WEI = 10**9 +SHARE_RATE_PRECISION_E27 = 10**27 TOTAL_BASIS_POINTS = 10000 MAX_BLOCK_GAS_LIMIT = 30_000_000 -UINT64_MAX = 2 ** 64 - 1 +UINT64_MAX = 2**64 - 1 diff --git a/src/main.py b/src/main.py index 98c398100..b6b2a7c7d 100644 --- a/src/main.py +++ b/src/main.py @@ -24,7 +24,7 @@ KeysAPIClientModule, LidoValidatorsProvider, FallbackProviderModule, - LazyCSM + LazyCSM, ) from src.web3py.middleware import metrics_collector from src.web3py.types import Web3 diff --git a/src/modules/ejector/ejector.py b/src/modules/ejector/ejector.py index db2e7fe81..e221b5497 100644 --- a/src/modules/ejector/ejector.py +++ b/src/modules/ejector/ejector.py @@ -1,25 +1,25 @@ import logging +import math from functools import reduce -from more_itertools import ilen from web3.exceptions import ContractCustomError from web3.types import Wei from src.constants import ( FAR_FUTURE_EPOCH, - MAX_EFFECTIVE_BALANCE, MAX_WITHDRAWALS_PER_PAYLOAD, + MIN_ACTIVATION_BALANCE, MIN_VALIDATOR_WITHDRAWABILITY_DELAY, ) from src.metrics.prometheus.business import CONTRACT_ON_PAUSE +from src.metrics.prometheus.duration_meter import duration_meter from src.metrics.prometheus.ejector import ( - EJECTOR_VALIDATORS_COUNT_TO_EJECT, - EJECTOR_TO_WITHDRAW_WEI_AMOUNT, EJECTOR_MAX_WITHDRAWAL_EPOCH, + EJECTOR_TO_WITHDRAW_WEI_AMOUNT, + EJECTOR_VALIDATORS_COUNT_TO_EJECT, ) -from src.metrics.prometheus.duration_meter import duration_meter from src.modules.ejector.data_encode import encode_data -from src.modules.ejector.types import ReportData, EjectorProcessingState +from src.modules.ejector.types import EjectorProcessingState, ReportData from src.modules.submodules.consensus import ConsensusModule, InitialEpochIsYetToArriveRevert from src.modules.submodules.oracle_module import BaseModule, ModuleExecuteDelay from src.modules.submodules.types import ZERO_HASH @@ -29,19 +29,18 @@ 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, ReferenceBlockStamp, NodeOperatorGlobalIndex +from src.types import BlockStamp, EpochNumber, 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, is_active_validator, is_fully_withdrawable_validator, is_partially_withdrawable_validator, - compute_activation_exit_epoch, - compute_exit_churn_limit, ) from src.web3py.extensions.lido_validators import LidoValidator from src.web3py.types import Web3 - logger = logging.getLogger(__name__) @@ -62,6 +61,7 @@ class Ejector(BaseModule, ConsensusModule): 3. Decode lido validators into bytes and send report transaction """ + COMPATIBLE_ONCHAIN_VERSIONS = [(1, 1), (1, 2)] AVG_EXPECTING_WITHDRAWALS_SWEEP_DURATION_MULTIPLIER = 0.5 @@ -75,7 +75,7 @@ def __init__(self, w3: Web3): self.validators_state_service = LidoValidatorStateService(w3) def refresh_contracts(self): - self.report_contract = self.w3.lido_contracts.validators_exit_bus_oracle + self.report_contract = self.w3.lido_contracts.validators_exit_bus_oracle # type: ignore def execute_module(self, last_finalized_blockstamp: BlockStamp) -> ModuleExecuteDelay: report_blockstamp = self.get_blockstamp_for_report(last_finalized_blockstamp) @@ -119,7 +119,7 @@ def get_validators_to_eject(self, blockstamp: ReferenceBlockStamp) -> list[tuple expected_balance = self._get_total_expected_balance(0, blockstamp) - consensus_version = self.w3.lido_contracts.validators_exit_bus_oracle.get_consensus_version(blockstamp.block_hash) + consensus_version = self.get_consensus_version(blockstamp) validators_iterator = iter(self.get_validators_iterator(consensus_version, blockstamp)) validators_to_eject: list[tuple[NodeOperatorGlobalIndex, LidoValidator]] = [] @@ -141,7 +141,7 @@ def get_validators_to_eject(self, blockstamp: ReferenceBlockStamp) -> list[tuple 'validators_to_eject_count': len(validators_to_eject), }) - if consensus_version != 1: + if self.get_consensus_version(blockstamp) != 1: forced_validators = validators_iterator.get_remaining_forced_validators() if forced_validators: logger.info({'msg': 'Eject forced to exit validators.', 'len': len(forced_validators)}) @@ -203,23 +203,16 @@ def is_reporting_allowed(self, blockstamp: ReferenceBlockStamp) -> bool: @lru_cache(maxsize=1) def _get_withdrawable_lido_validators_balance(self, on_epoch: EpochNumber, blockstamp: BlockStamp) -> Wei: lido_validators = self.w3.lido_validators.get_lido_validators(blockstamp=blockstamp) - - def get_total_withdrawable_balance(balance: Wei, validator: Validator) -> Wei: - if is_fully_withdrawable_validator(validator, on_epoch): - return Wei(balance + self._get_predicted_withdrawable_balance(validator)) - - return balance - - result = reduce( - get_total_withdrawable_balance, - lido_validators, - Wei(0), + return Wei( + sum( + self._get_predicted_withdrawable_balance(v) + for v in lido_validators + if is_fully_withdrawable_validator(v, on_epoch) + ) ) - return result - def _get_predicted_withdrawable_balance(self, validator: Validator) -> Wei: - return self.w3.to_wei(min(int(validator.balance), MAX_EFFECTIVE_BALANCE), 'gwei') + return self.w3.to_wei(min(int(validator.balance), MIN_ACTIVATION_BALANCE), 'gwei') @lru_cache(maxsize=1) def _get_total_el_balance(self, blockstamp: BlockStamp) -> Wei: @@ -285,20 +278,39 @@ def _get_latest_exit_epoch(self, blockstamp: ReferenceBlockStamp) -> tuple[Epoch @lru_cache(maxsize=1) def _get_sweep_delay_in_epochs(self, blockstamp: ReferenceBlockStamp) -> int: """Returns amount of epochs that will take to sweep all validators in chain.""" + + if self.get_consensus_version(blockstamp) in (1, 2): + return self._get_sweep_delay_in_epochs_pre_pectra(blockstamp) + return self._get_sweep_delay_in_epochs_post_pectra(blockstamp) + + def _get_sweep_delay_in_epochs_pre_pectra(self, blockstamp: ReferenceBlockStamp) -> int: chain_config = self.get_chain_config(blockstamp) - total_withdrawable_validators = self._get_total_withdrawable_validators(blockstamp) + + total_withdrawable_validators = len(self._get_withdrawable_validators(blockstamp)) + logger.info({'msg': 'Calculate total withdrawable validators.', 'value': total_withdrawable_validators}) full_sweep_in_epochs = total_withdrawable_validators / MAX_WITHDRAWALS_PER_PAYLOAD / chain_config.slots_per_epoch return int(full_sweep_in_epochs * self.AVG_EXPECTING_WITHDRAWALS_SWEEP_DURATION_MULTIPLIER) - def _get_total_withdrawable_validators(self, blockstamp: ReferenceBlockStamp) -> int: - total_withdrawable_validators = ilen(filter(lambda validator: ( - is_partially_withdrawable_validator(validator) or - is_fully_withdrawable_validator(validator, blockstamp.ref_epoch) - ), self.w3.cc.get_validators(blockstamp))) - + def _get_sweep_delay_in_epochs_post_pectra(self, blockstamp: ReferenceBlockStamp) -> int: + # This version is intended for use with Pectra, but we do not currently take into account pending withdrawal + # requests. It would require a large amount of pending withdrawal requests to significantly impact sweep + # duration. Roughly every 512 requests adds one more epoch to sweep duration in the current state. + # On the other side, to consider pending withdrawals it is necessary to fetch the beacon state and query the + # EIP-7002 predeployed contract, which adds complexity with limited improvement for predictions. + chain_config = self.get_chain_config(blockstamp) + total_withdrawable_validators = len(self._get_withdrawable_validators(blockstamp)) logger.info({'msg': 'Calculate total withdrawable validators.', 'value': total_withdrawable_validators}) - return total_withdrawable_validators + slots_to_sweep = math.ceil(total_withdrawable_validators / MAX_WITHDRAWALS_PER_PAYLOAD) + full_sweep_in_epochs = math.ceil(slots_to_sweep / chain_config.slots_per_epoch) + return math.ceil(full_sweep_in_epochs * self.AVG_EXPECTING_WITHDRAWALS_SWEEP_DURATION_MULTIPLIER) + + def _get_withdrawable_validators(self, blockstamp: ReferenceBlockStamp) -> list[Validator]: + return [ + v + for v in self.w3.cc.get_validators(blockstamp) + if is_partially_withdrawable_validator(v) or is_fully_withdrawable_validator(v, blockstamp.ref_epoch) + ] @lru_cache(maxsize=1) def _get_churn_limit(self, blockstamp: ReferenceBlockStamp) -> int: diff --git a/src/modules/submodules/consensus.py b/src/modules/submodules/consensus.py index 00ed876bb..29807c1ad 100644 --- a/src/modules/submodules/consensus.py +++ b/src/modules/submodules/consensus.py @@ -87,6 +87,10 @@ def _get_consensus_contract_members(self, blockstamp: BlockStamp): consensus_contract = self._get_consensus_contract(blockstamp) return consensus_contract.get_members(blockstamp.block_hash) + @lru_cache(maxsize=1) + def get_consensus_version(self, blockstamp: BlockStamp): + return self.report_contract.get_consensus_version(blockstamp.block_hash) + @lru_cache(maxsize=1) def get_chain_config(self, blockstamp: BlockStamp) -> ChainConfig: consensus_contract = self._get_consensus_contract(blockstamp) diff --git a/src/web3py/types.py b/src/web3py/types.py index c21e890eb..7f206cd46 100644 --- a/src/web3py/types.py +++ b/src/web3py/types.py @@ -1,14 +1,13 @@ from web3 import Web3 as _Web3 - from src.providers.ipfs import IPFSProvider from src.web3py.extensions import ( - LidoContracts, - TransactionUtils, + CSM, ConsensusClientModule, KeysAPIClientModule, + LidoContracts, LidoValidatorsProvider, - CSM + TransactionUtils, ) diff --git a/tests/factory/no_registry.py b/tests/factory/no_registry.py index 9b2264314..27ac9bc9b 100644 --- a/tests/factory/no_registry.py +++ b/tests/factory/no_registry.py @@ -6,11 +6,12 @@ from hexbytes import HexBytes from pydantic_factories import Use -from src.constants import FAR_FUTURE_EPOCH +from src.constants import EFFECTIVE_BALANCE_INCREMENT, FAR_FUTURE_EPOCH, MIN_ACTIVATION_BALANCE from src.providers.consensus.types import Validator, ValidatorState from src.providers.keys.types import LidoKey +from src.types import Gwei +from src.web3py.extensions.lido_validators import LidoValidator, NodeOperator, StakingModule from tests.factory.web3_factory import Web3Factory -from src.web3py.extensions.lido_validators import StakingModule, LidoValidator, NodeOperator faker = Faker() @@ -18,6 +19,7 @@ class ValidatorStateFactory(Web3Factory): __model__ = ValidatorState + withdrawal_credentials = "0x01" exit_epoch = FAR_FUTURE_EPOCH @classmethod @@ -40,7 +42,7 @@ def build_pending_deposit_vals(cls, **kwargs: Any): exit_epoch=str(FAR_FUTURE_EPOCH), effective_balance=str(0), ), - **kwargs + **kwargs, ) @@ -83,7 +85,7 @@ def build_pending_deposit_vals(cls, **kwargs: Any): exit_epoch=str(FAR_FUTURE_EPOCH), effective_balance=str(0), ), - **kwargs + **kwargs, ) @classmethod @@ -93,7 +95,7 @@ def build_not_active_vals(cls, epoch, **kwargs: Any): activation_epoch=str(faker.pyint(min_value=epoch + 1, max_value=FAR_FUTURE_EPOCH)), exit_epoch=str(FAR_FUTURE_EPOCH), ), - **kwargs + **kwargs, ) @classmethod @@ -103,7 +105,7 @@ def build_active_vals(cls, epoch, **kwargs: Any): activation_epoch=str(faker.pyint(min_value=0, max_value=epoch - 1)), exit_epoch=str(faker.pyint(min_value=epoch + 1, max_value=FAR_FUTURE_EPOCH)), ), - **kwargs + **kwargs, ) @classmethod @@ -113,7 +115,17 @@ def build_exit_vals(cls, epoch, **kwargs: Any): activation_epoch='0', exit_epoch=str(faker.pyint(min_value=1, max_value=epoch)), ), - **kwargs + **kwargs, + ) + + @classmethod + def build_with_balance(cls, balance: Gwei, **kwargs: Any): + return cls.build( + balance=balance, + validator=ValidatorStateFactory.build( + effective_balance=min(balance - balance % EFFECTIVE_BALANCE_INCREMENT, MIN_ACTIVATION_BALANCE), + ), + **kwargs, ) diff --git a/tests/modules/ejector/test_ejector.py b/tests/modules/ejector/test_ejector.py index cbc267cc9..6a5bffdbf 100644 --- a/tests/modules/ejector/test_ejector.py +++ b/tests/modules/ejector/test_ejector.py @@ -12,7 +12,7 @@ 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.types import BlockStamp, ReferenceBlockStamp +from src.types import BlockStamp, Gwei, ReferenceBlockStamp from src.utils import validator_state from src.web3py.extensions.contracts import LidoContracts from src.web3py.extensions.lido_validators import NodeOperatorId, StakingModuleId @@ -136,9 +136,8 @@ def test_no_validators_to_eject( result = ejector.get_validators_to_eject(ref_blockstamp) assert result == [], "Unexpected validators to eject" - ejector.w3.lido_contracts.validators_exit_bus_oracle.get_consensus_version = Mock(return_value=2) - with monkeypatch.context() as m: + ejector.get_consensus_version = Mock(return_value=2) val_iter = iter(SimpleIterator([])) val_iter.get_remaining_forced_validators = Mock(return_value=[]) m.setattr( @@ -160,14 +159,13 @@ def test_simple( ): ejector.get_chain_config = Mock(return_value=chain_config) ejector.w3.lido_contracts.withdrawal_queue_nft.unfinalized_steth = Mock(return_value=200) - ejector.w3.lido_contracts.validators_exit_bus_oracle.get_contract_version = Mock(return_value=1) ejector.prediction_service.get_rewards_per_epoch = Mock(return_value=1) - ejector._get_sweep_delay_in_epochs = Mock(return_value=ref_blockstamp.ref_epoch) + ejector._get_sweep_delay_in_epochs = Mock(return_value=0) ejector._get_total_el_balance = Mock(return_value=100) ejector.validators_state_service.get_recently_requested_but_not_exited_validators = Mock(return_value=[]) ejector._get_withdrawable_lido_validators_balance = Mock(return_value=0) - ejector._get_predicted_withdrawable_epoch = Mock(return_value=50) + ejector._get_predicted_withdrawable_epoch = Mock(return_value=ref_blockstamp.ref_epoch + 50) ejector._get_predicted_withdrawable_balance = Mock(return_value=50) validators = [ @@ -177,6 +175,7 @@ def test_simple( ] with monkeypatch.context() as m: + ejector.get_consensus_version = Mock(return_value=1) m.setattr( ejector_module.ExitOrderIterator, "__iter__", @@ -185,9 +184,8 @@ def test_simple( result = ejector.get_validators_to_eject(ref_blockstamp) assert result == [validators[0]], "Unexpected validators to eject" - ejector.w3.lido_contracts.validators_exit_bus_oracle.get_consensus_version = Mock(return_value=2) - with monkeypatch.context() as m: + ejector.get_consensus_version = Mock(return_value=2) val_iter = iter(SimpleIterator(validators[:2])) val_iter.get_remaining_forced_validators = Mock(return_value=validators[2:]) m.setattr( @@ -253,7 +251,7 @@ def test_get_total_active_validators(ejector: Ejector) -> None: @pytest.mark.unit @pytest.mark.usefixtures("consensus_client", "lido_validators") -def test_get_withdrawable_lido_validators( +def test_get_withdrawable_lido_validators_balance( ejector: Ejector, ref_blockstamp: ReferenceBlockStamp, monkeypatch: pytest.MonkeyPatch, @@ -298,7 +296,7 @@ def test_get_predicted_withdrawable_balance(ejector: Ejector) -> None: @pytest.mark.unit @pytest.mark.usefixtures("consensus_client") -def test_get_sweep_delay_in_epochs( +def test_get_sweep_delay_in_epochs_pre_electra( ejector: Ejector, ref_blockstamp: ReferenceBlockStamp, chain_config: ChainConfig, @@ -306,6 +304,7 @@ def test_get_sweep_delay_in_epochs( ) -> None: ejector.w3.cc.get_validators = Mock(return_value=LidoValidatorFactory.batch(1024)) ejector.get_chain_config = Mock(return_value=chain_config) + ejector.get_consensus_version = Mock(return_value=1) with monkeypatch.context() as m: m.setattr( @@ -340,6 +339,102 @@ def test_get_sweep_delay_in_epochs( assert result == 1, "Unexpected sweep delay in epochs" +@pytest.mark.unit +@pytest.mark.usefixtures("consensus_client") +def test_get_sweep_delay_in_epochs_post_electra( + ejector: Ejector, + chain_config: ChainConfig, + monkeypatch: pytest.MonkeyPatch, +) -> None: + ejector.get_chain_config = Mock(return_value=chain_config) + ejector.get_consensus_version = Mock(return_value=3) + ejector.w3.cc = Mock() + + ejector.w3.cc.get_validators = Mock(return_value=[]) + delay = ejector._get_sweep_delay_in_epochs(Mock(ref_epoch=0)) + assert delay == 0, "Unexpected sweep delay in epochs" + + ejector.w3.cc.get_validators = Mock(return_value=[LidoValidatorFactory.build_with_balance(Gwei(31 * 10**9))] * 3) + with monkeypatch.context() as m: + m.setattr( + ejector_module, + "is_fully_withdrawable_validator", + Mock(return_value=False), + ) + delay = ejector._get_sweep_delay_in_epochs(Mock(ref_epoch=0)) + assert delay == 0, "Unexpected sweep delay in epochs" + + ejector.w3.cc.get_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)), + ], + ) + with monkeypatch.context() as m: + m.setattr( + ejector_module, + "is_fully_withdrawable_validator", + Mock(return_value=False), + ) + delay = ejector._get_sweep_delay_in_epochs(Mock(ref_epoch=0)) + assert delay == 1, "Unexpected sweep delay in epochs" + + ejector.w3.cc.get_validators = Mock( + return_value=[ + LidoValidatorFactory.build_with_balance(Gwei(31 * 10**9)), + LidoValidatorFactory.build_with_balance(Gwei(31 * 10**9)), + LidoValidatorFactory.build_with_balance(Gwei(31 * 10**9)), + ], + ) + with monkeypatch.context() as m: + m.setattr( + ejector_module, + "is_fully_withdrawable_validator", + Mock(return_value=True), + ) + delay = ejector._get_sweep_delay_in_epochs(Mock(ref_epoch=0)) + assert delay == 1, "Unexpected sweep delay in epochs" + + ejector.w3.cc.get_validators = Mock( + return_value=[ + LidoValidatorFactory.build_with_balance(Gwei(32 * 10**9)), + LidoValidatorFactory.build_with_balance(Gwei(33 * 10**9)), + ] + * 513, + ) + with monkeypatch.context() as m: + m.setattr( + ejector_module, + "is_fully_withdrawable_validator", + Mock(return_value=True), + ) + delay = ejector._get_sweep_delay_in_epochs(Mock(ref_epoch=0)) + assert delay == 2, "Unexpected sweep delay in epochs" + + +@pytest.mark.unit +def test_get_withdrawable_validators(ejector: Ejector, monkeypatch) -> None: + ejector.w3.cc = Mock() + ejector.w3.cc.get_validators = Mock( + return_value=[ + LidoValidatorFactory.build_with_balance(Gwei(32 * 10**9), index=1), + LidoValidatorFactory.build_with_balance(Gwei(33 * 10**9), index=2), + LidoValidatorFactory.build_with_balance(Gwei(31 * 10**9), index=3), + ], + ) + + with monkeypatch.context() as m: + m.setattr( + ejector_module, + "is_fully_withdrawable_validator", + Mock(return_value=False), + ) + withdrawable = ejector._get_withdrawable_validators(Mock()) + + assert [v.index for v in withdrawable] == [2] + + @pytest.mark.usefixtures("contracts") def test_get_total_balance(ejector: Ejector, blockstamp: BlockStamp) -> None: ejector.w3.lido_contracts.get_withdrawal_balance = Mock(return_value=3)