diff --git a/README.md b/README.md index 466f8afdc..a6681e66d 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,8 @@ [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) [![Tests](https://github.com/lidofinance/lido-oracle/workflows/Tests/badge.svg?branch=daemon_v2)](https://github.com/lidofinance/lido-oracle/actions) -Oracle daemon for Lido decentralized staking service: Monitoring the state of the protocol across both layers and submitting regular update reports to the Lido smart contracts. +Oracle daemon for Lido decentralized staking service: Monitoring the state of the protocol across both layers and submitting regular update +reports to the Lido smart contracts. ## How it works @@ -15,7 +16,8 @@ There are 3 modules in the oracle: ### Accounting module -Accounting module updates the protocol TVL, distributes node-operator rewards, updates information about the number of exited and stuck validators and processes user withdrawal requests. +Accounting module updates the protocol TVL, distributes node-operator rewards, updates information about the number of exited and stuck +validators and processes user withdrawal requests. Also Accounting module makes decision to turn on/off the bunker. **Flow** @@ -37,7 +39,8 @@ The frame includes these stages: ### Ejector module -Ejector module requests Lido validators to eject via events in Execution Layer when the protocol requires additional funds to process user withdrawals. +Ejector module requests Lido validators to eject via events in Execution Layer when the protocol requires additional funds to process user +withdrawals. **Flow** @@ -58,6 +61,7 @@ Only Oracle: - Memory - 8 GB Oracle + KAPI: + - vCPU - 4 - Memory - 16 GB @@ -65,7 +69,8 @@ Oracle + KAPI: ### Execution Client Node -To prepare the report, Oracle fetches up to 10 days old events, makes historical requests for balance data and makes simulated reports on historical blocks. This requires an [archive](https://ethereum.org/en/developers/docs/nodes-and-clients/#archive-node) execution node. +To prepare the report, Oracle fetches up to 10 days old events, makes historical requests for balance data and makes simulated reports on +historical blocks. This requires an [archive](https://ethereum.org/en/developers/docs/nodes-and-clients/#archive-node) execution node. Oracle needs two weeks of archived data. | Client | Tested | Notes | @@ -77,19 +82,21 @@ Oracle needs two weeks of archived data. ### Consensus Client Node -Also, to calculate some metrics for bunker mode Oracle needs [archive](https://ethereum.org/en/developers/docs/nodes-and-clients/#archive-node) consensus node. +Also, to calculate some metrics for bunker mode Oracle +needs [archive](https://ethereum.org/en/developers/docs/nodes-and-clients/#archive-node) consensus node. -| Client | Tested | Notes | -|---------------------------------------------------|:------:|-------------------------------------------------------------------------------------------------------------------------------------------------| -| [Lighthouse](https://lighthouse.sigmaprime.io/) | 🟢 | Use `--reconstruct-historic-states` param | -| [Lodestar](https://lodestar.chainsafe.io) | 🔴 | Not tested yet | -| [Nimbus](https://nimbus.team) | 🔴 | Not tested yet | -| [Prysm](https://github.com/prysmaticlabs/prysm) | 🟢 | Use
`--grpc-max-msg-size=104857600`
`--enable-historical-state-representation=true`
`--slots-per-archive-point=1024`
params | -| [Teku](https://docs.teku.consensys.net) | 🟢 | Use
`--data-storage-mode=archive`
`--data-storage-archive-frequency=1024`
`--reconstruct-historic-states=true`
params | +| Client | Tested | Notes | +|-------------------------------------------------|:------:|-------------------------------------------------------------------------------------------------------------------------------------------------| +| [Lighthouse](https://lighthouse.sigmaprime.io/) | 🟢 | Use `--reconstruct-historic-states` param | +| [Lodestar](https://lodestar.chainsafe.io) | 🔴 | Not tested yet | +| [Nimbus](https://nimbus.team) | 🔴 | Not tested yet | +| [Prysm](https://github.com/prysmaticlabs/prysm) | 🟢 | Use
`--grpc-max-msg-size=104857600`
`--enable-historical-state-representation=true`
`--slots-per-archive-point=1024`
params | +| [Teku](https://docs.teku.consensys.net) | 🟢 | Use
`--data-storage-mode=archive`
`--data-storage-archive-frequency=1024`
`--reconstruct-historic-states=true`
params | ### Keys API Service -This is a separate service that uses Consensus and Execution Clients to fetch all lido keys. It stores the latest state of lido keys in database. +This is a separate service that uses Consensus and Execution Clients to fetch all lido keys. It stores the latest state of lido keys in +database. [Lido Keys API repository.](https://github.com/lidofinance/lido-keys-api) @@ -102,9 +109,11 @@ Pull the image using the following command: docker pull lidofinance/oracle:{tag} ``` -Where `{tag}` is a version of the image. You can find the latest version in the [releases](https://github.com/lidofinance/lido-oracle/releases) +Where `{tag}` is a version of the image. You can find the latest version in +the [releases](https://github.com/lidofinance/lido-oracle/releases) **OR**\ -You can build it locally using the following command (make sure build it from latest [release](https://github.com/lidofinance/lido-oracle/releases)): +You can build it locally using the following command (make sure build it from +latest [release](https://github.com/lidofinance/lido-oracle/releases)): ```bash docker build -t lidofinance/oracle . @@ -124,16 +133,17 @@ Full variables list could be found [here](https://github.com/lidofinance/lido-or and your environment is ready to run the oracle. ## Run the oracle + 1. By default, the oracle runs in *dry mode*. It means that it will not send any transactions to the Ethereum network. - To run Oracle in *production mode*, set `MEMBER_PRIV_KEY` or `MEMBER_PRIV_KEY_FILE` environment variable: + To run Oracle in *production mode*, set `MEMBER_PRIV_KEY` or `MEMBER_PRIV_KEY_FILE` environment variable: ``` MEMBER_PRIV_KEY={value} ``` - Where `{value}` is a private key of the Oracle member account or: + Where `{value}` is a private key of the Oracle member account or: ``` MEMBER_PRIV_KEY_FILE={path} ``` - Where `{path}` is a path to the private key of the Oracle member account. + Where `{path}` is a path to the private key of the Oracle member account. 2. Run the container using the following command: ```bash @@ -203,6 +213,7 @@ In manual mode all sleeps are disabled and `ALLOW_REPORTING_IN_BUNKER_MODE` is T | `CACHE_PATH` | Directory to store cache for CSM module | False | `.` | ### Mainnet variables + > LIDO_LOCATOR_ADDRESS=0xC1d0b3DE6792Bf6b4b37EccdcC24e45978Cfd2Eb > ALLOW_REPORTING_IN_BUNKER_MODE=False @@ -281,9 +292,12 @@ Special metrics for ejector oracle: Special metrics for CSM oracle: -| Metric name | Description | Labels | -|-----------------------------------|---------------------------------------------|--------| -| TBD | TBD | | +| Metric name | Description | Labels | +|---------------------------------|----------------------------------------|--------| +| csm_current_frame_range_l_epoch | Left epoch of the current frame range | | +| csm_current_frame_range_r_epoch | Right epoch of the current frame range | | +| csm_unprocessed_epochs_count | Unprocessed epochs count | | +| csm_min_unprocessed_epoch | Minimum unprocessed epoch | | # Development diff --git a/docker-compose.devnet.yml b/docker-compose.devnet.yml new file mode 100644 index 000000000..9bc432db2 --- /dev/null +++ b/docker-compose.devnet.yml @@ -0,0 +1,169 @@ +version: '3.9' + +networks: + devnet: + name: ${DOCKER_NETWORK_NAME} + external: true + +services: + + lido-oracle-accounting: + image: lidofinance/oracle:4.1.1 + restart: unless-stopped + depends_on: + - keys_api + networks: + - devnet + deploy: + resources: + limits: + cpus: "1" + memory: 4G + labels: + - "prometheus-job=lido-oracle-accounting" + - "prometheus-endpoint=/" + - "prometheus-port=9000" + environment: + - "PROMETHEUS_PORT=9000" + - "EXECUTION_CLIENT_URI=${EXECUTION_CLIENT_URI}" + - "CONSENSUS_CLIENT_URI=${CONSENSUS_CLIENT_URI}" + - "KEYS_API_URI=http://keys_api:9030" + - "MEMBER_PRIV_KEY=${MEMBER_PRIV_KEY_1}" + - "LIDO_LOCATOR_ADDRESS=${LIDO_LOCATOR_ADDRESS}" + - "ALLOW_REPORTING_IN_BUNKER_MODE=false" + command: accounting + + lido-oracle-ejector: + image: lidofinance/oracle:4.1.1 + restart: unless-stopped + depends_on: + - keys_api + networks: + - devnet + deploy: + resources: + limits: + cpus: "1" + memory: 4G + labels: + - "prometheus-job=lido-oracle-ejector" + - "prometheus-endpoint=/" + - "prometheus-port=9000" + environment: + - "PROMETHEUS_PORT=9000" + - "EXECUTION_CLIENT_URI=${EXECUTION_CLIENT_URI}" + - "CONSENSUS_CLIENT_URI=${CONSENSUS_CLIENT_URI}" + - "KEYS_API_URI=http://keys_api:9030" + - "MEMBER_PRIV_KEY=${MEMBER_PRIV_KEY_1}" + - "LIDO_LOCATOR_ADDRESS=${LIDO_LOCATOR_ADDRESS}" + command: ejector + + lido-oracle-csm: + image: lidofinance/oracle:4.1.1 + restart: unless-stopped + depends_on: + - keys_api + networks: + - devnet + deploy: + resources: + limits: + cpus: "2" + memory: 4G + labels: + - "prometheus-job=lido-oracle-ejector" + - "prometheus-endpoint=/" + - "prometheus-port=9000" + environment: + - "PROMETHEUS_PORT=9000" + - "EXECUTION_CLIENT_URI=${EXECUTION_CLIENT_URI}" + - "CONSENSUS_CLIENT_URI=${CONSENSUS_CLIENT_URI}" + - "KEYS_API_URI=http://keys_api:9030" + - "MEMBER_PRIV_KEY=${MEMBER_PRIV_KEY_1}" + - "LIDO_LOCATOR_ADDRESS=${LIDO_LOCATOR_ADDRESS}" + - "CSM_MODULE_ADDRESS=${CSM_MODULE_ADDRESS}" + - "PINATA_JWT=${PINATA_JWT}" + - "GW3_ACCESS_KEY=${GW3_ACCESS_KEY}" + - "GW3_SECRET_KEY=${GW3_SECRET_KEY}" + command: csm + + + lido-oracle-accounting-v5: + build: ./ + restart: unless-stopped + depends_on: + - keys_api + networks: + - devnet + deploy: + resources: + limits: + cpus: "1" + memory: 4G + labels: + - "prometheus-job=lido-oracle-accounting" + - "prometheus-endpoint=/" + - "prometheus-port=9000" + environment: + - "PROMETHEUS_PORT=9000" + - "EXECUTION_CLIENT_URI=${EXECUTION_CLIENT_URI}" + - "CONSENSUS_CLIENT_URI=${CONSENSUS_CLIENT_URI}" + - "KEYS_API_URI=http://keys_api:9030" + - "MEMBER_PRIV_KEY=${MEMBER_PRIV_KEY_2}" + - "LIDO_LOCATOR_ADDRESS=${LIDO_LOCATOR_ADDRESS}" + - "ALLOW_REPORTING_IN_BUNKER_MODE=false" + command: accounting + + lido-oracle-ejector-v5: + build: ./ + restart: unless-stopped + depends_on: + - keys_api + networks: + - devnet + deploy: + resources: + limits: + cpus: "1" + memory: 4G + labels: + - "prometheus-job=lido-oracle-ejector" + - "prometheus-endpoint=/" + - "prometheus-port=9000" + environment: + - "PROMETHEUS_PORT=9000" + - "EXECUTION_CLIENT_URI=${EXECUTION_CLIENT_URI}" + - "CONSENSUS_CLIENT_URI=${CONSENSUS_CLIENT_URI}" + - "KEYS_API_URI=http://keys_api:9030" + - "MEMBER_PRIV_KEY=${MEMBER_PRIV_KEY_2}" + - "LIDO_LOCATOR_ADDRESS=${LIDO_LOCATOR_ADDRESS}" + command: ejector + + lido-oracle-csm-v5: + build: ./ + restart: unless-stopped + depends_on: + - keys_api + networks: + - devnet + deploy: + resources: + limits: + cpus: "2" + memory: 4G + labels: + - "prometheus-job=lido-oracle-ejector" + - "prometheus-endpoint=/" + - "prometheus-port=9000" + environment: + - "PROMETHEUS_PORT=9000" + - "EXECUTION_CLIENT_URI=${EXECUTION_CLIENT_URI}" + - "CONSENSUS_CLIENT_URI=${CONSENSUS_CLIENT_URI}" + - "KEYS_API_URI=http://keys_api:9030" + - "MEMBER_PRIV_KEY=${MEMBER_PRIV_KEY_2}" + - "LIDO_LOCATOR_ADDRESS=${LIDO_LOCATOR_ADDRESS}" + - "CSM_MODULE_ADDRESS=${CSM_MODULE_ADDRESS}" + - "PINATA_JWT=${PINATA_JWT}" + - "GW3_ACCESS_KEY=${GW3_ACCESS_KEY}" + - "GW3_SECRET_KEY=${GW3_SECRET_KEY}" + command: csm diff --git a/fixtures/tests/modules/accounting/test_withdrawal_integration.py/test_happy_path.json b/fixtures/tests/modules/accounting/test_withdrawal_integration.py/test_happy_path.json index 2f8b7f796..cd66099db 100644 --- a/fixtures/tests/modules/accounting/test_withdrawal_integration.py/test_happy_path.json +++ b/fixtures/tests/modules/accounting/test_withdrawal_integration.py/test_happy_path.json @@ -6,7 +6,7 @@ "to": "0x8D49f1b4AF30598679D4D37Be4B094da1b459b82", "data": "0xa3a3fd5d" }, - "0x8ee61584b9d3e010c55f1fa77a803051f5f783385ec75b4e3fc71e199a86184d" + "latest" ], "response": { "jsonrpc": "2.0", @@ -195,7 +195,7 @@ "to": "0x4c1F6cA213abdbc19b27f2562d7b1A645A019bD9", "data": "0x29fd065d" }, - "0x8ee61584b9d3e010c55f1fa77a803051f5f783385ec75b4e3fc71e199a86184d" + "latest" ], "response": { "jsonrpc": "2.0", @@ -218,4 +218,4 @@ "result": "0x0000000000000000000000000000000000000000000000037d3047cdfd698705000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000030af00000000000000000000000000000000000000000000000000000000000030c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002" } } -] \ No newline at end of file +] diff --git a/src/constants.py b/src/constants.py index e3dc6b67d..d9a78874b 100644 --- a/src/constants.py +++ b/src/constants.py @@ -1,5 +1,7 @@ +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 SHARD_COMMITTEE_PERIOD = 256 @@ -10,22 +12,31 @@ 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 = 32 * 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) + # 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 34028ce03..b6b2a7c7d 100644 --- a/src/main.py +++ b/src/main.py @@ -24,14 +24,13 @@ KeysAPIClientModule, LidoValidatorsProvider, FallbackProviderModule, - LazyCSM + LazyCSM, ) from src.web3py.middleware import metrics_collector from src.web3py.types import Web3 from src.web3py.contract_tweak import tweak_w3_contracts - logger = logging.getLogger(__name__) @@ -42,22 +41,10 @@ def main(module_name: OracleModule): 'variables': { **build_info, 'module': module_name, - 'ACCOUNT': variables.ACCOUNT.address if variables.ACCOUNT else 'Dry', - 'LIDO_LOCATOR_ADDRESS': variables.LIDO_LOCATOR_ADDRESS, - 'CSM_MODULE_ADDRESS': variables.CSM_MODULE_ADDRESS, - 'FINALIZATION_BATCH_MAX_REQUEST_COUNT': variables.FINALIZATION_BATCH_MAX_REQUEST_COUNT, - 'EL_REQUESTS_BATCH_SIZE': variables.EL_REQUESTS_BATCH_SIZE, - 'MAX_CYCLE_LIFETIME_IN_SECONDS': variables.MAX_CYCLE_LIFETIME_IN_SECONDS, + **variables.PUBLIC_ENV_VARS, }, }) - ENV_VARIABLES_INFO.info({ - "ACCOUNT": str(variables.ACCOUNT.address) if variables.ACCOUNT else 'Dry', - "LIDO_LOCATOR_ADDRESS": str(variables.LIDO_LOCATOR_ADDRESS), - "CSM_MODULE_ADDRESS": str(variables.CSM_MODULE_ADDRESS), - "FINALIZATION_BATCH_MAX_REQUEST_COUNT": str(variables.FINALIZATION_BATCH_MAX_REQUEST_COUNT), - "EL_REQUESTS_BATCH_SIZE": str(variables.EL_REQUESTS_BATCH_SIZE), - "MAX_CYCLE_LIFETIME_IN_SECONDS": str(variables.MAX_CYCLE_LIFETIME_IN_SECONDS), - }) + ENV_VARIABLES_INFO.info(variables.PUBLIC_ENV_VARS) BUILD_INFO.info(build_info) logger.info({'msg': f'Start healthcheck server for Docker container on port {variables.HEALTHCHECK_SERVER_PORT}'}) diff --git a/src/modules/accounting/accounting.py b/src/modules/accounting/accounting.py index 6369d9409..f9c4289a9 100644 --- a/src/modules/accounting/accounting.py +++ b/src/modules/accounting/accounting.py @@ -58,7 +58,7 @@ class Accounting(BaseModule, ConsensusModule): - Send extra data Contains stuck and exited validators count by each node operator. """ - COMPATIBLE_ONCHAIN_VERSIONS = [(1, 1), (2, 2)] + COMPATIBLE_ONCHAIN_VERSIONS = [(1, 1), (2, 2), (2, 3)] def __init__(self, w3: Web3): self.report_contract: AccountingOracleContract = w3.lido_contracts.accounting_oracle @@ -217,7 +217,8 @@ def _get_consensus_lido_state(self, blockstamp: ReferenceBlockStamp) -> tuple[Va lido_validators = self.w3.lido_validators.get_lido_validators(blockstamp) count = len(lido_validators) - total_balance = Gwei(sum(int(validator.balance) for validator in lido_validators)) + pending_deposits_sum = self.w3.lido_validators.calculate_pending_deposits_sum(lido_validators) + total_balance = Gwei(sum(int(validator.balance) for validator in lido_validators) + pending_deposits_sum) logger.info({'msg': 'Calculate lido state on CL. (Validators count, Total balance in gwei)', 'value': (count, total_balance)}) return ValidatorsCount(count), ValidatorsBalance(total_balance) @@ -326,7 +327,7 @@ def get_extra_data(self, blockstamp: ReferenceBlockStamp) -> ExtraData: logger.info({'msg': 'Calculate stuck validators.', 'value': stuck_validators}) exited_validators = self.lido_validator_state_service.get_lido_newly_exited_validators(blockstamp) logger.info({'msg': 'Calculate exited validators.', 'value': exited_validators}) - orl = self.w3.lido_contracts.oracle_report_sanity_checker.get_oracle_report_limits(blockstamp.block_hash) + orl = self.w3.lido_contracts.oracle_report_sanity_checker.get_oracle_report_limits() if consensus_version == 1: return ExtraDataService.collect( @@ -350,7 +351,7 @@ def _get_generic_extra_data(self, blockstamp: ReferenceBlockStamp) -> GenericExt logger.info({'msg': 'Calculate stuck validators.', 'value': stuck_validators}) exited_validators = self.lido_validator_state_service.get_lido_newly_exited_validators(blockstamp) logger.info({'msg': 'Calculate exited validators.', 'value': exited_validators}) - orl = self.w3.lido_contracts.oracle_report_sanity_checker.get_oracle_report_limits(blockstamp.block_hash) + orl = self.w3.lido_contracts.oracle_report_sanity_checker.get_oracle_report_limits() return stuck_validators, exited_validators, orl def _calculate_report_v1(self, blockstamp: ReferenceBlockStamp) -> ReportData: diff --git a/src/modules/csm/csm.py b/src/modules/csm/csm.py index ff7e52d43..d989d91ff 100644 --- a/src/modules/csm/csm.py +++ b/src/modules/csm/csm.py @@ -60,7 +60,7 @@ class CSOracle(BaseModule, ConsensusModule): 3. Calculate the share of each CSM node operator excluding underperforming validators. """ - COMPATIBLE_ONCHAIN_VERSIONS = [(1, 1)] + COMPATIBLE_ONCHAIN_VERSIONS = [(1, 1), (1, 2)] report_contract: CSFeeOracleContract module_id: StakingModuleId diff --git a/src/modules/ejector/ejector.py b/src/modules/ejector/ejector.py index db2e7fe81..c5c432c48 100644 --- a/src/modules/ejector/ejector.py +++ b/src/modules/ejector/ejector.py @@ -1,25 +1,24 @@ import logging -from functools import reduce +import math -from more_itertools import ilen from web3.exceptions import ContractCustomError from web3.types import Wei from src.constants import ( + EFFECTIVE_BALANCE_INCREMENT, FAR_FUTURE_EPOCH, - MAX_EFFECTIVE_BALANCE, MAX_WITHDRAWALS_PER_PAYLOAD, 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 +28,19 @@ 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, Gwei, NodeOperatorGlobalIndex, ReferenceBlockStamp from src.utils.cache import global_lru_cache as lru_cache from src.utils.validator_state import ( - is_active_validator, - is_fully_withdrawable_validator, - is_partially_withdrawable_validator, compute_activation_exit_epoch, + compute_exit_balance_churn_limit, compute_exit_churn_limit, + is_active_validator, + is_fully_withdrawable_validator, + is_partially_withdrawable_validator, get_max_effective_balance, ) from src.web3py.extensions.lido_validators import LidoValidator from src.web3py.types import Web3 - logger = logging.getLogger(__name__) @@ -62,7 +61,8 @@ class Ejector(BaseModule, ConsensusModule): 3. Decode lido validators into bytes and send report transaction """ - COMPATIBLE_ONCHAIN_VERSIONS = [(1, 1), (1, 2)] + + COMPATIBLE_ONCHAIN_VERSIONS = [(1, 1), (1, 2), (1, 3)] 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) @@ -117,9 +117,9 @@ 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.w3.lido_contracts.validators_exit_bus_oracle.get_consensus_version(blockstamp.block_hash) + consensus_version = self.consensus_version(blockstamp) validators_iterator = iter(self.get_validators_iterator(consensus_version, blockstamp)) validators_to_eject: list[tuple[NodeOperatorGlobalIndex, LidoValidator]] = [] @@ -129,8 +129,9 @@ 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 @@ -141,7 +142,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.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)}) @@ -149,7 +150,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 +166,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) @@ -178,7 +179,7 @@ def _get_total_expected_balance(self, vals_to_exit: int, blockstamp: ReferenceBl return future_rewards + future_withdrawals + total_available_balance + going_to_withdraw_balance - def get_validators_iterator(self, consensus_version: int, blockstamp: ReferenceBlockStamp): + def get_validators_iterator(self, consensus_version: int, blockstamp: ReferenceBlockStamp): chain_config = self.get_chain_config(blockstamp) if consensus_version == 1: @@ -203,23 +204,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') + 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: @@ -232,11 +226,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. """ + fork = self.fork(blockstamp) + + if fork < fork.ELECTRA: + 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) @@ -247,10 +253,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 = compute_exit_balance_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]: """ @@ -286,35 +314,48 @@ def _get_latest_exit_epoch(self, blockstamp: ReferenceBlockStamp) -> tuple[Epoch def _get_sweep_delay_in_epochs(self, blockstamp: ReferenceBlockStamp) -> int: """Returns amount of epochs that will take to sweep all validators in chain.""" chain_config = self.get_chain_config(blockstamp) - total_withdrawable_validators = self._get_total_withdrawable_validators(blockstamp) - 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) + if self.consensus_version(blockstamp) in (1, 2): + total_withdrawable_validators = len(self._get_withdrawable_validators(blockstamp)) + logger.info({'msg': 'Calculate total withdrawable validators.', 'value': total_withdrawable_validators}) - 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))) + 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) + # 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. + 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: - total_active_validators = self._get_total_active_validators(blockstamp) + total_active_validators = len(self._get_active_validators(blockstamp)) + logger.info({'msg': 'Calculate total active validators.', 'value': total_active_validators}) churn_limit = compute_exit_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/modules/submodules/consensus.py b/src/modules/submodules/consensus.py index 00ed876bb..063b06291 100644 --- a/src/modules/submodules/consensus.py +++ b/src/modules/submodules/consensus.py @@ -87,6 +87,14 @@ 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 consensus_version(self, blockstamp: BlockStamp): + return self.report_contract.get_consensus_version(blockstamp.block_hash) + + @lru_cache(maxsize=1) + def fork(self, blockstamp: BlockStamp): + return self.w3.cc.get_fork(blockstamp.slot_number) + @lru_cache(maxsize=1) def get_chain_config(self, blockstamp: BlockStamp) -> ChainConfig: consensus_contract = self._get_consensus_contract(blockstamp) diff --git a/src/modules/submodules/oracle_module.py b/src/modules/submodules/oracle_module.py index 6fb22b900..214e3205a 100644 --- a/src/modules/submodules/oracle_module.py +++ b/src/modules/submodules/oracle_module.py @@ -25,7 +25,6 @@ from src import variables from src.types import SlotNumber, BlockStamp, BlockRoot - logger = logging.getLogger(__name__) @@ -57,42 +56,36 @@ def run_as_daemon(self): logger.debug({'msg': 'Startup new cycle.'}) self.cycle_handler() - @timeout(variables.MAX_CYCLE_LIFETIME_IN_SECONDS) def cycle_handler(self): - blockstamp = self._receive_last_finalized_slot() + self._cycle() + self._sleep_cycle() - if blockstamp.slot_number > self._slot_threshold: + @timeout(variables.MAX_CYCLE_LIFETIME_IN_SECONDS) + def _cycle(self): + """ + Main cycle logic: fetch the last finalized slot, refresh contracts if necessary, + and execute the module's business logic. + """ + # pylint: disable=too-many-branches + try: + blockstamp = self._receive_last_finalized_slot() + + # Check if the blockstamp is below the threshold and exit early + if blockstamp.slot_number <= self._slot_threshold: + logger.info({ + 'msg': 'Skipping the report. Waiting for new finalized slot.', + 'slot_threshold': self._slot_threshold, + }) + return + + # Refresh contracts if the address has changed if self.w3.lido_contracts.has_contract_address_changed(): clear_global_cache() self.refresh_contracts() - result = self.run_cycle(blockstamp) + result = self.run_cycle(blockstamp) if result is ModuleExecuteDelay.NEXT_FINALIZED_EPOCH: self._slot_threshold = blockstamp.slot_number - else: - logger.info({ - 'msg': 'Skipping the report. Wait for new finalized slot.', - 'slot_threshold': self._slot_threshold, - }) - - logger.info({'msg': f'Cycle end. Sleep for {variables.CYCLE_SLEEP_IN_SECONDS} seconds.'}) - time.sleep(variables.CYCLE_SLEEP_IN_SECONDS) - - def _receive_last_finalized_slot(self) -> BlockStamp: - block_root = BlockRoot(self.w3.cc.get_block_root('finalized').root) - block_details = self.w3.cc.get_block_details(block_root) - bs = build_blockstamp(block_details) - logger.info({'msg': 'Fetch last finalized BlockStamp.', 'value': asdict(bs)}) - ORACLE_SLOT_NUMBER.labels('finalized').set(bs.slot_number) - ORACLE_BLOCK_NUMBER.labels('finalized').set(bs.block_number) - return bs - - def run_cycle(self, blockstamp: BlockStamp) -> ModuleExecuteDelay: - # pylint: disable=too-many-branches - logger.info({'msg': 'Execute module.', 'value': blockstamp}) - - try: - result = self.execute_module(blockstamp) except IsNotMemberException as exception: logger.error({'msg': 'Provided account is not part of Oracle`s committee.'}) raise exception @@ -119,12 +112,27 @@ def run_cycle(self, blockstamp: BlockStamp) -> ModuleExecuteDelay: logger.error({'msg': 'IPFS provider error.', 'error': str(error)}) except ValueError as error: logger.error({'msg': 'Unexpected error.', 'error': str(error)}) - else: - # if there are no exceptions, then pulse - pulse() - return result - return ModuleExecuteDelay.NEXT_SLOT + @staticmethod + def _sleep_cycle(): + """Handles sleeping between cycles based on the configured cycle sleep time.""" + logger.info({'msg': f'Cycle end. Sleeping for {variables.CYCLE_SLEEP_IN_SECONDS} seconds.'}) + time.sleep(variables.CYCLE_SLEEP_IN_SECONDS) + + def _receive_last_finalized_slot(self) -> BlockStamp: + block_root = BlockRoot(self.w3.cc.get_block_root('finalized').root) + block_details = self.w3.cc.get_block_details(block_root) + bs = build_blockstamp(block_details) + logger.info({'msg': 'Fetch last finalized BlockStamp.', 'value': asdict(bs)}) + ORACLE_SLOT_NUMBER.labels('finalized').set(bs.slot_number) + ORACLE_BLOCK_NUMBER.labels('finalized').set(bs.block_number) + return bs + + def run_cycle(self, blockstamp: BlockStamp) -> ModuleExecuteDelay: + logger.info({'msg': 'Execute module.', 'value': blockstamp}) + result = self.execute_module(blockstamp) + pulse() + return result @abstractmethod def execute_module(self, last_finalized_blockstamp: BlockStamp) -> ModuleExecuteDelay: diff --git a/src/providers/consensus/client.py b/src/providers/consensus/client.py index 0c39d2f64..0c4404c17 100644 --- a/src/providers/consensus/client.py +++ b/src/providers/consensus/client.py @@ -1,11 +1,13 @@ +from enum import StrEnum from http import HTTPStatus -from typing import Literal, cast +from typing import TYPE_CHECKING, 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, BlockDetailsResponse, BlockHeaderFullResponse, BlockHeaderResponseData, @@ -16,12 +18,15 @@ SlotAttestationCommittee, BlockAttestation, ) 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 +from src.utils.types import is_4bytes_hex -logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from src.types import Fork +logger = logging.getLogger(__name__) LiteralState = Literal['head', 'genesis', 'finalized', 'justified'] @@ -50,13 +55,43 @@ class ConsensusClient(HTTPProvider): API_GET_VALIDATORS = 'eth/v1/beacon/states/{}/validators' API_GET_SPEC = 'eth/v1/config/spec' API_GET_GENESIS = 'eth/v1/beacon/genesis' + API_GET_FORK = '/eth/v1/beacon/states/{}/fork' + + def get_config_spec(self) -> BeaconSpecResponse: + data = self._get_raw_spec() + return BeaconSpecResponse.from_response(**data) + + def get_fork(self, state_id: LiteralState | SlotNumber) -> "Fork": + data, _ = self._get( + self.API_GET_FORK, + path_params=(state_id,), + ) + if not isinstance(data, dict): + raise ValueError("Expected mapping response from getFork") + + current_version = data["current_version"] + return self._forks()(current_version) # type: ignore[operator] + + def _forks(self) -> "Fork": + spec = self._get_raw_spec() + + versions = {} + for k, v in spec.items(): + if k.endswith("FORK_VERSION"): + if not is_4bytes_hex(v): + raise ValueError(f"Got invalid fork version {v}") + versions[k.split("_")[0].upper()] = v - def get_config_spec(self): + if not versions: + raise ValueError("No forks defined in the spec") + return cast("Fork", StrEnum("Fork", versions.items())) + + def _get_raw_spec(self) -> dict[str, str]: """Spec: https://ethereum.github.io/beacon-APIs/#/Config/getSpec""" data, _ = self._get(self.API_GET_SPEC) if not isinstance(data, dict): raise ValueError("Expected mapping response from getSpec") - return BeaconSpecResponse.from_response(**data) + return data def get_genesis(self): """ @@ -144,12 +179,31 @@ def get_attestation_committees( @lru_cache(maxsize=1) def get_state_block_roots(self, state_id: SlotNumber) -> list[BlockRoot]: + streamed_json = cast(TransientStreamingJSONObject, self._get( + self.API_GET_STATE, + path_params=(state_id,), + stream=True, + )) + 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, )) - return list(streamed_json['data']['block_roots']) + view = {} + data = streamed_json['data'] + try: + # NOTE: Keep in mind: the order is important. + 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]: @@ -159,6 +213,12 @@ def get_validators(self, blockstamp: BlockStamp) -> list[Validator]: @list_of_dataclasses(Validator.from_response) def get_validators_no_cache(self, blockstamp: BlockStamp, pub_keys: str | tuple | None = None) -> list[dict]: """Spec: https://ethereum.github.io/beacon-APIs/#/Beacon/getStateValidators""" + logger.info({ + 'msg': 'Getting validators...', + 'url': self.API_GET_VALIDATORS, + 'slot_number': blockstamp.slot_number, + 'state_root': blockstamp.state_root, + }) try: data, _ = self._get( self.API_GET_VALIDATORS, @@ -168,6 +228,7 @@ def get_validators_no_cache(self, blockstamp: BlockStamp, pub_keys: str | tuple ) if not isinstance(data, list): raise ValueError("Expected list response from getStateValidators") + logger.info({'msg': f'Fetched {len(data)} validators'}) return data except NotOkResponse as error: if self.PRYSM_STATE_NOT_FOUND_ERROR in error.text: diff --git a/src/providers/consensus/types.py b/src/providers/consensus/types.py index 7b257b34c..bf22866a8 100644 --- a/src/providers/consensus/types.py +++ b/src/providers/consensus/types.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from enum import Enum -from src.types import BlockHash, BlockRoot, StateRoot +from src.types import BlockHash, BlockRoot, Gwei, SlotNumber, StateRoot from src.utils.dataclass import Nested, FromResponse @@ -150,3 +150,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/providers/execution/contracts/lido.py b/src/providers/execution/contracts/lido.py index ed49f6ee1..301e61fd6 100644 --- a/src/providers/execution/contracts/lido.py +++ b/src/providers/execution/contracts/lido.py @@ -89,7 +89,7 @@ def _handle_oracle_report( state_override: dict[ChecksumAddress, CallOverrideParams] = { accounting_oracle_address: { # Fix: insufficient funds for gas * price + value - 'balance': Wei(10**18), + 'balance': Wei(100 * 10**18), # Fix: Sanity checker uses `lastProcessingRefSlot` from AccountingOracle to # properly process negative rebase sanity checks. Since current simulation skips call to AO, # setting up `lastProcessingRefSlot` directly. diff --git a/src/services/bunker_cases/abnormal_cl_rebase.py b/src/services/bunker_cases/abnormal_cl_rebase.py index ef74a7fca..7c8ffbc3c 100644 --- a/src/services/bunker_cases/abnormal_cl_rebase.py +++ b/src/services/bunker_cases/abnormal_cl_rebase.py @@ -6,7 +6,7 @@ from web3.contract.contract import ContractEvent from web3.types import EventData -from src.constants import MAX_EFFECTIVE_BALANCE, EFFECTIVE_BALANCE_INCREMENT +from src.constants import EFFECTIVE_BALANCE_INCREMENT, MIN_ACTIVATION_BALANCE from src.modules.submodules.types import ChainConfig from src.providers.consensus.types import Validator from src.providers.keys.types import LidoKey @@ -93,6 +93,7 @@ def _calculate_lido_normal_cl_rebase(self, blockstamp: ReferenceBlockStamp) -> G self.lido_keys, last_report_all_validators ) + # Calculate mean sum of effective balance for all validators and Lido validators (ACTIVE only) mean_sum_of_all_effective_balance = AbnormalClRebase.get_mean_sum_of_effective_balance( last_report_blockstamp, blockstamp, last_report_all_validators, self.all_validators ) @@ -216,10 +217,11 @@ def _get_lido_validators_balance_with_vault( Get Lido validator balance with withdrawals vault balance """ real_cl_balance = AbnormalClRebase.calculate_validators_balance_sum(lido_validators) + pending_deposits_sum = LidoValidatorsProvider.calculate_pending_deposits_sum(lido_validators) withdrawals_vault_balance = int( self.w3.from_wei(self.w3.lido_contracts.get_withdrawal_balance_no_cache(blockstamp), "gwei") ) - return Gwei(real_cl_balance + withdrawals_vault_balance) + return Gwei(real_cl_balance + pending_deposits_sum + withdrawals_vault_balance) def _get_withdrawn_from_vault_between_blocks( self, prev_blockstamp: BlockStamp, ref_blockstamp: ReferenceBlockStamp @@ -289,7 +291,7 @@ def calculate_validators_count_diff_in_gwei( validators_diff = len(ref_validators) - len(prev_validators) if validators_diff < 0: raise ValueError("Validators count diff should be positive or 0. Something went wrong with CL API") - return Gwei(validators_diff * MAX_EFFECTIVE_BALANCE) + return Gwei(validators_diff * MIN_ACTIVATION_BALANCE) @staticmethod def get_mean_sum_of_effective_balance( diff --git a/src/services/bunker_cases/midterm_slashing_penalty.py b/src/services/bunker_cases/midterm_slashing_penalty.py index 721238378..40b00211a 100644 --- a/src/services/bunker_cases/midterm_slashing_penalty.py +++ b/src/services/bunker_cases/midterm_slashing_penalty.py @@ -5,7 +5,7 @@ EPOCHS_PER_SLASHINGS_VECTOR, MIN_VALIDATOR_WITHDRAWABILITY_DELAY, PROPORTIONAL_SLASHING_MULTIPLIER_BELLATRIX, - EFFECTIVE_BALANCE_INCREMENT, MAX_EFFECTIVE_BALANCE + EFFECTIVE_BALANCE_INCREMENT, ) from src.modules.submodules.types import FrameConfig, ChainConfig from src.providers.consensus.types import Validator @@ -183,29 +183,30 @@ def predict_midterm_penalty_in_frame( ref_epoch, all_slashed_validators, EpochNumber(midterm_penalty_epoch) ) penalty_in_frame += MidtermSlashingPenalty.get_validator_midterm_penalty( - validator, len(bound_slashed_validators), total_balance + validator, bound_slashed_validators, total_balance ) return Gwei(penalty_in_frame) @staticmethod def get_validator_midterm_penalty( validator: LidoValidator, - bound_slashed_validators_count: int, - total_balance: Gwei + bound_slashed_validators: list[Validator], + total_balance: Gwei, ) -> Gwei: """ Calculate midterm penalty for particular validator - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#slashings + https://github.com/ethereum/consensus-specs/blob/dev/specs/electra/beacon-chain.md#modified-process_slashings """ - # We don't know which balance was at slashing epoch, so we make a pessimistic assumption that it was 32 ETH - slashings = Gwei(bound_slashed_validators_count * MAX_EFFECTIVE_BALANCE) + # We don't know validators effective balances on the moment of slashing, + # so we assume that it was at least `effective_balance` + slashings = Gwei(sum(int(v.validator.effective_balance) for v in bound_slashed_validators)) adjusted_total_slashing_balance = min( slashings * PROPORTIONAL_SLASHING_MULTIPLIER_BELLATRIX, total_balance ) effective_balance = int(validator.validator.effective_balance) - penalty_numerator = effective_balance // EFFECTIVE_BALANCE_INCREMENT * adjusted_total_slashing_balance - penalty = penalty_numerator // total_balance * EFFECTIVE_BALANCE_INCREMENT - + penalty_per_effective_balance_increment = adjusted_total_slashing_balance // (total_balance // EFFECTIVE_BALANCE_INCREMENT) + effective_balance_increments = effective_balance // EFFECTIVE_BALANCE_INCREMENT + penalty = penalty_per_effective_balance_increment * effective_balance_increments return Gwei(penalty) @staticmethod diff --git a/src/services/exit_order/iterator.py b/src/services/exit_order/iterator.py index bf398352a..ba65e484f 100644 --- a/src/services/exit_order/iterator.py +++ b/src/services/exit_order/iterator.py @@ -54,9 +54,8 @@ def __iter__(self) -> Iterator[tuple[NodeOperatorGlobalIndex, LidoValidator]]: eois = ExitOrderIteratorStateService(self.w3, self.blockstamp) self.left_queue_count = 0 - self.max_validators_to_exit = eois.w3.lido_contracts.oracle_report_sanity_checker.get_oracle_report_limits( - self.blockstamp.block_hash, - ).max_validator_exit_requests_per_report + self.max_validators_to_exit = (eois.w3.lido_contracts.oracle_report_sanity_checker.get_oracle_report_limits() + .max_validator_exit_requests_per_report) self.operator_network_penetration_threshold = eois.get_operator_network_penetration_threshold(self.blockstamp) # Prepare list of exitable validators, which will be sorted by exit order predicates diff --git a/src/services/exit_order_v2/iterator.py b/src/services/exit_order_v2/iterator.py index fc139318b..636f235c9 100644 --- a/src/services/exit_order_v2/iterator.py +++ b/src/services/exit_order_v2/iterator.py @@ -149,9 +149,8 @@ def _calculate_lido_stats(self): self.node_operators_stats[gid].soft_exit_to = self.node_operators_stats[gid].node_operator.target_validators_count def _load_blockchain_state(self): - self.max_validators_to_exit = self.w3.lido_contracts.oracle_report_sanity_checker.get_oracle_report_limits( - self.blockstamp.block_hash, - ).max_validator_exit_requests_per_report + self.max_validators_to_exit = (self.w3.lido_contracts.oracle_report_sanity_checker.get_oracle_report_limits() + .max_validator_exit_requests_per_report) self.no_penetration_threshold = self.w3.lido_contracts.oracle_daemon_config.node_operator_network_penetration_threshold_bp( block_identifier=self.blockstamp.block_hash, diff --git a/src/services/safe_border.py b/src/services/safe_border.py index f355e9493..9edb94889 100644 --- a/src/services/safe_border.py +++ b/src/services/safe_border.py @@ -57,7 +57,7 @@ def __init__( self._retrieve_constants() def _retrieve_constants(self): - limits_list = self.w3.lido_contracts.oracle_report_sanity_checker.get_oracle_report_limits(self.blockstamp.block_hash) + limits_list = self.w3.lido_contracts.oracle_report_sanity_checker.get_oracle_report_limits() self.finalization_default_shift = math.ceil( limits_list.request_timestamp_margin / (self.chain_config.slots_per_epoch * self.chain_config.seconds_per_slot) diff --git a/src/services/withdrawal.py b/src/services/withdrawal.py index ada3d5a41..57bbdb011 100644 --- a/src/services/withdrawal.py +++ b/src/services/withdrawal.py @@ -82,7 +82,7 @@ def _calculate_finalization_batches( available_eth: int, until_timestamp: int ) -> list[int]: - max_length = self.w3.lido_contracts.withdrawal_queue_nft.max_batches_length(self.blockstamp.block_hash) + max_length = self.w3.lido_contracts.withdrawal_queue_nft.max_batches_length() state = BatchState( remaining_eth_budget=available_eth, diff --git a/src/types.py b/src/types.py index 22ed30d2d..c4982579d 100644 --- a/src/types.py +++ b/src/types.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from enum import StrEnum +from enum import StrEnum, auto from typing import NewType from eth_typing import BlockNumber, ChecksumAddress, HexStr @@ -38,6 +38,20 @@ class OracleModule(StrEnum): type OperatorsValidatorCount = dict[NodeOperatorGlobalIndex, int] +class _Fork(StrEnum): + """We store fork versions as an enum of hex encoded 4 bytes, so the values are comparable as strings""" + + GENESIS = auto() + ALTAIR = auto() + BELLATRIX = auto() + CAPELLA = auto() + DENEB = auto() + ELECTRA = auto() + + +type Fork = _Fork + + @dataclass(frozen=True) class BlockStamp: state_root: StateRoot 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/build.py b/src/utils/build.py index e969e5e98..ff2c9af0c 100644 --- a/src/utils/build.py +++ b/src/utils/build.py @@ -1,16 +1,12 @@ import json -import os UNKNOWN_BUILD_INFO = {"version": "unknown", "branch": "unknown", "commit": "unknown"} def get_build_info() -> dict: path = "./build-info.json" - if os.path.exists(path): + try: with open(path, "r") as f: - try: - build_info = json.load(f) - except json.JSONDecodeError: - return UNKNOWN_BUILD_INFO - return build_info - return UNKNOWN_BUILD_INFO + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return UNKNOWN_BUILD_INFO diff --git a/src/utils/types.py b/src/utils/types.py index 08dd5d85d..e64059951 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: HexStr) -> bytes: return bytes.fromhex(hex_str[2:]) + + +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 56449f290..b02140cb1 100644 --- a/src/utils/validator_state.py +++ b/src/utils/validator_state.py @@ -1,14 +1,18 @@ from typing import Sequence from src.constants import ( - MAX_EFFECTIVE_BALANCE, + CHURN_LIMIT_QUOTIENT, + COMPOUNDING_WITHDRAWAL_PREFIX, + EFFECTIVE_BALANCE_INCREMENT, ETH1_ADDRESS_WITHDRAWAL_PREFIX, - SHARD_COMMITTEE_PERIOD, FAR_FUTURE_EPOCH, - EFFECTIVE_BALANCE_INCREMENT, + MAX_EFFECTIVE_BALANCE_ELECTRA, + MAX_PER_EPOCH_ACTIVATION_EXIT_CHURN_LIMIT, MAX_SEED_LOOKAHEAD, + MIN_ACTIVATION_BALANCE, MIN_PER_EPOCH_CHURN_LIMIT, - CHURN_LIMIT_QUOTIENT, + MIN_PER_EPOCH_CHURN_LIMIT_ELECTRA, + SHARD_COMMITTEE_PERIOD, ) from src.providers.consensus.types import Validator from src.types import EpochNumber, Gwei @@ -41,15 +45,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 +71,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) ) @@ -74,7 +95,10 @@ def is_validator_eligible_to_exit(validator: Validator, epoch: EpochNumber) -> b """ Check if `validator` can exit. Verify the validator has been active long enough. - https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/beacon-chain.md#voluntary-exits + https://github.com/ethereum/consensus-specs/blob/dev/specs/electra/beacon-chain.md#voluntary-exits + + The validator can only exit if it has no pending withdrawals in the queue. + This method don't take partial withdrawals into account because Lido protocol doesn't support partial withdrawals. """ active_long_enough = int(validator.validator.activation_epoch) + SHARD_COMMITTEE_PERIOD <= epoch return active_long_enough and not is_on_exit(validator) @@ -114,3 +138,24 @@ 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) + + +# @see https://github.com/ethereum/consensus-specs/blob/dev/specs/electra/beacon-chain.md#new-get_activation_exit_churn_limit +def compute_exit_balance_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``. + 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/src/variables.py b/src/variables.py index ec13ae368..6af82816c 100644 --- a/src/variables.py +++ b/src/variables.py @@ -87,22 +87,66 @@ def check_all_required_variables(): errors = check_uri_required_variables() - if LIDO_LOCATOR_ADDRESS in (None, ''): + if not LIDO_LOCATOR_ADDRESS: errors.append('LIDO_LOCATOR_ADDRESS') return errors def check_uri_required_variables(): - errors = [] - if '' in EXECUTION_CLIENT_URI: - errors.append('EXECUTION_CLIENT_URI') - if '' in CONSENSUS_CLIENT_URI: - errors.append('CONSENSUS_CLIENT_URI') - if '' in KEYS_API_URI: - errors.append('KEYS_API_URI') - return errors + required_uris = { + 'EXECUTION_CLIENT_URI': EXECUTION_CLIENT_URI, + 'CONSENSUS_CLIENT_URI': CONSENSUS_CLIENT_URI, + 'KEYS_API_URI': KEYS_API_URI, + } + return [name for name, uri in required_uris.items() if '' in uri] def raise_from_errors(errors): if errors: raise ValueError("The following variables are required: " + ", ".join(errors)) + + +# All non-private env variables to the logs in main +PUBLIC_ENV_VARS = { + 'ACCOUNT': 'Dry' if ACCOUNT is None else ACCOUNT.address, + 'LIDO_LOCATOR_ADDRESS': LIDO_LOCATOR_ADDRESS, + 'CSM_MODULE_ADDRESS': CSM_MODULE_ADDRESS, + 'FINALIZATION_BATCH_MAX_REQUEST_COUNT': FINALIZATION_BATCH_MAX_REQUEST_COUNT, + 'EL_REQUESTS_BATCH_SIZE': EL_REQUESTS_BATCH_SIZE, + 'CSM_ORACLE_MAX_CONCURRENCY': CSM_ORACLE_MAX_CONCURRENCY, + 'TX_GAS_ADDITION': TX_GAS_ADDITION, + 'EVENTS_SEARCH_STEP': EVENTS_SEARCH_STEP, + 'MIN_PRIORITY_FEE': MIN_PRIORITY_FEE, + 'MAX_PRIORITY_FEE': MAX_PRIORITY_FEE, + 'PRIORITY_FEE_PERCENTILE': PRIORITY_FEE_PERCENTILE, + 'DAEMON': DAEMON, + 'SUBMIT_DATA_DELAY_IN_SLOTS': SUBMIT_DATA_DELAY_IN_SLOTS, + 'CYCLE_SLEEP_IN_SECONDS': CYCLE_SLEEP_IN_SECONDS, + 'ALLOW_REPORTING_IN_BUNKER_MODE': ALLOW_REPORTING_IN_BUNKER_MODE, + 'HTTP_REQUEST_TIMEOUT_EXECUTION': HTTP_REQUEST_TIMEOUT_EXECUTION, + 'HTTP_REQUEST_TIMEOUT_CONSENSUS': HTTP_REQUEST_TIMEOUT_CONSENSUS, + 'HTTP_REQUEST_RETRY_COUNT_CONSENSUS': HTTP_REQUEST_RETRY_COUNT_CONSENSUS, + 'HTTP_REQUEST_SLEEP_BEFORE_RETRY_IN_SECONDS_CONSENSUS': HTTP_REQUEST_SLEEP_BEFORE_RETRY_IN_SECONDS_CONSENSUS, + 'HTTP_REQUEST_TIMEOUT_KEYS_API': HTTP_REQUEST_TIMEOUT_KEYS_API, + 'HTTP_REQUEST_RETRY_COUNT_KEYS_API': HTTP_REQUEST_RETRY_COUNT_KEYS_API, + 'HTTP_REQUEST_SLEEP_BEFORE_RETRY_IN_SECONDS_KEYS_API': HTTP_REQUEST_SLEEP_BEFORE_RETRY_IN_SECONDS_KEYS_API, + 'HTTP_REQUEST_TIMEOUT_IPFS': HTTP_REQUEST_TIMEOUT_IPFS, + 'HTTP_REQUEST_RETRY_COUNT_IPFS': HTTP_REQUEST_RETRY_COUNT_IPFS, + 'PROMETHEUS_PORT': PROMETHEUS_PORT, + 'PROMETHEUS_PREFIX': PROMETHEUS_PREFIX, + 'HEALTHCHECK_SERVER_PORT': HEALTHCHECK_SERVER_PORT, + 'MAX_CYCLE_LIFETIME_IN_SECONDS': MAX_CYCLE_LIFETIME_IN_SECONDS, + 'CACHE_PATH': CACHE_PATH, +} + +PRIVATE_ENV_VARS = { + 'EXECUTION_CLIENT_URI': EXECUTION_CLIENT_URI, + 'CONSENSUS_CLIENT_URI': CONSENSUS_CLIENT_URI, + 'KEYS_API_URI': KEYS_API_URI, + 'GW3_ACCESS_KEY': GW3_ACCESS_KEY, + 'GW3_SECRET_KEY': GW3_SECRET_KEY, + 'PINATA_JWT': PINATA_JWT, + 'MEMBER_PRIV_KEY': MEMBER_PRIV_KEY, +} + +assert not set(PRIVATE_ENV_VARS.keys()).intersection(set(PUBLIC_ENV_VARS.keys())) diff --git a/src/web3py/extensions/lido_validators.py b/src/web3py/extensions/lido_validators.py index 6e813e20b..90758e1ef 100644 --- a/src/web3py/extensions/lido_validators.py +++ b/src/web3py/extensions/lido_validators.py @@ -6,6 +6,7 @@ from eth_typing import ChecksumAddress from web3.module import Module +from src.constants import FAR_FUTURE_EPOCH, MIN_ACTIVATION_BALANCE from src.providers.consensus.types import Validator from src.providers.keys.types import LidoKey from src.types import BlockStamp, StakingModuleId, NodeOperatorId, NodeOperatorGlobalIndex, StakingModuleAddress @@ -172,6 +173,16 @@ def merge_validators_with_keys(keys: list[LidoKey], validators: list[Validator]) return lido_validators + @staticmethod + def calculate_pending_deposits_sum(lido_validators: list[LidoValidator]) -> int: + # NOTE: Using 32 ETH as a default validator pending balance is OK for the current protocol implementation. + # It must be changed in case of validators consolidation feature implementation. + return sum( + MIN_ACTIVATION_BALANCE + for validator in lido_validators + if int(validator.balance) == 0 and int(validator.validator.activation_epoch) == FAR_FUTURE_EPOCH + ) + @lru_cache(maxsize=1) def get_lido_validators_by_node_operators(self, blockstamp: BlockStamp) -> ValidatorsByNodeOperator: merged_validators = self.get_lido_validators(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 eee95c6c5..069f976a6 100644 --- a/tests/factory/no_registry.py +++ b/tests/factory/no_registry.py @@ -3,13 +3,15 @@ from typing import Any from faker import Faker +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, 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 +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() @@ -17,12 +19,32 @@ class ValidatorStateFactory(Web3Factory): __model__ = ValidatorState + withdrawal_credentials = "0x01" exit_epoch = FAR_FUTURE_EPOCH + @classmethod + def build(cls, **kwargs: Any): + if 'pubkey' not in kwargs: + kwargs['pubkey'] = HexBytes(faker.binary(length=48)).hex() + return super().build(**kwargs) + class ValidatorFactory(Web3Factory): __model__ = Validator + @classmethod + def build_pending_deposit_vals(cls, **kwargs: Any): + return cls.build( + balance=str(0), + validator=ValidatorStateFactory.build( + activation_eligibility_epoch=str(FAR_FUTURE_EPOCH), + activation_epoch=str(FAR_FUTURE_EPOCH), + exit_epoch=str(FAR_FUTURE_EPOCH), + effective_balance=str(0), + ), + **kwargs, + ) + class LidoKeyFactory(Web3Factory): __model__ = LidoKey @@ -53,6 +75,19 @@ def build_with_activation_epoch_bound(cls, max_value: int, **kwargs: Any): validator=ValidatorStateFactory.build(activation_epoch=str(faker.pyint(max_value=max_value - 1))), **kwargs ) + @classmethod + def build_pending_deposit_vals(cls, **kwargs: Any): + return cls.build( + balance=str(0), + validator=ValidatorStateFactory.build( + activation_eligibility_epoch=str(FAR_FUTURE_EPOCH), + activation_epoch=str(FAR_FUTURE_EPOCH), + exit_epoch=str(FAR_FUTURE_EPOCH), + effective_balance=str(0), + ), + **kwargs, + ) + @classmethod def build_not_active_vals(cls, epoch, **kwargs: Any): return cls.build( @@ -60,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 @@ -70,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 @@ -80,7 +115,18 @@ 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, meb: int = MAX_EFFECTIVE_BALANCE, **kwargs: Any): + return cls.build( + balance=balance, + validator=ValidatorStateFactory.build( + 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/accounting/bunker/conftest.py b/tests/modules/accounting/bunker/conftest.py index 68b01e80e..c6df7477a 100644 --- a/tests/modules/accounting/bunker/conftest.py +++ b/tests/modules/accounting/bunker/conftest.py @@ -9,6 +9,7 @@ from src.services.bunker_cases.abnormal_cl_rebase import AbnormalClRebase from src.services.bunker_cases.types import BunkerConfig from src.types import BlockNumber, BlockStamp, ReferenceBlockStamp +from tests.modules.ejector.test_exit_order_state_service import FAR_FUTURE_EPOCH def simple_ref_blockstamp(block_number: int) -> ReferenceBlockStamp: @@ -25,7 +26,9 @@ def simple_key(pubkey: str) -> LidoKey: return key -def simple_validator(index, pubkey, balance, slashed=False, withdrawable_epoch='', exit_epoch='100500') -> Validator: +def simple_validator( + index, pubkey, balance, slashed=False, withdrawable_epoch='', exit_epoch='100500', activation_epoch="0" +) -> Validator: return Validator( index=str(index), balance=str(balance), @@ -36,7 +39,7 @@ def simple_validator(index, pubkey, balance, slashed=False, withdrawable_epoch=' effective_balance=str(32 * 10**9), slashed=slashed, activation_eligibility_epoch='', - activation_epoch='0', + activation_epoch=activation_epoch, exit_epoch=exit_epoch, withdrawable_epoch=withdrawable_epoch, ), @@ -134,6 +137,7 @@ def _get_withdrawal_vault_balance(blockstamp: BlockStamp): 31: 2 * 10**18, 33: 2 * 10**18, 40: 2 * 10**18, + 50: 2 * 10**18, } return balance[blockstamp.block_number] @@ -199,6 +203,14 @@ def _get_validators(state: ReferenceBlockStamp, _=None): simple_validator(4, '0x04', 32 * 10**9), simple_validator(5, '0x05', (32 * 10**9) + 824112), ], + 50: [ + simple_validator(4, '0x00', balance=0, activation_epoch=FAR_FUTURE_EPOCH), + simple_validator(1, '0x01', 32 * 10**9), + simple_validator(2, '0x02', 32 * 10**9), + simple_validator(3, '0x03', (32 * 10**9) + 333333), + simple_validator(4, '0x04', balance=0, activation_epoch=FAR_FUTURE_EPOCH), + simple_validator(5, '0x05', (32 * 10**9) + 824112), + ], 1000: [ simple_validator(0, '0x00', 32 * 10**9), simple_validator(1, '0x01', 32 * 10**9), diff --git a/tests/modules/accounting/bunker/test_bunker_abnormal_cl_rebase.py b/tests/modules/accounting/bunker/test_bunker_abnormal_cl_rebase.py index a85c46288..a004d6847 100644 --- a/tests/modules/accounting/bunker/test_bunker_abnormal_cl_rebase.py +++ b/tests/modules/accounting/bunker/test_bunker_abnormal_cl_rebase.py @@ -90,6 +90,7 @@ def test_is_abnormal_cl_rebase( @pytest.mark.parametrize( ("blockstamp", "expected_rebase"), [ + (simple_ref_blockstamp(50), 512000000), (simple_ref_blockstamp(40), 420650924), (simple_ref_blockstamp(20), 140216974), (simple_ref_blockstamp(123), 1120376622), @@ -234,6 +235,7 @@ def test_calculate_cl_rebase_between_blocks( @pytest.mark.parametrize( ("blockstamp", "expected_result"), [ + (simple_ref_blockstamp(50), 98001157445), (simple_ref_blockstamp(40), 98001157445), (simple_ref_blockstamp(20), 77999899300), ], diff --git a/tests/modules/accounting/bunker/test_bunker_medterm_penalty.py b/tests/modules/accounting/bunker/test_bunker_medterm_penalty.py index a7fc0a4c4..b6e3d81ac 100644 --- a/tests/modules/accounting/bunker/test_bunker_medterm_penalty.py +++ b/tests/modules/accounting/bunker/test_bunker_medterm_penalty.py @@ -1,10 +1,11 @@ import pytest +from src.constants import MAX_EFFECTIVE_BALANCE_ELECTRA, MAX_EFFECTIVE_BALANCE from src.modules.submodules.consensus import FrameConfig from src.modules.submodules.types import ChainConfig from src.providers.consensus.types import Validator, ValidatorStatus, ValidatorState from src.services.bunker_cases.midterm_slashing_penalty import MidtermSlashingPenalty -from src.types import EpochNumber, ReferenceBlockStamp +from src.types import EpochNumber, ReferenceBlockStamp, Gwei def simple_blockstamp( @@ -30,7 +31,7 @@ def simple_validators( validator=ValidatorState( pubkey=f"0x{index}", withdrawal_credentials='', - effective_balance=str(32 * 10**9), + effective_balance=effective_balance, slashed=slashed, activation_eligibility_epoch='', activation_epoch='0', @@ -219,7 +220,7 @@ def test_get_per_frame_lido_validators_with_future_midterm_epoch( {18: simple_validators(0, 0, slashed=True)}, simple_validators(0, 0, slashed=True), 100, - {18: 0}, + {18: 960_000_000}, ), ( # all are slashed @@ -288,7 +289,7 @@ def test_get_future_midterm_penalty_sum_in_frames( simple_validators(0, 0, slashed=True), 100 * 32 * 10**9, simple_validators(0, 0, slashed=True), - 0, + 960_000_000, ), ( # all are slashed @@ -304,7 +305,7 @@ def test_get_future_midterm_penalty_sum_in_frames( simple_validators(0, 9, slashed=True), 100 * 32 * 10**9, simple_validators(0, 9, slashed=True), - 10 * 9 * 10**9, + 96_000_000_000, ), ( # slashed in different epochs in different frames without determined slashing epochs @@ -318,7 +319,7 @@ def test_get_future_midterm_penalty_sum_in_frames( *simple_validators(0, 5, slashed=True), *simple_validators(6, 9, slashed=True, exit_epoch="8192", withdrawable_epoch="8197"), ], - 10 * 9 * 10**9, + 96_000_000_000, ), ], ) @@ -332,22 +333,58 @@ def test_predict_midterm_penalty_in_frame( assert result == expected_result +# 50% active validators with 2048 EB and the rest part with 32 EB +half_electra = [ + *simple_validators(0, 250_000, effective_balance=str(MAX_EFFECTIVE_BALANCE)), + *simple_validators(250_001, 500_000, effective_balance=str(MAX_EFFECTIVE_BALANCE_ELECTRA)), +] +# 20% active validators with 2048 EB and the rest part with 32 EB +part_electra = [ + *simple_validators(0, 10_000, effective_balance=str(MAX_EFFECTIVE_BALANCE_ELECTRA)), + *simple_validators(10_001, 500_000, effective_balance=str(MAX_EFFECTIVE_BALANCE)), +] + +one_32eth = simple_validators(0, 0, effective_balance=str(MAX_EFFECTIVE_BALANCE)) +one_2048eth = simple_validators(0, 0, effective_balance=str(MAX_EFFECTIVE_BALANCE_ELECTRA)) + + @pytest.mark.unit @pytest.mark.parametrize( - ("bounded_slashings_count", "active_validators_count", "expected_penalty"), + ("bounded_slashed_validators", "active_validators", "expected_penalty"), [ - (1, 500000, 0), - (100, 500000, 0), - (1000, 500000, 0), - (5000, 500000, 0), - (10000, 500000, 1000000000), - (20000, 500000, 3000000000), - (50000, 500000, 9000000000), + (one_32eth, half_electra, 5888), + (one_2048eth, half_electra, 378_080), + (one_32eth, part_electra, 84_928), + (one_2048eth, part_electra, 5_436_832), + (100 * one_32eth, half_electra, 590_752), + (100 * one_2048eth, half_electra, 37_809_216), + (100 * one_32eth, part_electra, 8_495_072), + (100 * one_2048eth, part_electra, 543_686_016), + (10_000 * one_32eth, half_electra, 59_076_896), + (10_000 * one_2048eth, half_electra, 3_780_922_816), + (10_000 * one_32eth, part_electra, 849_509_408), + (10_000 * one_2048eth, part_electra, 32_000_000_000), + ], + ids=[ + "1 bounded slashing with 32 EB, half active validators with 2048 EB and the rest part with 32 EB", + "1 bounded slashing with 2048 EB, half active validators with 2048 EB and the rest part with 32 EB", + "1 bounded slashing with 32 EB, 10% active validators with 2048 EB and the rest part with 32 EB", + "1 bounded slashing with 2048 EB, 10% active validators with 2048 EB and the rest part with 32 EB", + "100 bounded slashing with 32 EB, half active validators with 2048 EB and the rest part with 32 EB", + "100 bounded slashing with 2048 EB, half active validators with 2048 EB and the rest part with 32 EB", + "100 bounded slashing with 32 EB, 10% active validators with 2048 EB and the rest part with 32 EB", + "100 bounded slashing with 2048 EB, 10% active validators with 2048 EB and the rest part with 32 EB", + "10_000 bounded slashing with 32 EB, half active validators with 2048 EB and the rest part with 32 EB", + "10_000 bounded slashing with 2048 EB, half active validators with 2048 EB and the rest part with 32 EB", + "10_000 bounded slashing with 32 EB, 10% active validators with 2048 EB and the rest part with 32 EB", + "10_000 bounded slashing with 2048 EB, 10% active validators with 2048 EB and the rest part with 32 EB", ], ) -def test_get_midterm_penalty(bounded_slashings_count, active_validators_count, expected_penalty): +def test_get_midterm_penalty(bounded_slashed_validators, active_validators, expected_penalty): result = MidtermSlashingPenalty.get_validator_midterm_penalty( - simple_validators(0, 0)[0], bounded_slashings_count, active_validators_count * 32 * 10**9 + validator=simple_validators(0, 0)[0], + bound_slashed_validators=bounded_slashed_validators, + total_balance=Gwei(sum(int(v.validator.effective_balance) for v in active_validators)), ) assert result == expected_penalty diff --git a/tests/modules/accounting/test_accounting_module.py b/tests/modules/accounting/test_accounting_module.py index c0124b555..cf357bb25 100644 --- a/tests/modules/accounting/test_accounting_module.py +++ b/tests/modules/accounting/test_accounting_module.py @@ -6,6 +6,7 @@ from web3.types import Wei from src import variables +from src.constants import MIN_ACTIVATION_BALANCE from src.modules.accounting import accounting as accounting_module from src.modules.accounting.accounting import Accounting from src.modules.accounting.accounting import logger as accounting_logger @@ -21,7 +22,6 @@ from tests.factory.configs import ChainConfigFactory, FrameConfigFactory from tests.factory.contract_responses import LidoReportRebaseFactory from tests.factory.no_registry import LidoValidatorFactory, StakingModuleFactory -from tests.web3_extentions.test_lido_validators import blockstamp @pytest.fixture(autouse=True) @@ -101,13 +101,18 @@ def test_get_updated_modules_stats(accounting: Accounting): @pytest.mark.usefixtures("lido_validators") def test_get_consensus_lido_state(accounting: Accounting): bs = ReferenceBlockStampFactory.build() - validators = LidoValidatorFactory.batch(10) + validators = [ + *[LidoValidatorFactory.build_pending_deposit_vals() for _ in range(3)], + *[LidoValidatorFactory.build_not_active_vals(bs.ref_epoch) for _ in range(3)], + *[LidoValidatorFactory.build_active_vals(bs.ref_epoch) for _ in range(2)], + *[LidoValidatorFactory.build_exit_vals(bs.ref_epoch) for _ in range(2)], + ] accounting.w3.lido_validators.get_lido_validators = Mock(return_value=validators) count, balance = accounting._get_consensus_lido_state(bs) assert count == 10 - assert balance == sum((int(val.balance) for val in validators)) + assert balance == sum((int(val.balance) for val in validators)) + 3 * MIN_ACTIVATION_BALANCE @pytest.mark.unit diff --git a/tests/modules/accounting/test_withdrawal_integration.py b/tests/modules/accounting/test_withdrawal_integration.py index 8c31233db..8ce27ca98 100644 --- a/tests/modules/accounting/test_withdrawal_integration.py +++ b/tests/modules/accounting/test_withdrawal_integration.py @@ -1,8 +1,8 @@ import pytest +from src.constants import SHARE_RATE_PRECISION_E27 from src.modules.submodules.types import FrameConfig, ChainConfig from src.services.withdrawal import Withdrawal -from src.constants import SHARE_RATE_PRECISION_E27 from tests.conftest import get_blockstamp_by_state diff --git a/tests/modules/ejector/test_ejector.py b/tests/modules/ejector/test_ejector.py index cbc267cc9..3ee64e0e4 100644 --- a/tests/modules/ejector/test_ejector.py +++ b/tests/modules/ejector/test_ejector.py @@ -5,17 +5,17 @@ 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 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.types import BlockStamp, ReferenceBlockStamp +from src.types import BlockStamp, _Fork as Fork, Gwei, ReferenceBlockStamp from src.utils import validator_state from src.web3py.extensions.contracts import LidoContracts -from src.web3py.extensions.lido_validators import NodeOperatorId, StakingModuleId +from src.web3py.extensions.lido_validators import LidoValidator, NodeOperatorId, StakingModuleId from src.web3py.types import Web3 from tests.factory.base_oracle import EjectorProcessingStateFactory from tests.factory.blockstamp import BlockStampFactory, ReferenceBlockStampFactory @@ -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.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.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.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( @@ -226,13 +224,14 @@ def test_is_contract_reportable(ejector: Ejector, blockstamp: BlockStamp) -> Non @pytest.mark.unit def test_get_predicted_withdrawable_epoch(ejector: Ejector) -> None: + ejector.fork = Mock(return_value=Fork.CAPELLA) 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" @@ -248,12 +247,39 @@ 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 @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, @@ -275,7 +301,7 @@ def test_get_withdrawable_lido_validators( ) 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() @@ -283,22 +309,29 @@ def test_get_withdrawable_lido_validators( @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, "Expected validator's balance in gwei" + + validator = LidoValidatorFactory.build_with_balance(Gwei(MAX_EFFECTIVE_BALANCE + 1)) result = ejector._get_predicted_withdrawable_balance(validator) - assert result == 42 * 10**9, "Expected validator's balance in gwei" + assert result == MAX_EFFECTIVE_BALANCE, "Expect MAX_EFFECTIVE_BALANCE" - validator = LidoValidatorFactory.build(balance=str(MAX_EFFECTIVE_BALANCE + 1)) + 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 @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 +339,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.consensus_version = Mock(return_value=1) with monkeypatch.context() as m: m.setattr( @@ -340,6 +374,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.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) diff --git a/tests/modules/submodules/test_oracle_module.py b/tests/modules/submodules/test_oracle_module.py index cb302bb81..0eaabc791 100644 --- a/tests/modules/submodules/test_oracle_module.py +++ b/tests/modules/submodules/test_oracle_module.py @@ -1,4 +1,4 @@ -from unittest.mock import Mock +from unittest.mock import Mock, patch, MagicMock from typing import Type import pytest @@ -92,41 +92,51 @@ def _throw_on_third_call(): @pytest.mark.parametrize( "ex", [ - DecoratorTimeoutError, - NoActiveProviderError, - RequestsConnectionError, - NotOkResponse, - NoSlotsAvailable, - SlotNotFinalized, - InconsistentData, - KeysOutdatedException, + DecoratorTimeoutError("Fake exception"), + NoActiveProviderError("Fake exception"), + RequestsConnectionError("Fake exception"), + NotOkResponse(status=500, text="Fake exception"), + NoSlotsAvailable("Fake exception"), + SlotNotFinalized("Fake exception"), + InconsistentData("Fake exception"), + KeysOutdatedException("Fake exception"), ], + ids=lambda param: f"{type(param).__name__}", ) -def test_run_cycle_no_fail_on_retryable_error(oracle: BaseModule, ex: Type[Exception]): - def _throw_with(*args): - if ex is NotOkResponse: - raise ex(status=500, text="Fake exception") # type: ignore - raise ex("Fake exception") - - oracle.execute_module = Mock(side_effect=_throw_with) - - ret = oracle.run_cycle(ReferenceBlockStampFactory.build()) - assert ret is ModuleExecuteDelay.NEXT_SLOT +def test_cycle_no_fail_on_retryable_error(oracle: BaseModule, ex: Exception): + oracle.w3.lido_contracts = MagicMock() + with patch.object( + oracle, "_receive_last_finalized_slot", return_value=MagicMock(slot_number=1111111) + ), patch.object(oracle.w3.lido_contracts, "has_contract_address_changed", return_value=False), patch.object( + oracle, "execute_module", side_effect=ex + ): + oracle._cycle() + # test node availability + with patch.object(oracle, "_receive_last_finalized_slot", side_effect=ex): + oracle._cycle() @pytest.mark.unit @pytest.mark.parametrize( "ex", [ - IsNotMemberException, - IncompatibleOracleVersion, + IsNotMemberException("Fake exception"), + IncompatibleOracleVersion("Fake exception"), ], + ids=lambda param: f"{type(param).__name__}", ) -def test_run_cycle_fails_on_critical_exceptions(oracle: BaseModule, ex: Type[Exception]): - def _throw_with(*args): - raise ex("Fake exception") - - oracle.execute_module = Mock(side_effect=_throw_with) - - with pytest.raises(ex, match="Fake exception"): - oracle.run_cycle(ReferenceBlockStampFactory.build()) +def test_run_cycle_fails_on_critical_exceptions(oracle: BaseModule, ex: Exception): + oracle.w3.lido_contracts = MagicMock() + with patch.object( + oracle, "_receive_last_finalized_slot", return_value=MagicMock(slot_number=1111111) + ), patch.object(oracle.w3.lido_contracts, "has_contract_address_changed", return_value=False), patch.object( + oracle, "execute_module", side_effect=ex + ), pytest.raises( + type(ex), match="Fake exception" + ): + oracle._cycle() + # test node availability + with patch.object(oracle, "_receive_last_finalized_slot", side_effect=ex), pytest.raises( + type(ex), match="Fake exception" + ): + oracle._cycle() diff --git a/tests/providers/consensus/test_consensus_client.py b/tests/providers/consensus/test_consensus_client.py index c58aaf4f0..71965375d 100644 --- a/tests/providers/consensus/test_consensus_client.py +++ b/tests/providers/consensus/test_consensus_client.py @@ -71,6 +71,24 @@ def test_get_validators(consensus_client: ConsensusClient): assert validator_by_pub_key[0] == validator +@pytest.mark.integration +def test_get_fork(consensus_client: ConsensusClient): + fork = consensus_client.get_fork("head") + assert fork >= fork.GENESIS + + +@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 + + fork = consensus_client.get_fork(state_view.slot) + if fork >= fork.ELECTRA: + 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_build.py b/tests/utils/test_build.py index 8decb00aa..66b357b13 100644 --- a/tests/utils/test_build.py +++ b/tests/utils/test_build.py @@ -7,11 +7,10 @@ class TestGetBuildInfo(unittest.TestCase): - @patch('os.path.exists', return_value=True) @patch( 'builtins.open', new_callable=mock_open, read_data='{"version": "1.0.0", "branch": "main", "commit": "abc123"}' ) - def test_get_build_info_success(self, mock_open_file, mock_exists): + def test_get_build_info_success(self, mock_open_file): """Test that get_build_info successfully reads from the JSON file.""" expected_build_info = {"version": "1.0.0", "branch": "main", "commit": "abc123"} @@ -19,17 +18,14 @@ def test_get_build_info_success(self, mock_open_file, mock_exists): build_info = get_build_info() # Assertions - mock_exists.assert_called_once_with("./build-info.json") mock_open_file.assert_called_once_with("./build-info.json", "r") self.assertEqual(build_info, expected_build_info, "Build info should match the data from the file") - @patch('os.path.exists', return_value=False) - def test_get_build_info_file_not_exists(self, mock_exists): + def test_get_build_info_file_not_exists(self): """Test that get_build_info returns UNKNOWN_BUILD_INFO when the file does not exist.""" build_info = get_build_info() # Assertions - mock_exists.assert_called_once_with("./build-info.json") self.assertEqual(build_info, UNKNOWN_BUILD_INFO, "Should return UNKNOWN_BUILD_INFO when file doesn't exist") @patch('os.path.exists', return_value=True) @@ -41,6 +37,5 @@ def test_get_build_info_json_decode_error(self, mock_open_file, mock_exists): build_info = get_build_info() # Assertions - mock_exists.assert_called_once_with("./build-info.json") mock_open_file.assert_called_once_with("./build-info.json", "r") self.assertEqual(build_info, UNKNOWN_BUILD_INFO, "Should return UNKNOWN_BUILD_INFO when JSON decode fails") diff --git a/tests/utils/test_types.py b/tests/utils/test_types.py index 78bdcefd3..f06c7d379 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 @@ -15,3 +15,20 @@ 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" + + +@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 677763b3c..596753128 100644 --- a/tests/utils/test_validator_state_utils.py +++ b/tests/utils/test_validator_state_utils.py @@ -1,21 +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 -from src.providers.consensus.types import Validator, ValidatorStatus, ValidatorState +from src.constants import ( + EFFECTIVE_BALANCE_INCREMENT, + FAR_FUTURE_EPOCH, + MAX_EFFECTIVE_BALANCE_ELECTRA, + MIN_ACTIVATION_BALANCE, +) +from src.providers.consensus.types import Validator, ValidatorState, ValidatorStatus from src.types import EpochNumber, Gwei from src.utils.validator_state import ( + calculate_active_effective_balance_sum, calculate_total_active_effective_balance, - is_on_exit, + compute_activation_exit_epoch, + get_balance_churn_limit, + get_max_effective_balance, get_validator_age, - calculate_active_effective_balance_sum, - is_validator_eligible_to_exit, - is_fully_withdrawable_validator, - is_partially_withdrawable_validator, + has_compounding_withdrawal_credential, has_eth1_withdrawal_credential, - is_exited_validator, + has_execution_withdrawal_credential, is_active_validator, - compute_activation_exit_epoch, + is_exited_validator, + is_fully_withdrawable_validator, + is_on_exit, + is_partially_withdrawable_validator, + is_validator_eligible_to_exit, + compute_exit_balance_churn_limit, ) from tests.factory.no_registry import ValidatorFactory from tests.modules.accounting.bunker.test_bunker_abnormal_cl_rebase import simple_validators @@ -144,6 +154,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 +192,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_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, balance, epoch, expected): +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 +241,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 +278,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): @@ -296,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 = compute_exit_balance_churn_limit(total_active_balance) + assert actual_limit == expected_limit, "Unexpected exit churn limit" diff --git a/tests/web3_extentions/test_lido_validators.py b/tests/web3_extentions/test_lido_validators.py index 2a28b37ab..bd26579b1 100644 --- a/tests/web3_extentions/test_lido_validators.py +++ b/tests/web3_extentions/test_lido_validators.py @@ -2,6 +2,7 @@ import pytest +from src.constants import MIN_ACTIVATION_BALANCE from src.modules.accounting.types import BeaconStat from src.web3py.extensions.lido_validators import CountOfKeysDiffersException from tests.factory.blockstamp import ReferenceBlockStampFactory @@ -38,6 +39,24 @@ def test_get_lido_validators(web3, lido_validators, contracts): assert v.lido_id.key == v.validator.pubkey +@pytest.mark.unit +def test_calc_pending_deposits_sum(web3, lido_validators, contracts): + validators = [ValidatorFactory.build_pending_deposit_vals() for _ in range(5)] + validators.extend(ValidatorFactory.batch(30)) + lido_keys = LidoKeyFactory.generate_for_validators(validators[:15]) + lido_keys.extend(LidoKeyFactory.batch(10)) + + web3.lido_validators._kapi_sanity_check = Mock() + + web3.cc.get_validators = Mock(return_value=validators) + web3.kac.get_used_lido_keys = Mock(return_value=lido_keys) + + lido_validators = web3.lido_validators.get_lido_validators(blockstamp) + pending_deposits_sum = web3.lido_validators.calculate_pending_deposits_sum(lido_validators) + + assert pending_deposits_sum == 5 * MIN_ACTIVATION_BALANCE + + @pytest.mark.unit def test_kapi_has_lesser_keys_than_deposited_validators_count(web3, lido_validators, contracts): validators = ValidatorFactory.batch(10)