diff --git a/Dockerfile b/Dockerfile index 63a73a7fc..b0fde007e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM python:3.12.4-slim as base RUN apt-get update && apt-get install -y --no-install-recommends -qq \ libffi-dev=3.4.4-1 \ g++=4:12.2.0-3 \ - curl=7.88.1-10+deb12u7 \ + curl=7.88.1-10+deb12u8 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* diff --git a/pyproject.toml b/pyproject.toml index 201c3743d..ce14cc55d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "lido-oracle" -version = "4.0.3" +version = "4.1.0" description = "Oracle daemon for Lido decentralized staking service. Collects and reports Ethereum 2.0 beacon chain states (the number of visible validators and their summarized balances) to the Lido dApp contract running on Ethereum 1.0 side." authors = [ "Dmitry Chernukhin", diff --git a/src/constants.py b/src/constants.py index e3dc6b67d..f4baf41d3 100644 --- a/src/constants.py +++ b/src/constants.py @@ -1,11 +1,13 @@ # https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#misc +from src.types import Gwei + 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 @@ -15,12 +17,18 @@ 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 # 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) + # Local constants GWEI_TO_WEI = 10 ** 9 SHARE_RATE_PRECISION_E27 = 10 ** 27 diff --git a/src/modules/csm/csm.py b/src/modules/csm/csm.py index 190199dcc..ff7e52d43 100644 --- a/src/modules/csm/csm.py +++ b/src/modules/csm/csm.py @@ -308,10 +308,13 @@ def stuck_operators(self, blockstamp: ReferenceBlockStamp) -> set[NodeOperatorId blockstamp.slot_number, ) ) - digests = self.w3.lido_validators.get_lido_node_operators_by_modules(l_blockstamp).get(self.module_id) - if digests is None: - raise InconsistentData(f"No Node Operators digests found for {self.module_id=}") - stuck.update(no.id for no in digests if no.stuck_validators_count > 0) + + nos_by_module = self.w3.lido_validators.get_lido_node_operators_by_modules(l_blockstamp) + if self.module_id in nos_by_module: + stuck.update(no.id for no in nos_by_module[self.module_id] if no.stuck_validators_count > 0) + else: + logger.warning("No CSM digest at blockstamp=%s, module was not added yet?", l_blockstamp) + stuck.update( self.w3.csm.get_operators_with_stucks_in_range( l_blockstamp.block_hash, diff --git a/src/modules/submodules/consensus.py b/src/modules/submodules/consensus.py index 61a517173..00ed876bb 100644 --- a/src/modules/submodules/consensus.py +++ b/src/modules/submodules/consensus.py @@ -27,10 +27,8 @@ from src.utils.cache import global_lru_cache as lru_cache from src.web3py.types import Web3 - logger = logging.getLogger(__name__) - # Initial epoch is in the future. Revert signature: '0xcd0883ea' InitialEpochIsYetToArriveRevert = Web3.keccak(text="InitialEpochIsYetToArrive()")[:4].hex() @@ -135,25 +133,29 @@ def get_member_info(self, blockstamp: BlockStamp) -> MemberInfo: current_frame_consensus_report = current_frame_member_report = ZERO_HASH if variables.ACCOUNT: - ( - # Current frame's reference slot. - _, # current_frame_ref_slot - # Consensus report for the current frame, if any. Zero bytes otherwise. - current_frame_consensus_report, - # Whether the provided address is a member of the oracle committee. - is_member, - # Whether the oracle committee member is in the fast line members subset of the current reporting frame. - is_fast_lane, - # Whether the oracle committee member is allowed to submit a report at the moment of the call. - _, # can_report - # The last reference slot for which the member submitted a report. - last_member_report_ref_slot, - # The hash reported by the member for the current frame, if any. - current_frame_member_report, - ) = consensus_contract.get_consensus_state_for_member( - variables.ACCOUNT.address, - blockstamp.block_hash, - ) + try: + ( + # Current frame's reference slot. + _, # current_frame_ref_slot + # Consensus report for the current frame, if any. Zero bytes otherwise. + current_frame_consensus_report, + # Whether the provided address is a member of the oracle committee. + is_member, + # Whether the oracle committee member is in the fast line members subset of the current reporting frame. + is_fast_lane, + # Whether the oracle committee member is allowed to submit a report at the moment of the call. + _, # can_report + # The last reference slot for which the member submitted a report. + last_member_report_ref_slot, + # The hash reported by the member for the current frame, if any. + current_frame_member_report, + ) = consensus_contract.get_consensus_state_for_member( + variables.ACCOUNT.address, + blockstamp.block_hash, + ) + except ContractCustomError as revert: + if revert.data != InitialEpochIsYetToArriveRevert: + raise revert is_submit_member = self._is_submit_member(blockstamp) diff --git a/src/utils/validator_state.py b/src/utils/validator_state.py index 56449f290..c96fa47d7 100644 --- a/src/utils/validator_state.py +++ b/src/utils/validator_state.py @@ -1,7 +1,6 @@ from typing import Sequence from src.constants import ( - MAX_EFFECTIVE_BALANCE, ETH1_ADDRESS_WITHDRAWAL_PREFIX, SHARD_COMMITTEE_PERIOD, FAR_FUTURE_EPOCH, @@ -9,6 +8,9 @@ MAX_SEED_LOOKAHEAD, MIN_PER_EPOCH_CHURN_LIMIT, CHURN_LIMIT_QUOTIENT, + COMPOUNDING_WITHDRAWAL_PREFIX, + MAX_EFFECTIVE_BALANCE_ELECTRA, + MIN_ACTIVATION_BALANCE, ) from src.providers.consensus.types import Validator from src.types import EpochNumber, Gwei @@ -41,15 +43,24 @@ def is_partially_withdrawable_validator(validator: Validator) -> bool: Check if `validator` is partially withdrawable https://github.com/ethereum/consensus-specs/blob/dev/specs/capella/beacon-chain.md#is_partially_withdrawable_validator """ - has_max_effective_balance = int(validator.validator.effective_balance) == MAX_EFFECTIVE_BALANCE - has_excess_balance = int(validator.balance) > MAX_EFFECTIVE_BALANCE + max_effective_balance = get_max_effective_balance(validator) + has_max_effective_balance = int(validator.validator.effective_balance) == max_effective_balance + has_excess_balance = int(validator.balance) > max_effective_balance return ( - has_eth1_withdrawal_credential(validator) + has_execution_withdrawal_credential(validator) and has_max_effective_balance and has_excess_balance ) +def has_compounding_withdrawal_credential(validator: Validator) -> bool: + """ + Check if ``validator`` has an 0x02 prefixed "compounding" withdrawal credential. + https://github.com/ethereum/consensus-specs/blob/dev/specs/electra/beacon-chain.md#new-has_compounding_withdrawal_credential + """ + return validator.validator.withdrawal_credentials[:4] == COMPOUNDING_WITHDRAWAL_PREFIX + + def has_eth1_withdrawal_credential(validator: Validator) -> bool: """ Check if ``validator`` has an 0x01 prefixed "eth1" withdrawal credential. @@ -58,13 +69,21 @@ def has_eth1_withdrawal_credential(validator: Validator) -> bool: return validator.validator.withdrawal_credentials[:4] == ETH1_ADDRESS_WITHDRAWAL_PREFIX +def has_execution_withdrawal_credential(validator: Validator) -> bool: + """ + Check if ``validator`` has a 0x01 or 0x02 prefixed withdrawal credential. + https://github.com/ethereum/consensus-specs/blob/dev/specs/electra/beacon-chain.md#new-has_execution_withdrawal_credential + """ + return has_compounding_withdrawal_credential(validator) or has_eth1_withdrawal_credential(validator) + + def is_fully_withdrawable_validator(validator: Validator, epoch: EpochNumber) -> bool: """ Check if `validator` is fully withdrawable - https://github.com/ethereum/consensus-specs/blob/dev/specs/capella/beacon-chain.md#is_fully_withdrawable_validator + https://github.com/ethereum/consensus-specs/blob/dev/specs/electra/beacon-chain.md#modified-is_fully_withdrawable_validator """ return ( - has_eth1_withdrawal_credential(validator) + has_execution_withdrawal_credential(validator) and EpochNumber(int(validator.validator.withdrawable_epoch)) <= epoch and Gwei(int(validator.balance)) > Gwei(0) ) @@ -114,3 +133,13 @@ def compute_activation_exit_epoch(ref_epoch: EpochNumber): def compute_exit_churn_limit(active_validators_count: int): return max(MIN_PER_EPOCH_CHURN_LIMIT, active_validators_count // CHURN_LIMIT_QUOTIENT) + + +def get_max_effective_balance(validator: Validator) -> Gwei: + """ + Get max effective balance for ``validator``. + https://github.com/ethereum/consensus-specs/blob/dev/specs/electra/beacon-chain.md#new-get_max_effective_balance + """ + if has_compounding_withdrawal_credential(validator): + return MAX_EFFECTIVE_BALANCE_ELECTRA + return MIN_ACTIVATION_BALANCE diff --git a/tests/modules/csm/test_csm_module.py b/tests/modules/csm/test_csm_module.py index a8ab72fd2..7d48e6e70 100644 --- a/tests/modules/csm/test_csm_module.py +++ b/tests/modules/csm/test_csm_module.py @@ -16,7 +16,7 @@ from src.providers.ipfs import CIDv0, CID from src.types import EpochNumber, NodeOperatorId, SlotNumber, StakingModuleId, ValidatorIndex from src.web3py.extensions.csm import CSM -from tests.factory.blockstamp import ReferenceBlockStampFactory +from tests.factory.blockstamp import BlockStampFactory, ReferenceBlockStampFactory from tests.factory.configs import ChainConfigFactory, FrameConfigFactory @@ -83,6 +83,55 @@ def test_stuck_operators(module: CSOracle, csm: CSM): assert stuck == {NodeOperatorId(2), NodeOperatorId(4), NodeOperatorId(5), NodeOperatorId(6), NodeOperatorId(1337)} +def test_stuck_operators_left_border_before_enact(module: CSOracle, csm: CSM, caplog: pytest.LogCaptureFixture): + module.module = Mock() # type: ignore + module.module_id = StakingModuleId(3) + module.w3.cc = Mock() + module.w3.lido_validators = Mock() + module.w3.lido_contracts = Mock() + module.w3.lido_validators.get_lido_node_operators_by_modules = Mock( + return_value={ + 1: { + type('NodeOperator', (object,), {'id': 0, 'stuck_validators_count': 0})(), + type('NodeOperator', (object,), {'id': 1, 'stuck_validators_count': 0})(), + type('NodeOperator', (object,), {'id': 2, 'stuck_validators_count': 1})(), + type('NodeOperator', (object,), {'id': 3, 'stuck_validators_count': 0})(), + type('NodeOperator', (object,), {'id': 4, 'stuck_validators_count': 100500})(), + type('NodeOperator', (object,), {'id': 5, 'stuck_validators_count': 100})(), + type('NodeOperator', (object,), {'id': 6, 'stuck_validators_count': 0})(), + }, + 2: {}, + } + ) + + module.w3.csm.get_operators_with_stucks_in_range = Mock( + return_value=[ + NodeOperatorId(2), + NodeOperatorId(4), + NodeOperatorId(6), + ] + ) + + module.current_frame_range = Mock(return_value=(69, 100)) + module.converter = Mock() + module.converter.get_epoch_first_slot = Mock(return_value=lambda epoch: epoch * 32) + + l_blockstamp = BlockStampFactory.build() + blockstamp = BlockStampFactory.build() + + with patch('src.modules.csm.csm.build_blockstamp', return_value=l_blockstamp): + with patch('src.modules.csm.csm.get_next_non_missed_slot', return_value=Mock()): + stuck = module.stuck_operators(blockstamp=blockstamp) + + assert stuck == { + NodeOperatorId(2), + NodeOperatorId(4), + NodeOperatorId(6), + } + + assert caplog.messages[0].startswith("No CSM digest at blockstamp") + + def test_calculate_distribution(module: CSOracle, csm: CSM): csm.fee_distributor.shares_to_distribute = Mock(return_value=10_000) csm.oracle.perf_leeway_bp = Mock(return_value=500) diff --git a/tests/modules/submodules/consensus/test_consensus.py b/tests/modules/submodules/consensus/test_consensus.py index 74cc0b816..6cf948f4d 100644 --- a/tests/modules/submodules/consensus/test_consensus.py +++ b/tests/modules/submodules/consensus/test_consensus.py @@ -143,19 +143,41 @@ def test_get_frame_custom_exception(web3, consensus): consensus.get_initial_or_current_frame(bs) +@pytest.fixture() +def use_account(request): + if request.param: + request.getfixturevalue("set_submit_account") + else: + request.getfixturevalue("set_no_account") + + @pytest.mark.unit -def test_first_frame_is_not_yet_started(web3, consensus, caplog, set_no_account): +@pytest.mark.parametrize( + "use_account", [True, False], ids=['with_account', "without_account"], indirect=["use_account"] +) +def test_first_frame_is_not_yet_started(web3, consensus, caplog, use_account): bs = ReferenceBlockStampFactory.build() - - consensus_contract = Mock(get_current_frame=Mock(side_effect=ContractCustomError('0xcd0883ea', '0xcd0883ea'))) + err = ContractCustomError('0xcd0883ea', '0xcd0883ea') + consensus_contract = Mock( + get_current_frame=Mock(side_effect=err), get_consensus_state_for_member=Mock(side_effect=err) + ) consensus._get_consensus_contract = Mock(return_value=consensus_contract) + consensus._is_submit_member = Mock(return_value=True) consensus.get_frame_config = Mock(return_value=FrameConfigFactory.build(initial_epoch=5, epochs_per_frame=10)) consensus.get_chain_config = Mock(return_value=ChainConfigFactory.build()) first_frame = consensus.get_initial_or_current_frame(bs) + member_info = consensus.get_member_info(bs) assert first_frame.ref_slot == 5 * 32 - 1 assert first_frame.report_processing_deadline_slot == (5 + 10) * 32 - 1 + assert member_info.is_submit_member + assert member_info.is_report_member + assert member_info.is_fast_lane + assert member_info.current_frame_consensus_report == ZERO_HASH + assert member_info.current_frame_member_report == ZERO_HASH + assert member_info.current_frame_ref_slot == first_frame.ref_slot + assert member_info.deadline_slot == first_frame.report_processing_deadline_slot @pytest.mark.unit diff --git a/tests/utils/test_validator_state_utils.py b/tests/utils/test_validator_state_utils.py index 677763b3c..8b52d270b 100644 --- a/tests/utils/test_validator_state_utils.py +++ b/tests/utils/test_validator_state_utils.py @@ -1,7 +1,12 @@ from pydantic.class_validators import validator import pytest -from src.constants import FAR_FUTURE_EPOCH, EFFECTIVE_BALANCE_INCREMENT +from src.constants import ( + FAR_FUTURE_EPOCH, + EFFECTIVE_BALANCE_INCREMENT, + MAX_EFFECTIVE_BALANCE_ELECTRA, + MIN_ACTIVATION_BALANCE, +) from src.providers.consensus.types import Validator, ValidatorStatus, ValidatorState from src.types import EpochNumber, Gwei from src.utils.validator_state import ( @@ -16,6 +21,9 @@ is_exited_validator, is_active_validator, compute_activation_exit_epoch, + has_compounding_withdrawal_credential, + has_execution_withdrawal_credential, + get_max_effective_balance, ) from tests.factory.no_registry import ValidatorFactory from tests.modules.accounting.bunker.test_bunker_abnormal_cl_rebase import simple_validators @@ -144,6 +152,24 @@ def test_is_on_exit(exit_epoch, expected): assert actual == expected +@pytest.mark.unit +@pytest.mark.parametrize( + "withdrawal_credentials, expected", + [ + ('0x02ba', True), + ('02ab', False), + ('0x00ba', False), + ('00ba', False), + ], +) +def test_has_compounding_withdrawal_credential(withdrawal_credentials, expected): + validator = ValidatorFactory.build() + validator.validator.withdrawal_credentials = withdrawal_credentials + + actual = has_compounding_withdrawal_credential(validator) + assert actual == expected + + @pytest.mark.unit @pytest.mark.parametrize( "withdrawal_credentials, expected", @@ -164,18 +190,44 @@ def test_has_eth1_withdrawal_credential(withdrawal_credentials, expected): @pytest.mark.unit @pytest.mark.parametrize( - "withdrawable_epoch, balance, epoch, expected", + "wc, expected", [ - (176720, 32 * (10**10), 176722, True), - (176722, 32 * (10**10), 176722, True), - (176723, 32 * (10**10), 176722, False), - (176722, 0, 176722, False), + ('0x01ba', True), + ('01ab', False), + ('0x00ba', False), + ('00ba', False), + ('0x02ba', True), + ('02ab', False), + ('0x00ba', False), + ('00ba', False), ], ) -def test_is_fully_withdrawable_validator(withdrawable_epoch, balance, epoch, expected): +def test_has_execution_withdrawal_credential(wc, expected): + validator = ValidatorFactory.build() + validator.validator.withdrawal_credentials = wc + + actual = has_execution_withdrawal_credential(validator) + assert actual == expected + + +@pytest.mark.unit +@pytest.mark.parametrize( + "withdrawable_epoch, wc, balance, epoch, expected", + [ + (176720, '0x01ba', 32 * (10**10), 176722, True), + (176722, '0x01ba', 32 * (10**10), 176722, True), + (176723, '0x01ba', 32 * (10**10), 176722, False), + (176722, '0x01ba', 0, 176722, False), + (176720, '0x02ba', 32 * (10**10), 176722, True), + (176722, '0x02ba', 32 * (10**10), 176722, True), + (176723, '0x02ba', 32 * (10**10), 176722, False), + (176722, '0x02ba', 0, 176722, False), + ], +) +def test_is_fully_withdrawable_validator(withdrawable_epoch, wc, balance, epoch, expected): validator = ValidatorFactory.build() validator.validator.withdrawable_epoch = withdrawable_epoch - validator.validator.withdrawal_credentials = '0x01ba' + validator.validator.withdrawal_credentials = wc validator.balance = balance actual = is_fully_withdrawable_validator(validator, EpochNumber(epoch)) @@ -187,10 +239,13 @@ def test_is_fully_withdrawable_validator(withdrawable_epoch, balance, epoch, exp "effective_balance, add_balance, withdrawal_credentials, expected", [ (32 * 10**9, 1, '0x01ba', True), + (MAX_EFFECTIVE_BALANCE_ELECTRA, 1, '0x02ba', True), (32 * 10**9, 1, '0x0', False), (32 * 10**8, 0, '0x01ba', False), + (MAX_EFFECTIVE_BALANCE_ELECTRA, 0, '0x02ba', False), (32 * 10**9, 0, '0x', False), (0, 0, '0x01ba', False), + (0, 0, '0x02ba', False), ], ) def test_is_partially_withdrawable(effective_balance, add_balance, withdrawal_credentials, expected): @@ -221,6 +276,22 @@ def test_is_validator_eligible_to_exit(activation_epoch, exit_epoch, epoch, expe assert actual == expected +@pytest.mark.unit +@pytest.mark.parametrize( + "wc, expected", + [ + ('0x01ba', MIN_ACTIVATION_BALANCE), + ('0x02ba', MAX_EFFECTIVE_BALANCE_ELECTRA), + ('0x0', MIN_ACTIVATION_BALANCE), + ], +) +def test_max_effective_balance(wc, expected): + validator = ValidatorFactory.build() + validator.validator.withdrawal_credentials = wc + result = get_max_effective_balance(validator) + assert result == expected + + class TestCalculateTotalEffectiveBalance: @pytest.fixture def validators(self):