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)