From 62d7bcb9196bc1f1df4b3437c4a7d5cb6fe79ce1 Mon Sep 17 00:00:00 2001 From: cyc60 Date: Tue, 27 Jan 2026 20:16:47 +0300 Subject: [PATCH 01/19] Consolidation: handle target key Signed-off-by: cyc60 --- src/commands/consolidate.py | 52 +++++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/src/commands/consolidate.py b/src/commands/consolidate.py index 4047b8a6..481eebf4 100644 --- a/src/commands/consolidate.py +++ b/src/commands/consolidate.py @@ -210,6 +210,12 @@ def consolidate( ' --source-public-keys-file or --source-public-keys.' ) + if not any([source_public_keys, source_public_keys_file]) and target_public_key: + raise click.ClickException( + 'One of these parameters must be provided with target-public-key:' + ' --source-public-keys-file or --source-public-keys.' + ) + if source_public_keys_file: source_public_keys = _load_public_keys(source_public_keys_file) @@ -327,6 +333,7 @@ async def process( target_source = await _find_target_source_public_keys( vault_address=vault_address, chain_head=chain_head, + target_public_key=target_public_key, exclude_public_keys=exclude_public_keys, ) if not target_source: @@ -578,10 +585,11 @@ def _load_public_keys(public_keys_file: Path) -> list[HexStr]: return public_keys -# pylint: disable-next=too-many-locals +# pylint: disable-next=too-many-locals,too-many-branches async def _find_target_source_public_keys( vault_address: ChecksumAddress, chain_head: ChainHead, + target_public_key: HexStr | None, exclude_public_keys: set[HexStr], ) -> list[tuple[ConsensusValidator, ConsensusValidator]]: """ @@ -629,14 +637,37 @@ async def _find_target_source_public_keys( return [] source_validators.sort(key=lambda val: val.activation_epoch) - target_validator_candidates = [val for val in validator_candidates if val.is_compounding] - - if not target_validator_candidates: - # there are no 0x02 validators, switch the oldest 0x01 to 0x02 - return [(source_validators[0], source_validators[0])] + if target_public_key: + target_validators = [val for val in all_validators if val.public_key == target_public_key] + if not target_validators: + raise click.ClickException( + f'Validator {target_public_key} not found in the consensus layer.' + ) + target_validator = target_validators[0] + if target_validator.status in EXITING_STATUSES: + raise click.ClickException( + f'Target validator {target_public_key} is in exiting ' + f'status {target_validator.status.value}.' + ) + if target_validator.index in consolidating_indexes: + raise click.ClickException( + f'Target validator {target_public_key} is consolidating to another validator.' + ) + if target_validator.public_key in exclude_public_keys: + raise click.ClickException( + f'Target validator {target_public_key} is excluded from consolidation.' + ) + if not target_validator.is_compounding: + # switch the 0x01 to 0x02 + return [(target_validator, target_validator)] + else: + target_validator_candidates = [val for val in validator_candidates if val.is_compounding] + if not target_validator_candidates: + # there are no 0x02 validators, switch the oldest 0x01 to 0x02 + return [(source_validators[0], source_validators[0])] - # there is at least one 0x02 validator, top up the one with smallest balance - target_validator = min(target_validator_candidates, key=lambda val: val.balance) + # there is at least one 0x02 validator, top up the one with smallest balance + target_validator = min(target_validator_candidates, key=lambda val: val.balance) selected_source_validators: list[ConsensusValidator] = [] target_balance = target_validator.balance @@ -650,6 +681,11 @@ async def _find_target_source_public_keys( if selected_source_validators: return [(target_validator, val) for val in selected_source_validators] + if target_public_key: + raise click.ClickException( + 'Target validator is almost full, cannot consolidate any source validator...' + ) + # Target validator is almost full, switch the oldest 0x01 to 0x02 return [(source_validators[0], source_validators[0])] From 305f0d735df3b4521928d55ecd919aceb266780a Mon Sep 17 00:00:00 2001 From: cyc60 Date: Tue, 27 Jan 2026 20:33:51 +0300 Subject: [PATCH 02/19] Version bump Signed-off-by: cyc60 --- README.md | 6 +++--- pyproject.toml | 2 +- scripts/install.sh | 2 +- src/commands/consolidate.py | 13 +++++++------ 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 66b00aa0..bbaf39e8 100644 --- a/README.md +++ b/README.md @@ -98,14 +98,14 @@ operator COMMAND --flagA=123 --flagB=xyz Pull the latest docker Operator docker image: ```bash -docker pull europe-west4-docker.pkg.dev/stakewiselabs/public/v3-operator:v4.1.4 +docker pull europe-west4-docker.pkg.dev/stakewiselabs/public/v3-operator:v4.1.5 ``` You can also build the docker image from source by cloning this repo and executing the following command from within the `v3-operator` folder: ```bash -docker build --pull -t europe-west4-docker.pkg.dev/stakewiselabs/public/v3-operator:v4.1.4 . +docker build --pull -t europe-west4-docker.pkg.dev/stakewiselabs/public/v3-operator:v4.1.5 . ``` You will execute Operator Service commands using the format below (note the use of flags are optional): @@ -114,7 +114,7 @@ You will execute Operator Service commands using the format below (note the use docker run --rm -ti \ -u $(id -u):$(id -g) \ -v ~/.stakewise/:/data \ -europe-west4-docker.pkg.dev/stakewiselabs/public/v3-operator:v4.1.4 \ +europe-west4-docker.pkg.dev/stakewiselabs/public/v3-operator:v4.1.5 \ src/main.py COMMAND \ --flagA=123 \ --flagB=xyz diff --git a/pyproject.toml b/pyproject.toml index 9fa47840..5919d65e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "v3-operator" -version = "v4.1.4" +version = "v4.1.5" description = "StakeWise operator service for registering vault validators" authors = ["StakeWise Labs "] package-mode = false diff --git a/scripts/install.sh b/scripts/install.sh index 8c9567d8..ee781984 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -288,7 +288,7 @@ http_copy() { github_release() { owner_repo=$1 version=$2 - test -z "$version" && version="v4.1.4" + test -z "$version" && version="v4.1.5" giturl="https://github.com/${owner_repo}/releases/${version}" json=$(http_copy "$giturl" "Accept:application/json") test -z "$json" && return 1 diff --git a/src/commands/consolidate.py b/src/commands/consolidate.py index 481eebf4..44910be6 100644 --- a/src/commands/consolidate.py +++ b/src/commands/consolidate.py @@ -210,12 +210,6 @@ def consolidate( ' --source-public-keys-file or --source-public-keys.' ) - if not any([source_public_keys, source_public_keys_file]) and target_public_key: - raise click.ClickException( - 'One of these parameters must be provided with target-public-key:' - ' --source-public-keys-file or --source-public-keys.' - ) - if source_public_keys_file: source_public_keys = _load_public_keys(source_public_keys_file) @@ -659,6 +653,13 @@ async def _find_target_source_public_keys( ) if not target_validator.is_compounding: # switch the 0x01 to 0x02 + max_activation_epoch = chain_head.epoch - settings.network_config.SHARD_COMMITTEE_PERIOD + if target_validator.activation_epoch > max_activation_epoch: + raise click.ClickException( + f'Validator {target_public_key} is not active enough for consolidation. ' + f'It must be active for at least ' + f'{settings.network_config.SHARD_COMMITTEE_PERIOD} epochs before consolidation.' + ) return [(target_validator, target_validator)] else: target_validator_candidates = [val for val in validator_candidates if val.is_compounding] From 3dcbdb71a8b6d1bddca9db9e05b6f4d98cabd804 Mon Sep 17 00:00:00 2001 From: cyc60 Date: Tue, 27 Jan 2026 20:42:47 +0300 Subject: [PATCH 03/19] Review fix Signed-off-by: cyc60 --- src/commands/consolidate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/consolidate.py b/src/commands/consolidate.py index 44910be6..80c6fa81 100644 --- a/src/commands/consolidate.py +++ b/src/commands/consolidate.py @@ -684,7 +684,7 @@ async def _find_target_source_public_keys( if target_public_key: raise click.ClickException( - 'Target validator is almost full, cannot consolidate any source validator...' + 'Target validator has insufficient capacity to consolidate any source validators.' ) # Target validator is almost full, switch the oldest 0x01 to 0x02 From 76a26998468d5a98d3a3226d4274a666b785d523 Mon Sep 17 00:00:00 2001 From: cyc60 Date: Thu, 29 Jan 2026 18:46:08 +0300 Subject: [PATCH 04/19] Separate consolidation selector Signed-off-by: cyc60 --- src/commands/consolidate.py | 310 +-------------------- src/consolidations/__init__.py | 0 src/validators/consolidation_selector.py | 331 +++++++++++++++++++++++ 3 files changed, 346 insertions(+), 295 deletions(-) create mode 100644 src/consolidations/__init__.py create mode 100644 src/validators/consolidation_selector.py diff --git a/src/commands/consolidate.py b/src/commands/consolidate.py index 80c6fa81..1a24884a 100644 --- a/src/commands/consolidate.py +++ b/src/commands/consolidate.py @@ -15,7 +15,6 @@ from src.common.consolidations import ( get_consolidation_request_fee, get_consolidations_count, - get_pending_consolidations, ) from src.common.contracts import VaultContract from src.common.execution import check_gas_price @@ -30,15 +29,13 @@ validate_public_keys_file, ) from src.common.wallet import wallet -from src.common.withdrawals import get_pending_partial_withdrawals from src.config.config import OperatorConfig from src.config.networks import AVAILABLE_NETWORKS, GNOSIS, MAINNET, NETWORKS from src.config.settings import DEFAULT_MAX_CONSOLIDATION_REQUEST_FEE_GWEI, settings -from src.validators.consensus import EXITING_STATUSES, fetch_consensus_validators +from src.validators.consolidation_selector import ConsolidationSelector from src.validators.oracles import poll_consolidation_signature from src.validators.register_validators import submit_consolidate_validators from src.validators.relayer import RelayerClient -from src.validators.typings import ConsensusValidator logger = logging.getLogger(__name__) @@ -209,6 +206,11 @@ def consolidate( 'One of these parameters must be provided with target-public-key:' ' --source-public-keys-file or --source-public-keys.' ) + if any([source_public_keys, source_public_keys_file]) and not target_public_key: + raise click.ClickException( + 'target-public-key must be provided with one of these parameters:' + ' --source-public-keys-file or --source-public-keys.' + ) if source_public_keys_file: source_public_keys = _load_public_keys(source_public_keys_file) @@ -314,26 +316,16 @@ async def process( await _check_validators_manager(vault_address) await _check_consolidations_queue(chain_head) - if source_public_keys is not None and target_public_key is not None: - # keys provided by the user - target_source = await _check_public_keys( - vault_address=vault_address, - source_public_keys=source_public_keys, - target_public_key=target_public_key, - chain_head=chain_head, - ) - else: - target_source = await _find_target_source_public_keys( - vault_address=vault_address, - chain_head=chain_head, - target_public_key=target_public_key, - exclude_public_keys=exclude_public_keys, - ) - if not target_source: - raise click.ClickException( - f'Validators in vault {vault_address} can\'t be consolidated' - ) + consolidation_selector = await ConsolidationSelector.create( + source_public_keys=source_public_keys, + target_public_key=target_public_key, + chain_head=chain_head, + exclude_public_keys=exclude_public_keys, + ) + target_source = await consolidation_selector.get_target_source() + if not target_source: + raise click.ClickException(f'Validators in vault {vault_address} can\'t be consolidated') for target_validator, source_validator in target_source: if source_validator.index == target_validator.index: @@ -409,124 +401,6 @@ async def process( ) -# pylint: disable-next=too-many-branches,too-many-locals -async def _check_public_keys( - vault_address: ChecksumAddress, - source_public_keys: list[HexStr], - target_public_key: HexStr, - chain_head: ChainHead, -) -> list[tuple[ConsensusValidator, ConsensusValidator]]: - """ - Validate that provided public keys can be consolidated - and returns the target and source validators info. - """ - logger.info('Checking selected validators for consolidation...') - - # Validate that source public keys are unique - if len(source_public_keys) != len(set(source_public_keys)): - raise click.ClickException('Source public keys must be unique.') - - # Validate the switch from 0x01 to 0x02 and consolidation to another validator - if len(source_public_keys) > 1 and target_public_key in source_public_keys: - raise click.ClickException( - 'Cannot switch from 0x01 to 0x02 and consolidate ' - 'to another validator in the same request.' - ) - - # Fetch source and target validators - validators = await fetch_consensus_validators( - list(set(source_public_keys + [target_public_key])) - ) - pubkey_to_validator = {val.public_key: val for val in validators} - - source_validators: list[ConsensusValidator] = [] - max_activation_epoch = chain_head.epoch - settings.network_config.SHARD_COMMITTEE_PERIOD - - current_consolidations = await get_pending_consolidations(chain_head, validators) - consolidating_indexes: set[int] = set() - for cons in current_consolidations: - consolidating_indexes.add(cons.source_index) - - # Validate source public keys - for source_public_key in source_public_keys: - source_validator = pubkey_to_validator.get(source_public_key) - - if not source_validator: - raise click.ClickException( - f'Validator {source_public_key} not found in the consensus layer.' - ) - - # Validate the source validator status - if source_validator.status in EXITING_STATUSES: - raise click.ClickException( - f'Validator {source_public_key} is in exiting ' - f'status {source_validator.status.value}.' - ) - - # Validate the source has been active long enough - if source_validator.activation_epoch > max_activation_epoch: - raise click.ClickException( - f'Validator {source_validator.public_key} is not active enough for consolidation. ' - f'It must be active for at least ' - f'{settings.network_config.SHARD_COMMITTEE_PERIOD} epochs before consolidation.' - ) - - # Validate the source validator is not consolidating - if source_validator.index in consolidating_indexes: - raise click.ClickException( - f'Validator {source_validator.public_key} is consolidating to another validator.' - ) - source_validators.append(source_validator) - - # Validate target public key - target_validator = pubkey_to_validator.get(target_public_key) - if not target_validator: - raise click.ClickException( - f'Target validator {target_public_key} not found in the consensus layer.' - ) - if target_validator.status in EXITING_STATUSES: - raise click.ClickException( - f'Target validator {target_public_key} is in exiting ' - f'status {target_validator.status.value}.' - ) - if target_validator.index in consolidating_indexes: - raise click.ClickException( - f'Target validator {target_public_key} is consolidating to another validator.' - ) - - # Validate that target validator is a compounding validator. - # Not required for a switch from 0x01 to 0x02. - if not _is_switch_to_compounding(source_public_keys, target_public_key): - if not target_validator.is_compounding: - raise click.ClickException( - f'The target validator {target_public_key} is not a compounding validator.' - ) - - # Validate the source validators has no pending withdrawals in the queue - await _check_pending_balance_to_withdraw(chain_head, source_validators) - - # Validate the source and target validators are in the vault - logger.info('Fetching vault validators...') - vault_validators = await VaultContract(vault_address).get_registered_validators_public_keys( - from_block=settings.vault_first_block, - to_block=chain_head.block_number, - ) - for public_keys in source_public_keys + [target_public_key]: - if public_keys not in vault_validators: - raise click.ClickException( - f'Validator {public_keys} is not registered in the vault {vault_address}.' - ) - - # Validate the total balance won't exceed the max effective balance - if sum(val.balance for val in validators) > settings.max_validator_balance_gwei: - raise click.ClickException( - 'Cannot consolidate validators,' - f' total balance exceed {settings.max_validator_balance_gwei} Gwei' - ) - - return [(target_validator, source_validator) for source_validator in source_validators] - - async def _check_validators_manager(vault_address: ChecksumAddress) -> None: if settings.relayer_endpoint: return @@ -547,18 +421,6 @@ async def _check_consolidations_queue(chain_head: ChainHead) -> None: ) -async def _check_pending_balance_to_withdraw( - chain_head: ChainHead, validators: list[ConsensusValidator] -) -> None: - """Verify the source validators has no pending withdrawals in the queue""" - pending_partial_withdrawals = await get_pending_partial_withdrawals(chain_head, validators) - if pending_partial_withdrawals: - indexes = ', '.join(str(w.validator_index) for w in pending_partial_withdrawals) - raise click.ClickException( - f'Validators with indexes {indexes} have pending partial withdrawals in the queue. ' - ) - - def _encode_validators(target_source_public_keys: list[tuple[HexStr, HexStr]]) -> bytes: validators_data = b'' for target_key, source_key in target_source_public_keys: @@ -567,10 +429,6 @@ def _encode_validators(target_source_public_keys: list[tuple[HexStr, HexStr]]) - return validators_data -def _is_switch_to_compounding(source_public_keys: list[HexStr], target_public_key: HexStr) -> bool: - return len(source_public_keys) == 1 and source_public_keys[0] == target_public_key - - def _load_public_keys(public_keys_file: Path) -> list[HexStr]: """Loads public keys from file.""" with open(public_keys_file, 'r', encoding='utf-8') as f: @@ -579,144 +437,6 @@ def _load_public_keys(public_keys_file: Path) -> list[HexStr]: return public_keys -# pylint: disable-next=too-many-locals,too-many-branches -async def _find_target_source_public_keys( - vault_address: ChecksumAddress, - chain_head: ChainHead, - target_public_key: HexStr | None, - exclude_public_keys: set[HexStr], -) -> list[tuple[ConsensusValidator, ConsensusValidator]]: - """ - If there are no 0x02 validators, - take the oldest 0x01 validator and convert it to 0x02 with confirmation prompt. - If there is 0x02 validator, - take the oldest 0x01 validators to top up its balance to 2048 ETH / 64 GNO. - """ - logger.info('Fetching vault validators...') - vault_contract = VaultContract(vault_address) - public_keys = await vault_contract.get_registered_validators_public_keys( - from_block=settings.vault_first_block, - to_block=chain_head.block_number, - ) - all_validators = await fetch_consensus_validators(public_keys) - - # use all validators to fetch all the consolidations - # including the ones were source validator is exiting - current_consolidations = await get_pending_consolidations(chain_head, all_validators) - consolidating_indexes: set[int] = set() - for cons in current_consolidations: - consolidating_indexes.add(cons.source_index) - consolidating_indexes.add(cons.target_index) - - # Candidates on the role of either source or target validator - validator_candidates: list[ConsensusValidator] = [] - - for val in all_validators: - if val.status in EXITING_STATUSES: - continue - if val.index in consolidating_indexes: - continue - if val.public_key in exclude_public_keys: - continue - validator_candidates.append(val) - - if not validator_candidates: - return [] - - source_validators = await _get_source_validators( - chain_head=chain_head, - validator_candidates=validator_candidates, - ) - if not source_validators: - return [] - - source_validators.sort(key=lambda val: val.activation_epoch) - if target_public_key: - target_validators = [val for val in all_validators if val.public_key == target_public_key] - if not target_validators: - raise click.ClickException( - f'Validator {target_public_key} not found in the consensus layer.' - ) - target_validator = target_validators[0] - if target_validator.status in EXITING_STATUSES: - raise click.ClickException( - f'Target validator {target_public_key} is in exiting ' - f'status {target_validator.status.value}.' - ) - if target_validator.index in consolidating_indexes: - raise click.ClickException( - f'Target validator {target_public_key} is consolidating to another validator.' - ) - if target_validator.public_key in exclude_public_keys: - raise click.ClickException( - f'Target validator {target_public_key} is excluded from consolidation.' - ) - if not target_validator.is_compounding: - # switch the 0x01 to 0x02 - max_activation_epoch = chain_head.epoch - settings.network_config.SHARD_COMMITTEE_PERIOD - if target_validator.activation_epoch > max_activation_epoch: - raise click.ClickException( - f'Validator {target_public_key} is not active enough for consolidation. ' - f'It must be active for at least ' - f'{settings.network_config.SHARD_COMMITTEE_PERIOD} epochs before consolidation.' - ) - return [(target_validator, target_validator)] - else: - target_validator_candidates = [val for val in validator_candidates if val.is_compounding] - if not target_validator_candidates: - # there are no 0x02 validators, switch the oldest 0x01 to 0x02 - return [(source_validators[0], source_validators[0])] - - # there is at least one 0x02 validator, top up the one with smallest balance - target_validator = min(target_validator_candidates, key=lambda val: val.balance) - - selected_source_validators: list[ConsensusValidator] = [] - target_balance = target_validator.balance - - for val in source_validators: - if target_balance + val.balance > settings.max_validator_balance_gwei: - break - selected_source_validators.append(val) - target_balance += val.balance # type: ignore - - if selected_source_validators: - return [(target_validator, val) for val in selected_source_validators] - - if target_public_key: - raise click.ClickException( - 'Target validator has insufficient capacity to consolidate any source validators.' - ) - - # Target validator is almost full, switch the oldest 0x01 to 0x02 - return [(source_validators[0], source_validators[0])] - - -async def _get_source_validators( - chain_head: ChainHead, - validator_candidates: list[ConsensusValidator], -) -> list[ConsensusValidator]: - max_activation_epoch = chain_head.epoch - settings.network_config.SHARD_COMMITTEE_PERIOD - - pending_partial_withdrawals = await get_pending_partial_withdrawals( - chain_head=chain_head, consensus_validators=validator_candidates - ) - pending_partial_withdrawals_indexes = { - withdrawal.validator_index for withdrawal in pending_partial_withdrawals - } - - source_validators = [] - for val in validator_candidates: - if val.is_compounding: - continue - if val.activation_epoch >= max_activation_epoch: - continue - if val.index in pending_partial_withdrawals_indexes: - continue - source_validators.append(val) - - return source_validators - - async def _get_validators_manager_signature( vault_address: ChecksumAddress, target_source_public_keys: list[tuple[HexStr, HexStr]] ) -> HexStr: diff --git a/src/consolidations/__init__.py b/src/consolidations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/validators/consolidation_selector.py b/src/validators/consolidation_selector.py new file mode 100644 index 00000000..718993c9 --- /dev/null +++ b/src/validators/consolidation_selector.py @@ -0,0 +1,331 @@ +import logging + +import click +from eth_typing import HexStr +from sw_utils import ChainHead + +from src.common.consolidations import get_pending_consolidations +from src.common.contracts import VaultContract +from src.common.withdrawals import get_pending_partial_withdrawals +from src.config.settings import settings +from src.validators.consensus import EXITING_STATUSES, fetch_consensus_validators +from src.validators.typings import ConsensusValidator + +logger = logging.getLogger(__name__) + + +class ConsolidationSelector: + vault_validators: list[HexStr] + consensus_validators: list[ConsensusValidator] + consolidating_indexes: set[int] + pending_partial_withdrawals_indexes: set[int] + exclude_public_keys: set[HexStr] + + @classmethod + async def create( + cls, + source_public_keys: list[HexStr] | None, + target_public_key: HexStr | None, + chain_head: ChainHead, + exclude_public_keys: set[HexStr], + ) -> 'ConsolidationSelector': + klass: type[ConsolidationSelectorSelector] | type[ConsolidationSelectorChecker] + if source_public_keys is not None: + klass = ConsolidationSelectorChecker + else: + klass = ConsolidationSelectorSelector + self = klass( + source_public_keys=source_public_keys, + target_public_key=target_public_key, + chain_head=chain_head, + exclude_public_keys=exclude_public_keys, + ) + logger.info('Fetching vault validators...') + self.vault_validators = await VaultContract( + settings.vault + ).get_registered_validators_public_keys( + from_block=settings.vault_first_block, + to_block=self.chain_head.block_number, + ) + if source_public_keys is not None and target_public_key is not None: + self.consensus_validators = await fetch_consensus_validators( + list(set(source_public_keys + [target_public_key])) + ) + else: + self.consensus_validators = await fetch_consensus_validators(self.vault_validators) + + pending_partial_withdrawals = await get_pending_partial_withdrawals( + chain_head, self.consensus_validators + ) + pending_consolidations = await get_pending_consolidations( + chain_head, self.consensus_validators + ) + self.consolidating_indexes = set() + for cons in pending_consolidations: + self.consolidating_indexes.add(cons.source_index) + self.consolidating_indexes.add(cons.target_index) # todo + + self.pending_partial_withdrawals_indexes = set() + for withdrawal in pending_partial_withdrawals: + self.pending_partial_withdrawals_indexes.add(withdrawal.validator_index) + return self + + def __init__( + self, + source_public_keys: list[HexStr] | None, + target_public_key: HexStr | None, + chain_head: ChainHead, + exclude_public_keys: set[HexStr], + ): + self.source_public_keys = source_public_keys + self.target_public_key = target_public_key + self.chain_head = chain_head + self.exclude_public_keys = exclude_public_keys + + async def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator]]: + ''' + # Source validators must be: + - unique + - in the vault + - not exiting + - active for at least SHARD_COMMITTEE_PERIOD epochs + - not consolidating to another validator + - not consolidations from another validator + - have no pending partial withdrawals in the queue + - total balance that won't exceed the max effective balance when consolidated + # Target validator must be: + - in the vault + - not exiting + - not consolidating to another validator + - a compounding validator + + # For switch from 0x01 to 0x02: + - source and target public keys are the same + - in the vault + - not exiting + - active for at least SHARD_COMMITTEE_PERIOD epochs + ''' + raise NotImplementedError() + + def _validate_target_validator( + self, + is_switch: bool | None = None, + ) -> ConsensusValidator: + target_validators = [ + val for val in self.consensus_validators if val.public_key == self.target_public_key + ] + if not target_validators: + raise click.ClickException( + f'Validator {self.target_public_key} not found in the consensus layer.' + ) + target_validator = target_validators[0] + if target_validator.status in EXITING_STATUSES: + raise click.ClickException( + f'Target validator {self.target_public_key} is in exiting ' + f'status {target_validator.status.value}.' + ) + if target_validator.index in self.consolidating_indexes: + raise click.ClickException( + f'Target validator {self.target_public_key} is consolidating to another validator.' + ) + if target_validator.public_key in self.exclude_public_keys: + raise click.ClickException( + f'Target validator {self.target_public_key} is excluded from consolidation.' + ) + + if is_switch is None: + is_switch = not target_validator.is_compounding + + if is_switch: + if target_validator.is_compounding: + raise click.ClickException( + f'Target validator {self.target_public_key} is already a compounding validator.' + ) + # switch the 0x01 to 0x02 + if target_validator.activation_epoch > self.max_activation_epoch: + raise click.ClickException( + f'Validator {self.target_public_key} is not active enough for consolidation. ' + f'It must be active for at least ' + f'{settings.network_config.SHARD_COMMITTEE_PERIOD} epochs before consolidation.' + ) + return target_validator + + @property + def max_activation_epoch(self) -> int: + return self.chain_head.epoch - settings.network_config.SHARD_COMMITTEE_PERIOD + + +# 2 subclasses +class ConsolidationSelectorSelector(ConsolidationSelector): + async def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator]]: + """ + If there are no 0x02 validators, + take the oldest 0x01 validator and convert it to 0x02 with confirmation prompt. + If there is 0x02 validator, + take the oldest 0x01 validators to top up its balance to 2048 ETH / 64 GNO. + """ + # Candidates on the role of either source or target validator + + source_validators_candidates, target_validator_candidates = ( + self._find_validators_candidates() + ) + if not target_validator_candidates: + return [] + + source_validators_candidates.sort(key=lambda val: val.activation_epoch) + if self.target_public_key: + target_validator = self._validate_target_validator() + if not target_validator.is_compounding: + return [(target_validator, target_validator)] + + else: + target_validator_candidates = [ + val for val in target_validator_candidates if val.is_compounding + ] + if not target_validator_candidates: + # there are no 0x02 validators, switch the oldest 0x01 to 0x02 + return [(source_validators_candidates[0], source_validators_candidates[0])] + + # there is at least one 0x02 validator, top up the one with smallest balance + target_validator = min(target_validator_candidates, key=lambda val: val.balance) + + selected_source_validators: list[ConsensusValidator] = [] + target_balance = target_validator.balance + + for val in source_validators_candidates: + if target_balance + val.balance > settings.max_validator_balance_gwei: + break + selected_source_validators.append(val) + target_balance += val.balance # type: ignore + + if selected_source_validators: + return [(target_validator, val) for val in selected_source_validators] + + if self.target_public_key: + raise click.ClickException( + 'Target validator has insufficient capacity to consolidate any source validators.' + ) + + # Target validator is almost full, switch the oldest 0x01 to 0x02 + return [(selected_source_validators[0], selected_source_validators[0])] + + def _find_validators_candidates( + self, + ) -> tuple[list[ConsensusValidator], list[ConsensusValidator]]: + source_validators: list[ConsensusValidator] = [] + target_validators: list[ConsensusValidator] = [] + for val in self.consensus_validators: + if val.status in EXITING_STATUSES: + continue + if val.index in self.consolidating_indexes: + continue + if val.public_key in self.exclude_public_keys: + continue + target_validators.append(val) + + # source + if val.is_compounding: + continue + if val.activation_epoch >= self.max_activation_epoch: + continue + if val.index in self.pending_partial_withdrawals_indexes: + continue + source_validators.append(val) + return source_validators, target_validators + + +class ConsolidationSelectorChecker(ConsolidationSelector): + async def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator]]: + """ + Validate that provided public keys can be consolidated + and returns the target and source validators info. + + """ + if self.source_public_keys is None or self.target_public_key is None: + raise click.ClickException( + 'Both source_public_keys and target_public_key must be provided for checking.' + ) + + logger.info('Checking selected validators for consolidation...') + + # Validate that source public keys are unique + if len(self.source_public_keys) != len(set(self.source_public_keys)): + raise click.ClickException('Source public keys must be unique.') + + # Validate the switch from 0x01 to 0x02 and consolidation to another validator + if len(self.source_public_keys) > 1 and self.target_public_key in self.source_public_keys: + raise click.ClickException( + 'Cannot switch from 0x01 to 0x02 and consolidate ' + 'to another validator in the same request.' + ) + + # Validate the source and target validators are in the vault + for public_keys in self.source_public_keys + [self.target_public_key]: + if public_keys not in self.vault_validators: + raise click.ClickException( + f'Validator {public_keys} is not registered in the vault {settings.vault}.' + ) + + # Validate target public key + is_switch = is_switch_to_compounding(self.source_public_keys, self.target_public_key) + target_validator = self._validate_target_validator(is_switch=is_switch) + if is_switch: + return [(target_validator, target_validator)] + + # Validate source public keys + pubkey_to_validator = {val.public_key: val for val in self.consensus_validators} + source_validators: list[ConsensusValidator] = [] + for source_public_key in self.source_public_keys: + source_validator = pubkey_to_validator.get(source_public_key) + if not source_validator: + raise click.ClickException( + f'Validator {source_public_key} not found in the consensus layer.' + ) + + # Validate the source validator status + if source_validator.status in EXITING_STATUSES: + raise click.ClickException( + f'Validator {source_public_key} is in exiting ' + f'status {source_validator.status.value}.' + ) + + # Validate the source has been active long enough + if source_validator.activation_epoch > self.max_activation_epoch: + raise click.ClickException( + f'Validator {source_validator.public_key}' + f' is not active enough for consolidation. ' + f'It must be active for at least ' + f'{settings.network_config.SHARD_COMMITTEE_PERIOD}' + f' epochs before consolidation.' + ) + + # Validate the source validator is not consolidating + if source_validator.index in self.consolidating_indexes: + raise click.ClickException( + f'Validator {source_validator.public_key} ' + f'is consolidating to another validator.' + ) + source_validators.append(source_validator) + + # Validate the source validators has no pending withdrawals in the queue + if source_validator.index in self.pending_partial_withdrawals_indexes: + raise click.ClickException( + f'Validator {source_validator.public_key} ' + f'have pending partial withdrawals in the queue. ' + ) + + # Validate the total balance won't exceed the max effective balance + if ( + sum(val.balance for val in self.consensus_validators) + > settings.max_validator_balance_gwei + ): + raise click.ClickException( + 'Cannot consolidate validators,' + f' total balance exceed {settings.max_validator_balance_gwei} Gwei' + ) + + return [(target_validator, source_validator) for source_validator in source_validators] + + +def is_switch_to_compounding(source_public_keys: list[HexStr], target_public_key: HexStr) -> bool: + return len(source_public_keys) == 1 and source_public_keys[0] == target_public_key From a7e8665f97bed06cc236545b18995ad901a4d649 Mon Sep 17 00:00:00 2001 From: cyc60 Date: Thu, 29 Jan 2026 19:49:56 +0300 Subject: [PATCH 05/19] Refactor consolidation manager Signed-off-by: cyc60 --- src/commands/consolidate.py | 14 +- ...n_selector.py => consolidation_manager.py} | 231 +++++++++--------- src/validators/typings.py | 10 + 3 files changed, 134 insertions(+), 121 deletions(-) rename src/validators/{consolidation_selector.py => consolidation_manager.py} (81%) diff --git a/src/commands/consolidate.py b/src/commands/consolidate.py index 1a24884a..0f076001 100644 --- a/src/commands/consolidate.py +++ b/src/commands/consolidate.py @@ -32,10 +32,11 @@ from src.config.config import OperatorConfig from src.config.networks import AVAILABLE_NETWORKS, GNOSIS, MAINNET, NETWORKS from src.config.settings import DEFAULT_MAX_CONSOLIDATION_REQUEST_FEE_GWEI, settings -from src.validators.consolidation_selector import ConsolidationSelector +from src.validators.consolidation_manager import ConsolidationManager from src.validators.oracles import poll_consolidation_signature from src.validators.register_validators import submit_consolidate_validators from src.validators.relayer import RelayerClient +from src.validators.typings import ConsolidationKeys logger = logging.getLogger(__name__) @@ -317,9 +318,14 @@ async def process( await _check_validators_manager(vault_address) await _check_consolidations_queue(chain_head) - consolidation_selector = await ConsolidationSelector.create( - source_public_keys=source_public_keys, - target_public_key=target_public_key, + consolidation_keys = None + if source_public_keys and target_public_key: + consolidation_keys = ConsolidationKeys( + source_public_keys=source_public_keys, + target_public_key=target_public_key, + ) + consolidation_selector = await ConsolidationManager.create( + consolidation_keys=consolidation_keys, chain_head=chain_head, exclude_public_keys=exclude_public_keys, ) diff --git a/src/validators/consolidation_selector.py b/src/validators/consolidation_manager.py similarity index 81% rename from src/validators/consolidation_selector.py rename to src/validators/consolidation_manager.py index 718993c9..62b522b8 100644 --- a/src/validators/consolidation_selector.py +++ b/src/validators/consolidation_manager.py @@ -9,12 +9,13 @@ from src.common.withdrawals import get_pending_partial_withdrawals from src.config.settings import settings from src.validators.consensus import EXITING_STATUSES, fetch_consensus_validators -from src.validators.typings import ConsensusValidator +from src.validators.typings import ConsensusValidator, ConsolidationKeys logger = logging.getLogger(__name__) -class ConsolidationSelector: +class ConsolidationManager: + chain_head: ChainHead vault_validators: list[HexStr] consensus_validators: list[ConsensusValidator] consolidating_indexes: set[int] @@ -24,22 +25,21 @@ class ConsolidationSelector: @classmethod async def create( cls, - source_public_keys: list[HexStr] | None, - target_public_key: HexStr | None, + consolidation_keys: ConsolidationKeys | None, chain_head: ChainHead, exclude_public_keys: set[HexStr], - ) -> 'ConsolidationSelector': - klass: type[ConsolidationSelectorSelector] | type[ConsolidationSelectorChecker] - if source_public_keys is not None: - klass = ConsolidationSelectorChecker + ) -> 'ConsolidationManager': + self: ConsolidationManager + if consolidation_keys is not None: + self = ConsolidationChecker( + consolidation_keys=consolidation_keys, + chain_head=chain_head, + ) else: - klass = ConsolidationSelectorSelector - self = klass( - source_public_keys=source_public_keys, - target_public_key=target_public_key, - chain_head=chain_head, - exclude_public_keys=exclude_public_keys, - ) + self = ConsolidationSelector( + chain_head=chain_head, + exclude_public_keys=exclude_public_keys, + ) logger.info('Fetching vault validators...') self.vault_validators = await VaultContract( settings.vault @@ -47,9 +47,9 @@ async def create( from_block=settings.vault_first_block, to_block=self.chain_head.block_number, ) - if source_public_keys is not None and target_public_key is not None: + if consolidation_keys is not None: self.consensus_validators = await fetch_consensus_validators( - list(set(source_public_keys + [target_public_key])) + consolidation_keys.all_public_keys ) else: self.consensus_validators = await fetch_consensus_validators(self.vault_validators) @@ -70,18 +70,6 @@ async def create( self.pending_partial_withdrawals_indexes.add(withdrawal.validator_index) return self - def __init__( - self, - source_public_keys: list[HexStr] | None, - target_public_key: HexStr | None, - chain_head: ChainHead, - exclude_public_keys: set[HexStr], - ): - self.source_public_keys = source_public_keys - self.target_public_key = target_public_key - self.chain_head = chain_head - self.exclude_public_keys = exclude_public_keys - async def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator]]: ''' # Source validators must be: @@ -107,56 +95,20 @@ async def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusVal ''' raise NotImplementedError() - def _validate_target_validator( - self, - is_switch: bool | None = None, - ) -> ConsensusValidator: - target_validators = [ - val for val in self.consensus_validators if val.public_key == self.target_public_key - ] - if not target_validators: - raise click.ClickException( - f'Validator {self.target_public_key} not found in the consensus layer.' - ) - target_validator = target_validators[0] - if target_validator.status in EXITING_STATUSES: - raise click.ClickException( - f'Target validator {self.target_public_key} is in exiting ' - f'status {target_validator.status.value}.' - ) - if target_validator.index in self.consolidating_indexes: - raise click.ClickException( - f'Target validator {self.target_public_key} is consolidating to another validator.' - ) - if target_validator.public_key in self.exclude_public_keys: - raise click.ClickException( - f'Target validator {self.target_public_key} is excluded from consolidation.' - ) - - if is_switch is None: - is_switch = not target_validator.is_compounding - - if is_switch: - if target_validator.is_compounding: - raise click.ClickException( - f'Target validator {self.target_public_key} is already a compounding validator.' - ) - # switch the 0x01 to 0x02 - if target_validator.activation_epoch > self.max_activation_epoch: - raise click.ClickException( - f'Validator {self.target_public_key} is not active enough for consolidation. ' - f'It must be active for at least ' - f'{settings.network_config.SHARD_COMMITTEE_PERIOD} epochs before consolidation.' - ) - return target_validator - @property def max_activation_epoch(self) -> int: return self.chain_head.epoch - settings.network_config.SHARD_COMMITTEE_PERIOD -# 2 subclasses -class ConsolidationSelectorSelector(ConsolidationSelector): +class ConsolidationSelector(ConsolidationManager): + def __init__( + self, + chain_head: ChainHead, + exclude_public_keys: set[HexStr], + ): + self.chain_head = chain_head + self.exclude_public_keys = exclude_public_keys + async def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator]]: """ If there are no 0x02 validators, @@ -173,21 +125,15 @@ async def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusVal return [] source_validators_candidates.sort(key=lambda val: val.activation_epoch) - if self.target_public_key: - target_validator = self._validate_target_validator() - if not target_validator.is_compounding: - return [(target_validator, target_validator)] - - else: - target_validator_candidates = [ - val for val in target_validator_candidates if val.is_compounding - ] - if not target_validator_candidates: - # there are no 0x02 validators, switch the oldest 0x01 to 0x02 - return [(source_validators_candidates[0], source_validators_candidates[0])] + target_validator_candidates = [ + val for val in target_validator_candidates if val.is_compounding + ] + if not target_validator_candidates: + # there are no 0x02 validators, switch the oldest 0x01 to 0x02 + return [(source_validators_candidates[0], source_validators_candidates[0])] - # there is at least one 0x02 validator, top up the one with smallest balance - target_validator = min(target_validator_candidates, key=lambda val: val.balance) + # there is at least one 0x02 validator, top up the one with smallest balance + target_validator = min(target_validator_candidates, key=lambda val: val.balance) selected_source_validators: list[ConsensusValidator] = [] target_balance = target_validator.balance @@ -201,11 +147,6 @@ async def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusVal if selected_source_validators: return [(target_validator, val) for val in selected_source_validators] - if self.target_public_key: - raise click.ClickException( - 'Target validator has insufficient capacity to consolidate any source validators.' - ) - # Target validator is almost full, switch the oldest 0x01 to 0x02 return [(selected_source_validators[0], selected_source_validators[0])] @@ -223,7 +164,7 @@ def _find_validators_candidates( continue target_validators.append(val) - # source + # additional filters for source validators if val.is_compounding: continue if val.activation_epoch >= self.max_activation_epoch: @@ -234,30 +175,22 @@ def _find_validators_candidates( return source_validators, target_validators -class ConsolidationSelectorChecker(ConsolidationSelector): +class ConsolidationChecker(ConsolidationManager): + def __init__( + self, + consolidation_keys: ConsolidationKeys, + chain_head: ChainHead, + ): + self.consolidation_keys = consolidation_keys + self.chain_head = chain_head + async def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator]]: """ Validate that provided public keys can be consolidated and returns the target and source validators info. - """ - if self.source_public_keys is None or self.target_public_key is None: - raise click.ClickException( - 'Both source_public_keys and target_public_key must be provided for checking.' - ) - logger.info('Checking selected validators for consolidation...') - - # Validate that source public keys are unique - if len(self.source_public_keys) != len(set(self.source_public_keys)): - raise click.ClickException('Source public keys must be unique.') - - # Validate the switch from 0x01 to 0x02 and consolidation to another validator - if len(self.source_public_keys) > 1 and self.target_public_key in self.source_public_keys: - raise click.ClickException( - 'Cannot switch from 0x01 to 0x02 and consolidate ' - 'to another validator in the same request.' - ) + self._validate_public_keys() # Validate the source and target validators are in the vault for public_keys in self.source_public_keys + [self.target_public_key]: @@ -267,9 +200,8 @@ async def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusVal ) # Validate target public key - is_switch = is_switch_to_compounding(self.source_public_keys, self.target_public_key) - target_validator = self._validate_target_validator(is_switch=is_switch) - if is_switch: + target_validator = self._validate_target_validator() + if self.is_switch_to_compounding(): return [(target_validator, target_validator)] # Validate source public keys @@ -326,6 +258,71 @@ async def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusVal return [(target_validator, source_validator) for source_validator in source_validators] + def _validate_public_keys(self) -> None: + if self.source_public_keys is None or self.target_public_key is None: + raise click.ClickException( + 'Both source_public_keys and target_public_key must be provided for checking.' + ) + # Validate that source public keys are unique + if len(self.source_public_keys) != len(set(self.source_public_keys)): + raise click.ClickException('Source public keys must be unique.') + + # Validate the switch from 0x01 to 0x02 and consolidation to another validator + if len(self.source_public_keys) > 1 and self.target_public_key in self.source_public_keys: + raise click.ClickException( + 'Cannot switch from 0x01 to 0x02 and consolidate ' + 'to another validator in the same request.' + ) + + def _validate_target_validator( + self, + ) -> ConsensusValidator: + target_validators = [ + val for val in self.consensus_validators if val.public_key == self.target_public_key + ] + if not target_validators: + raise click.ClickException( + f'Validator {self.target_public_key} not found in the consensus layer.' + ) + target_validator = target_validators[0] + if target_validator.status in EXITING_STATUSES: + raise click.ClickException( + f'Target validator {self.target_public_key} is in exiting ' + f'status {target_validator.status.value}.' + ) + if target_validator.index in self.consolidating_indexes: + raise click.ClickException( + f'Target validator {self.target_public_key} is consolidating to another validator.' + ) + if target_validator.public_key in self.exclude_public_keys: + raise click.ClickException( + f'Target validator {self.target_public_key} is excluded from consolidation.' + ) + + if self.is_switch_to_compounding(): + if target_validator.is_compounding: + raise click.ClickException( + f'Target validator {self.target_public_key} is already a compounding validator.' + ) + # switch the 0x01 to 0x02 + if target_validator.activation_epoch > self.max_activation_epoch: + raise click.ClickException( + f'Validator {self.target_public_key} is not active enough for consolidation. ' + f'It must be active for at least ' + f'{settings.network_config.SHARD_COMMITTEE_PERIOD} epochs before consolidation.' + ) + return target_validator + + def is_switch_to_compounding(self) -> bool: + return ( + len(self.source_public_keys) == 1 + and self.source_public_keys[0] == self.target_public_key + ) + + @property + def source_public_keys(self) -> list[HexStr]: + return self.consolidation_keys.source_public_keys -def is_switch_to_compounding(source_public_keys: list[HexStr], target_public_key: HexStr) -> bool: - return len(source_public_keys) == 1 and source_public_keys[0] == target_public_key + @property + def target_public_key(self) -> HexStr: + return self.consolidation_keys.target_public_key diff --git a/src/validators/typings.py b/src/validators/typings.py index d93abdd7..04a85b5e 100644 --- a/src/validators/typings.py +++ b/src/validators/typings.py @@ -112,3 +112,13 @@ class ApprovalRequest: class ConsolidationRequest: public_keys: list[HexStr] vault_address: ChecksumAddress + + +@dataclass +class ConsolidationKeys: + source_public_keys: list[HexStr] + target_public_key: HexStr + + @property + def all_public_keys(self) -> list[HexStr]: + return list(set(self.source_public_keys + [self.target_public_key])) From 89abaa6d29982f211c7c89c3f57717cccd311cd7 Mon Sep 17 00:00:00 2001 From: cyc60 Date: Mon, 2 Feb 2026 14:02:32 +0300 Subject: [PATCH 06/19] Add tests Signed-off-by: cyc60 --- src/commands/consolidate.py | 2 +- src/validators/consolidation_manager.py | 14 +- .../tests/test_consolidation_manager.py | 612 ++++++++++++++++++ 3 files changed, 618 insertions(+), 10 deletions(-) create mode 100644 src/validators/tests/test_consolidation_manager.py diff --git a/src/commands/consolidate.py b/src/commands/consolidate.py index 0f076001..8bc978e5 100644 --- a/src/commands/consolidate.py +++ b/src/commands/consolidate.py @@ -329,7 +329,7 @@ async def process( chain_head=chain_head, exclude_public_keys=exclude_public_keys, ) - target_source = await consolidation_selector.get_target_source() + target_source = consolidation_selector.get_target_source() if not target_source: raise click.ClickException(f'Validators in vault {vault_address} can\'t be consolidated') diff --git a/src/validators/consolidation_manager.py b/src/validators/consolidation_manager.py index 62b522b8..e92675b0 100644 --- a/src/validators/consolidation_manager.py +++ b/src/validators/consolidation_manager.py @@ -70,7 +70,7 @@ async def create( self.pending_partial_withdrawals_indexes.add(withdrawal.validator_index) return self - async def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator]]: + def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator]]: ''' # Source validators must be: - unique @@ -109,7 +109,7 @@ def __init__( self.chain_head = chain_head self.exclude_public_keys = exclude_public_keys - async def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator]]: + def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator]]: """ If there are no 0x02 validators, take the oldest 0x01 validator and convert it to 0x02 with confirmation prompt. @@ -121,7 +121,7 @@ async def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusVal source_validators_candidates, target_validator_candidates = ( self._find_validators_candidates() ) - if not target_validator_candidates: + if not source_validators_candidates or not target_validator_candidates: return [] source_validators_candidates.sort(key=lambda val: val.activation_epoch) @@ -165,7 +165,7 @@ def _find_validators_candidates( target_validators.append(val) # additional filters for source validators - if val.is_compounding: + if val.is_compounding: # todo continue if val.activation_epoch >= self.max_activation_epoch: continue @@ -184,7 +184,7 @@ def __init__( self.consolidation_keys = consolidation_keys self.chain_head = chain_head - async def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator]]: + def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator]]: """ Validate that provided public keys can be consolidated and returns the target and source validators info. @@ -294,10 +294,6 @@ def _validate_target_validator( raise click.ClickException( f'Target validator {self.target_public_key} is consolidating to another validator.' ) - if target_validator.public_key in self.exclude_public_keys: - raise click.ClickException( - f'Target validator {self.target_public_key} is excluded from consolidation.' - ) if self.is_switch_to_compounding(): if target_validator.is_compounding: diff --git a/src/validators/tests/test_consolidation_manager.py b/src/validators/tests/test_consolidation_manager.py new file mode 100644 index 00000000..21eee43b --- /dev/null +++ b/src/validators/tests/test_consolidation_manager.py @@ -0,0 +1,612 @@ +import click +import pytest +from eth_typing import HexStr +from sw_utils import ChainHead +from sw_utils.tests import faker + +from src.common.tests.factories import create_chain_head +from src.common.tests.utils import ether_to_gwei +from src.config.settings import settings +from src.validators.consensus import EXITING_STATUSES +from src.validators.consolidation_manager import ( + ConsolidationChecker, + ConsolidationSelector, +) +from src.validators.tests.factories import create_consensus_validator +from src.validators.typings import ConsensusValidator, ConsolidationKeys + + +@pytest.mark.usefixtures('fake_settings') +class TestConsolidationSelector: + async def test_empty_list_when_no_target_validators(self): + selector = create_manager( + vault_validators=[], + consensus_validators=[], + ) + result = selector.get_target_source() + assert result == [] + + async def test_switches_oldest_0x01_to_0x02(self): + consensus_validators = [ + create_consensus_validator( + activation_epoch=1, + is_compounding=False, + ), + ] + selector = create_manager( + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + ) + result = selector.get_target_source() + assert result == [(consensus_validators[0], consensus_validators[0])] + + async def test_consolidation_with_single_compounding(self): + consensus_validators = [ + create_consensus_validator( + activation_epoch=1, + is_compounding=False, + ), + create_consensus_validator( + activation_epoch=1, + is_compounding=True, + ), + ] + selector = create_manager( + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + ) + result = selector.get_target_source() + assert result == [(consensus_validators[1], consensus_validators[0])] + + async def test_consolidation_to_smallest_balance(self): + consensus_validators = [ + create_consensus_validator( + activation_epoch=1, + is_compounding=False, + ), + create_consensus_validator( + activation_epoch=1, is_compounding=True, balance=ether_to_gwei(32.1) + ), + create_consensus_validator( + activation_epoch=1, is_compounding=True, balance=ether_to_gwei(32.3) + ), + ] + selector = create_manager( + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + ) + result = selector.get_target_source() + assert result == [(consensus_validators[1], consensus_validators[0])] + + async def test_consolidation_max_balance(self): + consensus_validators = [ + create_consensus_validator( + activation_epoch=1, is_compounding=True, balance=ether_to_gwei(32.0) + ), + create_consensus_validator( + activation_epoch=1, is_compounding=False, balance=ether_to_gwei(32.1) + ), + create_consensus_validator( + activation_epoch=2, is_compounding=False, balance=ether_to_gwei(32.2) + ), + create_consensus_validator( + activation_epoch=3, is_compounding=False, balance=ether_to_gwei(32.3) + ), + ] + settings.max_validator_balance_gwei = ether_to_gwei(100) # todo + selector = create_manager( + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + ) + result = selector.get_target_source() + assert result == [ + (consensus_validators[0], consensus_validators[1]), + (consensus_validators[0], consensus_validators[2]), + ] + + async def test_excludes_consolidating_validators(self): + consensus_validators = [ + create_consensus_validator( + index=10, + activation_epoch=1, + is_compounding=False, + ), + create_consensus_validator( + index=11, + activation_epoch=1, + is_compounding=True, + ), + ] + selector = create_manager( + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + consolidating_indexes={10, 11}, + ) + result = selector.get_target_source() + assert result == [] + + async def test_excludes_pending_partial_withdrawals(self): + consensus_validators = [ + create_consensus_validator( + index=10, + activation_epoch=1, + is_compounding=False, + ), + create_consensus_validator( + index=11, + activation_epoch=1, + is_compounding=True, + ), + ] + selector = create_manager( + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + pending_partial_withdrawals_indexes={10, 11}, + ) + result = selector.get_target_source() + assert result == [] + + async def test_excludes_specified_public_keys(self): + consensus_validators = [ + create_consensus_validator( + index=10, + activation_epoch=1, + is_compounding=False, + ), + ] + selector = create_manager( + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + exclude_public_keys={consensus_validators[0].public_key}, + ) + result = selector.get_target_source() + assert result == [] + + async def test_excludes_exiting_validators(self): + consensus_validators = [ + create_consensus_validator( + activation_epoch=1, + is_compounding=False, + status=status, + ) + for status in EXITING_STATUSES + ] + selector = create_manager( + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + ) + result = selector.get_target_source() + assert result == [] + + async def test_min_activation_epoch(self): + epoch = 10 + consensus_validators = [ + create_consensus_validator( + index=10, + activation_epoch=epoch + settings.network_config.SHARD_COMMITTEE_PERIOD - 1, + is_compounding=False, + ), + ] + selector = create_manager( + chain_head=create_chain_head(epoch), + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + ) + result = selector.get_target_source() + assert result == [] + + +@pytest.mark.usefixtures('fake_settings') +class TestConsolidationChecker: + async def test_empty_list_when_empty_vault(self): + pk = faker.validator_public_key() + consolidation_keys = ConsolidationKeys( + source_public_keys=[pk], + target_public_key=pk, + ) + selector = create_manager( + consolidation_keys=consolidation_keys, + vault_validators=[], + consensus_validators=[], + ) + with pytest.raises( + click.ClickException, + match=f'Validator {pk} is not registered in the vault {settings.vault}.', + ): + selector.get_target_source() + + async def test_switch_from_0x01_to_0x02(self): + pk = faker.validator_public_key() + consolidation_keys = ConsolidationKeys( + source_public_keys=[pk], + target_public_key=pk, + ) + + consensus_validators = [ + create_consensus_validator( + public_key=pk, + activation_epoch=1, + is_compounding=False, + ), + ] + selector = create_manager( + consolidation_keys=consolidation_keys, + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + ) + result = selector.get_target_source() + assert result == [(consensus_validators[0], consensus_validators[0])] + + async def test_switch_from_0x02_to_0x02(self): + pk = faker.validator_public_key() + consolidation_keys = ConsolidationKeys( + source_public_keys=[pk], + target_public_key=pk, + ) + + consensus_validators = [ + create_consensus_validator( + public_key=pk, + activation_epoch=1, + is_compounding=True, + ), + ] + + selector = create_manager( + consolidation_keys=consolidation_keys, + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + ) + with pytest.raises( + click.ClickException, match=f'Target validator {pk} is already a compounding validator.' + ): + selector.get_target_source() + + async def test_consolidation_with_single_compounding(self): + source_pk = faker.validator_public_key() + target_pk = faker.validator_public_key() + consolidation_keys = ConsolidationKeys( + source_public_keys=[source_pk], + target_public_key=target_pk, + ) + + consensus_validators = [ + create_consensus_validator( + public_key=source_pk, + activation_epoch=1, + is_compounding=False, + ), + create_consensus_validator( + public_key=target_pk, + activation_epoch=1, + is_compounding=True, + ), + ] + selector = create_manager( + consolidation_keys=consolidation_keys, + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + ) + result = selector.get_target_source() + assert result == [(consensus_validators[1], consensus_validators[0])] + + async def test_consolidation_to_smallest_balance(self): + source_pk_1 = faker.validator_public_key() + source_pk_2 = faker.validator_public_key() + target_pk_1 = faker.validator_public_key() + target_pk_2 = faker.validator_public_key() + consolidation_keys = ConsolidationKeys( + source_public_keys=[source_pk_1, source_pk_2], + target_public_key=target_pk_1, # This is the intended target, but checker will validate it + ) + + consensus_validators = [ + create_consensus_validator( + public_key=source_pk_1, + activation_epoch=1, + is_compounding=False, + balance=ether_to_gwei(32.0), + ), + create_consensus_validator( + public_key=source_pk_2, + activation_epoch=1, + is_compounding=False, + balance=ether_to_gwei(32.0), + ), + create_consensus_validator( + public_key=target_pk_1, + index=100, + activation_epoch=1, + is_compounding=True, + balance=ether_to_gwei(32.1), + ), + create_consensus_validator( + public_key=target_pk_2, + index=101, + activation_epoch=1, + is_compounding=True, + balance=ether_to_gwei(32.3), + ), + ] + # Actually, for ConsolidationChecker, it should work with the provided target + # So the test should expect the consolidation to happen to the specified target_pk_1 + selector = create_manager( + consolidation_keys=consolidation_keys, + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + ) + result = selector.get_target_source() + # ConsolidationChecker validates the specific target provided in consolidation_keys + assert result == [ + (consensus_validators[2], consensus_validators[0]), + (consensus_validators[2], consensus_validators[1]), + ] + + async def test_consolidation_max_balance(self): + source_pk_1 = faker.validator_public_key() + source_pk_2 = faker.validator_public_key() + source_pk_3 = faker.validator_public_key() + target_pk = faker.validator_public_key() + consolidation_keys = ConsolidationKeys( + source_public_keys=[source_pk_1, source_pk_2, source_pk_3], + target_public_key=target_pk, + ) + + consensus_validators = [ + create_consensus_validator( + public_key=source_pk_1, + activation_epoch=1, + is_compounding=False, + balance=ether_to_gwei(32.0), + ), + create_consensus_validator( + public_key=source_pk_2, + activation_epoch=1, + is_compounding=False, + balance=ether_to_gwei(32.1), + ), + create_consensus_validator( + public_key=source_pk_3, + activation_epoch=1, + is_compounding=False, + balance=ether_to_gwei(32.2), + ), + create_consensus_validator( + public_key=target_pk, + activation_epoch=1, + is_compounding=True, + balance=ether_to_gwei(32.0), + ), + ] + settings.max_validator_balance_gwei = ether_to_gwei( + 96.0 + ) # Max balance to allow 2 consolidations + selector = create_manager( + consolidation_keys=consolidation_keys, + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + ) + + with pytest.raises( + click.ClickException, match='Cannot consolidate validators, total balance exceed' + ): + selector.get_target_source() + + async def test_excludes_consolidating_validators(self): + source_pk = faker.validator_public_key() + target_pk = faker.validator_public_key() + consolidation_keys = ConsolidationKeys( + source_public_keys=[source_pk], + target_public_key=target_pk, + ) + + consensus_validators = [ + create_consensus_validator( + public_key=source_pk, + index=10, + activation_epoch=1, + is_compounding=False, + ), + create_consensus_validator( + public_key=target_pk, + index=11, + activation_epoch=1, + is_compounding=True, + ), + ] + selector = create_manager( + consolidation_keys=consolidation_keys, + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + consolidating_indexes={10}, # Source validator is consolidating + ) + + with pytest.raises( + click.ClickException, + match=f'Validator {source_pk} is consolidating to another validator.', + ): + selector.get_target_source() + + # Also test when target validator is consolidating + selector = create_manager( + consolidation_keys=consolidation_keys, + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + consolidating_indexes={11}, # Target validator is consolidating + ) + + with pytest.raises( + click.ClickException, + match=f'Target validator {target_pk} is consolidating to another validator.', + ): + selector.get_target_source() + + async def test_excludes_pending_partial_withdrawals(self): + source_pk = faker.validator_public_key() + target_pk = faker.validator_public_key() + consolidation_keys = ConsolidationKeys( + source_public_keys=[source_pk], + target_public_key=target_pk, + ) + + consensus_validators = [ + create_consensus_validator( + public_key=source_pk, + index=10, + activation_epoch=1, + is_compounding=False, + ), + create_consensus_validator( + public_key=target_pk, + index=11, + activation_epoch=1, + is_compounding=True, + ), + ] + selector = create_manager( + consolidation_keys=consolidation_keys, + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + pending_partial_withdrawals_indexes={10}, # Source validator has pending withdrawal + ) + + with pytest.raises( + click.ClickException, + match=f'Validator {source_pk} have pending partial withdrawals in the queue.', + ): + selector.get_target_source() + + async def test_excludes_exiting_validators(self): + source_pk = faker.validator_public_key() + target_pk = faker.validator_public_key() + consolidation_keys = ConsolidationKeys( + source_public_keys=[source_pk], + target_public_key=target_pk, + ) + + # Test with exiting source validator + consensus_validators = [ + create_consensus_validator( + public_key=source_pk, + activation_epoch=1, + is_compounding=False, + status=EXITING_STATUSES[0], + ), + create_consensus_validator( + public_key=target_pk, + activation_epoch=1, + is_compounding=True, + ), + ] + selector = create_manager( + consolidation_keys=consolidation_keys, + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + ) + + with pytest.raises( + click.ClickException, + match=f'Validator {source_pk} is in exiting status {EXITING_STATUSES[0].value}.', + ): + selector.get_target_source() + + # Test with exiting target validator + consensus_validators = [ + create_consensus_validator( + public_key=source_pk, + activation_epoch=1, + is_compounding=False, + ), + create_consensus_validator( + public_key=target_pk, + activation_epoch=1, + is_compounding=True, + status=EXITING_STATUSES[0], + ), + ] + selector = create_manager( + consolidation_keys=consolidation_keys, + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + ) + + with pytest.raises( + click.ClickException, + match=f'Target validator {target_pk} is in exiting status {EXITING_STATUSES[0].value}.', + ): + selector.get_target_source() + + async def test_min_activation_epoch(self): + source_pk = faker.validator_public_key() + target_pk = faker.validator_public_key() + consolidation_keys = ConsolidationKeys( + source_public_keys=[source_pk], + target_public_key=target_pk, + ) + + epoch = 10 + consensus_validators = [ + create_consensus_validator( + public_key=source_pk, + index=10, + activation_epoch=epoch + settings.network_config.SHARD_COMMITTEE_PERIOD - 1, + is_compounding=False, + ), + create_consensus_validator( + public_key=target_pk, + index=11, + activation_epoch=1, + is_compounding=True, + ), + ] + selector = create_manager( + consolidation_keys=consolidation_keys, + chain_head=create_chain_head(epoch), + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + ) + + with pytest.raises( + click.ClickException, + match=f'Validator {consensus_validators[0].public_key} is not active enough for consolidation.', + ): + selector.get_target_source() + + +def create_manager( + consolidation_keys: ConsolidationKeys | None = None, + chain_head: ChainHead | None = None, + exclude_public_keys: set[HexStr] = None, + vault_validators: list[HexStr] = None, + consensus_validators: list[ConsensusValidator] = None, + consolidating_indexes: set[int] = None, + pending_partial_withdrawals_indexes: set[int] = None, +) -> ConsolidationSelector | ConsolidationChecker: + self: ConsolidationChecker | ConsolidationSelector + if chain_head is None: + chain_head = create_chain_head(epoch=1024) + if consolidation_keys is not None: + self = ConsolidationChecker( + consolidation_keys=consolidation_keys, + chain_head=chain_head, + ) + else: + if exclude_public_keys is None: + exclude_public_keys = [] + self = ConsolidationSelector( + chain_head=chain_head, + exclude_public_keys=exclude_public_keys, + ) + self.vault_validators = vault_validators + self.consensus_validators = consensus_validators + + if consolidating_indexes: + self.consolidating_indexes = consolidating_indexes + else: + self.consolidating_indexes = set() + + if pending_partial_withdrawals_indexes: + self.pending_partial_withdrawals_indexes = pending_partial_withdrawals_indexes + else: + self.pending_partial_withdrawals_indexes = set() + return self From 2f03552fee30a5767f116f0ef198c704cc6f033d Mon Sep 17 00:00:00 2001 From: cyc60 Date: Mon, 2 Feb 2026 15:21:57 +0300 Subject: [PATCH 07/19] Split consolidating indexes Signed-off-by: cyc60 --- src/validators/consolidation_manager.py | 31 ++-- .../tests/test_consolidation_manager.py | 145 ++++++++++++++++-- 2 files changed, 158 insertions(+), 18 deletions(-) diff --git a/src/validators/consolidation_manager.py b/src/validators/consolidation_manager.py index e92675b0..da4757b3 100644 --- a/src/validators/consolidation_manager.py +++ b/src/validators/consolidation_manager.py @@ -18,7 +18,8 @@ class ConsolidationManager: chain_head: ChainHead vault_validators: list[HexStr] consensus_validators: list[ConsensusValidator] - consolidating_indexes: set[int] + consolidating_source_indexes: set[int] + consolidating_target_indexes: set[int] pending_partial_withdrawals_indexes: set[int] exclude_public_keys: set[HexStr] @@ -60,10 +61,11 @@ async def create( pending_consolidations = await get_pending_consolidations( chain_head, self.consensus_validators ) - self.consolidating_indexes = set() + self.consolidating_source_indexes = set() + self.consolidating_target_indexes = set() for cons in pending_consolidations: - self.consolidating_indexes.add(cons.source_index) - self.consolidating_indexes.add(cons.target_index) # todo + self.consolidating_source_indexes.add(cons.source_index) + self.consolidating_target_indexes.add(cons.target_index) self.pending_partial_withdrawals_indexes = set() for withdrawal in pending_partial_withdrawals: @@ -148,7 +150,10 @@ def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator return [(target_validator, val) for val in selected_source_validators] # Target validator is almost full, switch the oldest 0x01 to 0x02 - return [(selected_source_validators[0], selected_source_validators[0])] + # Only do this if there are source validators available + if source_validators_candidates: + return [(source_validators_candidates[0], source_validators_candidates[0])] + return [] def _find_validators_candidates( self, @@ -158,7 +163,8 @@ def _find_validators_candidates( for val in self.consensus_validators: if val.status in EXITING_STATUSES: continue - if val.index in self.consolidating_indexes: + # Target validator cannot be used as source in ongoing consolidations + if val.index in self.consolidating_source_indexes: continue if val.public_key in self.exclude_public_keys: continue @@ -169,6 +175,9 @@ def _find_validators_candidates( continue if val.activation_epoch >= self.max_activation_epoch: continue + # Source validator cannot be in any ongoing consolidations (either as source or target) + if val.index in self.consolidating_target_indexes: + continue if val.index in self.pending_partial_withdrawals_indexes: continue source_validators.append(val) @@ -232,7 +241,10 @@ def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator ) # Validate the source validator is not consolidating - if source_validator.index in self.consolidating_indexes: + if ( + source_validator.index in self.consolidating_source_indexes + or source_validator.index in self.consolidating_target_indexes + ): raise click.ClickException( f'Validator {source_validator.public_key} ' f'is consolidating to another validator.' @@ -290,9 +302,10 @@ def _validate_target_validator( f'Target validator {self.target_public_key} is in exiting ' f'status {target_validator.status.value}.' ) - if target_validator.index in self.consolidating_indexes: + # Target validator cannot be used as source in ongoing consolidations + if target_validator.index in self.consolidating_source_indexes: raise click.ClickException( - f'Target validator {self.target_public_key} is consolidating to another validator.' + f'Target validator {self.target_public_key} is involved in another consolidation.' ) if self.is_switch_to_compounding(): diff --git a/src/validators/tests/test_consolidation_manager.py b/src/validators/tests/test_consolidation_manager.py index 21eee43b..70242444 100644 --- a/src/validators/tests/test_consolidation_manager.py +++ b/src/validators/tests/test_consolidation_manager.py @@ -120,7 +120,8 @@ async def test_excludes_consolidating_validators(self): selector = create_manager( vault_validators=[v.public_key for v in consensus_validators], consensus_validators=consensus_validators, - consolidating_indexes={10, 11}, + consolidating_source_indexes={10, 11}, + consolidating_target_indexes={10, 11}, ) result = selector.get_target_source() assert result == [] @@ -195,6 +196,124 @@ async def test_min_activation_epoch(self): result = selector.get_target_source() assert result == [] + async def test_excludes_source_as_target_validator(self): + """Test that target validator is excluded if it's in consolidating_source_indexes""" + consensus_validators = [ + create_consensus_validator( + index=10, + activation_epoch=1, + is_compounding=True, # compounding validator that could be a target + ), + create_consensus_validator( + index=11, + activation_epoch=1, + is_compounding=False, # non-compounding validator that could be a source + ), + ] + selector = create_manager( + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + consolidating_source_indexes={ + 10 + }, # index 10 is in source consolidation, so can't be target + consolidating_target_indexes=set(), + ) + result = selector.get_target_source() + # Should switch validator 11 from 0x01 to 0x02 since there are no valid targets + # but validator 11 is available as source + assert result == [(consensus_validators[1], consensus_validators[1])] + + async def test_excludes_source_validator_in_both_indexes(self): + """Test that source validator is excluded if it's in either source or target consolidating indexes""" + consensus_validators = [ + create_consensus_validator( + index=10, + activation_epoch=1, + is_compounding=False, # non-compounding validator that could be a source + ), + create_consensus_validator( + index=11, + activation_epoch=1, + is_compounding=True, # compounding validator that could be a target + ), + ] + selector = create_manager( + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + consolidating_source_indexes={10}, # index 10 is in source consolidation + consolidating_target_indexes=set(), + ) + result = selector.get_target_source() + # Should be empty because the only potential source (index 10) is excluded + # since it's in consolidating_source_indexes + assert result == [] + + # Test with target indexes too + selector = create_manager( + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + consolidating_source_indexes=set(), + consolidating_target_indexes={10}, # index 10 is in target consolidation + ) + result = selector.get_target_source() + # Should still be empty because the only potential source (index 10) is excluded + # since it's in consolidating_target_indexes + assert result == [] + + async def test_non_excludes_validator_in_target_indexes_as_target(self): + """Test that target validator is not excluded if it's in consolidating_target_indexes""" + consensus_validators = [ + create_consensus_validator( + index=10, + activation_epoch=1, + is_compounding=True, # compounding validator that could be a target + ), + create_consensus_validator( + index=11, + activation_epoch=1, + is_compounding=False, # non-compounding validator that could be a source + ), + ] + selector = create_manager( + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + consolidating_source_indexes=set(), + consolidating_target_indexes={ + 10 + }, # index 10 is in target consolidation, so can't be target again + ) + result = selector.get_target_source() + # Should switch validator 11 from 0x01 to 0x02 since there are no valid targets + # but validator 11 is available as source + assert result == [(consensus_validators[0], consensus_validators[1])] + + async def test_excludes_validator_in_target_indexes_as_source(self): + """Test that source validator is excluded if it's in consolidating_target_indexes""" + consensus_validators = [ + create_consensus_validator( + index=10, + activation_epoch=1, + is_compounding=False, # non-compounding validator that could be a source + ), + create_consensus_validator( + index=11, + activation_epoch=1, + is_compounding=True, # compounding validator that could be a target + ), + ] + selector = create_manager( + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + consolidating_source_indexes=set(), + consolidating_target_indexes={ + 10 + }, # index 10 is in target consolidation, so can't be source + ) + result = selector.get_target_source() + # Should be empty because the only potential source (index 10) is excluded + # since it's in consolidating_target_indexes + assert result == [] + @pytest.mark.usefixtures('fake_settings') class TestConsolidationChecker: @@ -418,7 +537,8 @@ async def test_excludes_consolidating_validators(self): consolidation_keys=consolidation_keys, vault_validators=[v.public_key for v in consensus_validators], consensus_validators=consensus_validators, - consolidating_indexes={10}, # Source validator is consolidating + consolidating_source_indexes={10}, # Source validator is consolidating + consolidating_target_indexes=set(), ) with pytest.raises( @@ -432,12 +552,13 @@ async def test_excludes_consolidating_validators(self): consolidation_keys=consolidation_keys, vault_validators=[v.public_key for v in consensus_validators], consensus_validators=consensus_validators, - consolidating_indexes={11}, # Target validator is consolidating + consolidating_source_indexes={11}, # Target validator is consolidating (as source) + consolidating_target_indexes=set(), ) with pytest.raises( click.ClickException, - match=f'Target validator {target_pk} is consolidating to another validator.', + match=f'Target validator {target_pk} is involved in another consolidation.', ): selector.get_target_source() @@ -579,7 +700,8 @@ def create_manager( exclude_public_keys: set[HexStr] = None, vault_validators: list[HexStr] = None, consensus_validators: list[ConsensusValidator] = None, - consolidating_indexes: set[int] = None, + consolidating_source_indexes: set[int] = None, + consolidating_target_indexes: set[int] = None, pending_partial_withdrawals_indexes: set[int] = None, ) -> ConsolidationSelector | ConsolidationChecker: self: ConsolidationChecker | ConsolidationSelector @@ -592,7 +714,7 @@ def create_manager( ) else: if exclude_public_keys is None: - exclude_public_keys = [] + exclude_public_keys = set() self = ConsolidationSelector( chain_head=chain_head, exclude_public_keys=exclude_public_keys, @@ -600,10 +722,15 @@ def create_manager( self.vault_validators = vault_validators self.consensus_validators = consensus_validators - if consolidating_indexes: - self.consolidating_indexes = consolidating_indexes + if consolidating_source_indexes: + self.consolidating_source_indexes = consolidating_source_indexes + else: + self.consolidating_source_indexes = set() + + if consolidating_target_indexes: + self.consolidating_target_indexes = consolidating_target_indexes else: - self.consolidating_indexes = set() + self.consolidating_target_indexes = set() if pending_partial_withdrawals_indexes: self.pending_partial_withdrawals_indexes = pending_partial_withdrawals_indexes From 04fbcb7593cf2d549f75be277f7e54b69e082448 Mon Sep 17 00:00:00 2001 From: cyc60 Date: Mon, 2 Feb 2026 15:58:44 +0300 Subject: [PATCH 08/19] Update tests Signed-off-by: cyc60 --- src/validators/consolidation_manager.py | 3 +- .../tests/test_consolidation_manager.py | 64 ++++++++++--------- 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/src/validators/consolidation_manager.py b/src/validators/consolidation_manager.py index da4757b3..eb2f6380 100644 --- a/src/validators/consolidation_manager.py +++ b/src/validators/consolidation_manager.py @@ -171,7 +171,8 @@ def _find_validators_candidates( target_validators.append(val) # additional filters for source validators - if val.is_compounding: # todo + # Source validator must be non-compounding + if val.is_compounding: continue if val.activation_epoch >= self.max_activation_epoch: continue diff --git a/src/validators/tests/test_consolidation_manager.py b/src/validators/tests/test_consolidation_manager.py index 70242444..ebfe742b 100644 --- a/src/validators/tests/test_consolidation_manager.py +++ b/src/validators/tests/test_consolidation_manager.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + import click import pytest from eth_typing import HexStr @@ -78,6 +80,7 @@ async def test_consolidation_to_smallest_balance(self): result = selector.get_target_source() assert result == [(consensus_validators[1], consensus_validators[0])] + @pytest.mark.asyncio async def test_consolidation_max_balance(self): consensus_validators = [ create_consensus_validator( @@ -93,16 +96,17 @@ async def test_consolidation_max_balance(self): activation_epoch=3, is_compounding=False, balance=ether_to_gwei(32.3) ), ] - settings.max_validator_balance_gwei = ether_to_gwei(100) # todo - selector = create_manager( - vault_validators=[v.public_key for v in consensus_validators], - consensus_validators=consensus_validators, - ) - result = selector.get_target_source() - assert result == [ - (consensus_validators[0], consensus_validators[1]), - (consensus_validators[0], consensus_validators[2]), - ] + + with patch.object(settings, 'max_validator_balance_gwei', ether_to_gwei(100)): + selector = create_manager( + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + ) + result = selector.get_target_source() + assert result == [ + (consensus_validators[0], consensus_validators[1]), + (consensus_validators[0], consensus_validators[2]), + ] async def test_excludes_consolidating_validators(self): consensus_validators = [ @@ -202,12 +206,12 @@ async def test_excludes_source_as_target_validator(self): create_consensus_validator( index=10, activation_epoch=1, - is_compounding=True, # compounding validator that could be a target + is_compounding=True, ), create_consensus_validator( index=11, activation_epoch=1, - is_compounding=False, # non-compounding validator that could be a source + is_compounding=False, ), ] selector = create_manager( @@ -229,12 +233,12 @@ async def test_excludes_source_validator_in_both_indexes(self): create_consensus_validator( index=10, activation_epoch=1, - is_compounding=False, # non-compounding validator that could be a source + is_compounding=False, ), create_consensus_validator( index=11, activation_epoch=1, - is_compounding=True, # compounding validator that could be a target + is_compounding=True, ), ] selector = create_manager( @@ -266,12 +270,12 @@ async def test_non_excludes_validator_in_target_indexes_as_target(self): create_consensus_validator( index=10, activation_epoch=1, - is_compounding=True, # compounding validator that could be a target + is_compounding=True, ), create_consensus_validator( index=11, activation_epoch=1, - is_compounding=False, # non-compounding validator that could be a source + is_compounding=False, ), ] selector = create_manager( @@ -293,12 +297,12 @@ async def test_excludes_validator_in_target_indexes_as_source(self): create_consensus_validator( index=10, activation_epoch=1, - is_compounding=False, # non-compounding validator that could be a source + is_compounding=False, ), create_consensus_validator( index=11, activation_epoch=1, - is_compounding=True, # compounding validator that could be a target + is_compounding=True, ), ] selector = create_manager( @@ -461,6 +465,7 @@ async def test_consolidation_to_smallest_balance(self): (consensus_validators[2], consensus_validators[1]), ] + @pytest.mark.asyncio async def test_consolidation_max_balance(self): source_pk_1 = faker.validator_public_key() source_pk_2 = faker.validator_public_key() @@ -497,19 +502,18 @@ async def test_consolidation_max_balance(self): balance=ether_to_gwei(32.0), ), ] - settings.max_validator_balance_gwei = ether_to_gwei( - 96.0 - ) # Max balance to allow 2 consolidations - selector = create_manager( - consolidation_keys=consolidation_keys, - vault_validators=[v.public_key for v in consensus_validators], - consensus_validators=consensus_validators, - ) - with pytest.raises( - click.ClickException, match='Cannot consolidate validators, total balance exceed' - ): - selector.get_target_source() + with patch.object(settings, 'max_validator_balance_gwei', ether_to_gwei(96.0)): + selector = create_manager( + consolidation_keys=consolidation_keys, + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + ) + + with pytest.raises( + click.ClickException, match='Cannot consolidate validators, total balance exceed' + ): + selector.get_target_source() async def test_excludes_consolidating_validators(self): source_pk = faker.validator_public_key() From c77f05e5835eed493e83574c25f3a1019c1ce59e Mon Sep 17 00:00:00 2001 From: cyc60 Date: Mon, 2 Feb 2026 16:57:27 +0300 Subject: [PATCH 09/19] Copilot fixes Signed-off-by: cyc60 --- src/commands/consolidate.py | 4 ++-- src/validators/consolidation_manager.py | 6 +++--- src/validators/tests/test_consolidation_manager.py | 2 +- src/validators/typings.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/commands/consolidate.py b/src/commands/consolidate.py index 8bc978e5..e9b69fe8 100644 --- a/src/commands/consolidate.py +++ b/src/commands/consolidate.py @@ -202,12 +202,12 @@ def consolidate( raise click.ClickException( 'Provide only one parameter: either --source-public-keys-file or --source-public-keys.' ) - if not any([source_public_keys, source_public_keys_file]) and target_public_key: + if not (source_public_keys or source_public_keys_file) and target_public_key: raise click.ClickException( 'One of these parameters must be provided with target-public-key:' ' --source-public-keys-file or --source-public-keys.' ) - if any([source_public_keys, source_public_keys_file]) and not target_public_key: + if (source_public_keys or source_public_keys_file) and not target_public_key: raise click.ClickException( 'target-public-key must be provided with one of these parameters:' ' --source-public-keys-file or --source-public-keys.' diff --git a/src/validators/consolidation_manager.py b/src/validators/consolidation_manager.py index eb2f6380..cc92cffa 100644 --- a/src/validators/consolidation_manager.py +++ b/src/validators/consolidation_manager.py @@ -203,10 +203,10 @@ def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator self._validate_public_keys() # Validate the source and target validators are in the vault - for public_keys in self.source_public_keys + [self.target_public_key]: - if public_keys not in self.vault_validators: + for public_key in self.source_public_keys + [self.target_public_key]: + if public_key not in self.vault_validators: raise click.ClickException( - f'Validator {public_keys} is not registered in the vault {settings.vault}.' + f'Validator {public_key} is not registered in the vault {settings.vault}.' ) # Validate target public key diff --git a/src/validators/tests/test_consolidation_manager.py b/src/validators/tests/test_consolidation_manager.py index ebfe742b..8d587619 100644 --- a/src/validators/tests/test_consolidation_manager.py +++ b/src/validators/tests/test_consolidation_manager.py @@ -264,7 +264,7 @@ async def test_excludes_source_validator_in_both_indexes(self): # since it's in consolidating_target_indexes assert result == [] - async def test_non_excludes_validator_in_target_indexes_as_target(self): + async def test_allows_validator_in_target_indexes_as_target(self): """Test that target validator is not excluded if it's in consolidating_target_indexes""" consensus_validators = [ create_consensus_validator( diff --git a/src/validators/typings.py b/src/validators/typings.py index 04a85b5e..1e7b5996 100644 --- a/src/validators/typings.py +++ b/src/validators/typings.py @@ -121,4 +121,4 @@ class ConsolidationKeys: @property def all_public_keys(self) -> list[HexStr]: - return list(set(self.source_public_keys + [self.target_public_key])) + return list(dict.fromkeys(self.source_public_keys + [self.target_public_key])) From 6bfd435debda0e015e6cf79432da031350b911fd Mon Sep 17 00:00:00 2001 From: cyc60 Date: Wed, 11 Feb 2026 15:18:46 +0300 Subject: [PATCH 10/19] Fix target validator check Signed-off-by: cyc60 --- src/consolidations/__init__.py | 0 src/validators/consolidation_manager.py | 18 ++++---- .../tests/test_consolidation_manager.py | 41 +++++++++++++++++-- 3 files changed, 46 insertions(+), 13 deletions(-) delete mode 100644 src/consolidations/__init__.py diff --git a/src/consolidations/__init__.py b/src/consolidations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/validators/consolidation_manager.py b/src/validators/consolidation_manager.py index cc92cffa..4e46af9f 100644 --- a/src/validators/consolidation_manager.py +++ b/src/validators/consolidation_manager.py @@ -80,7 +80,7 @@ def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator - not exiting - active for at least SHARD_COMMITTEE_PERIOD epochs - not consolidating to another validator - - not consolidations from another validator + - not consolidating from another validator - have no pending partial withdrawals in the queue - total balance that won't exceed the max effective balance when consolidated # Target validator must be: @@ -151,9 +151,7 @@ def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator # Target validator is almost full, switch the oldest 0x01 to 0x02 # Only do this if there are source validators available - if source_validators_candidates: - return [(source_validators_candidates[0], source_validators_candidates[0])] - return [] + return [(source_validators_candidates[0], source_validators_candidates[0])] def _find_validators_candidates( self, @@ -256,7 +254,7 @@ def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator if source_validator.index in self.pending_partial_withdrawals_indexes: raise click.ClickException( f'Validator {source_validator.public_key} ' - f'have pending partial withdrawals in the queue. ' + f'has pending partial withdrawals in the queue. ' ) # Validate the total balance won't exceed the max effective balance @@ -272,10 +270,6 @@ def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator return [(target_validator, source_validator) for source_validator in source_validators] def _validate_public_keys(self) -> None: - if self.source_public_keys is None or self.target_public_key is None: - raise click.ClickException( - 'Both source_public_keys and target_public_key must be provided for checking.' - ) # Validate that source public keys are unique if len(self.source_public_keys) != len(set(self.source_public_keys)): raise click.ClickException('Source public keys must be unique.') @@ -321,6 +315,12 @@ def _validate_target_validator( f'It must be active for at least ' f'{settings.network_config.SHARD_COMMITTEE_PERIOD} epochs before consolidation.' ) + else: + if not target_validator.is_compounding: + raise click.ClickException( + f'The target validator {self.target_public_key}' + f' is not a compounding validator.' + ) return target_validator def is_switch_to_compounding(self) -> bool: diff --git a/src/validators/tests/test_consolidation_manager.py b/src/validators/tests/test_consolidation_manager.py index 8d587619..4cb02207 100644 --- a/src/validators/tests/test_consolidation_manager.py +++ b/src/validators/tests/test_consolidation_manager.py @@ -80,7 +80,6 @@ async def test_consolidation_to_smallest_balance(self): result = selector.get_target_source() assert result == [(consensus_validators[1], consensus_validators[0])] - @pytest.mark.asyncio async def test_consolidation_max_balance(self): consensus_validators = [ create_consensus_validator( @@ -597,7 +596,7 @@ async def test_excludes_pending_partial_withdrawals(self): with pytest.raises( click.ClickException, - match=f'Validator {source_pk} have pending partial withdrawals in the queue.', + match=f'Validator {source_pk} has pending partial withdrawals in the queue.', ): selector.get_target_source() @@ -661,6 +660,40 @@ async def test_excludes_exiting_validators(self): ): selector.get_target_source() + async def test_rejects_non_compounding_target(self): + source_pk = faker.validator_public_key() + target_pk = faker.validator_public_key() + consolidation_keys = ConsolidationKeys( + source_public_keys=[source_pk], + target_public_key=target_pk, + ) + + consensus_validators = [ + create_consensus_validator( + public_key=source_pk, + index=10, + activation_epoch=1, + is_compounding=False, + ), + create_consensus_validator( + public_key=target_pk, + index=11, + activation_epoch=1, + is_compounding=False, + ), + ] + selector = create_manager( + consolidation_keys=consolidation_keys, + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + ) + + with pytest.raises( + click.ClickException, + match=f'The target validator {target_pk} is not a compounding validator.', + ): + selector.get_target_source() + async def test_min_activation_epoch(self): source_pk = faker.validator_public_key() target_pk = faker.validator_public_key() @@ -701,8 +734,8 @@ async def test_min_activation_epoch(self): def create_manager( consolidation_keys: ConsolidationKeys | None = None, chain_head: ChainHead | None = None, - exclude_public_keys: set[HexStr] = None, - vault_validators: list[HexStr] = None, + exclude_public_keys: set[HexStr] | None = None, + vault_validators: list[HexStr] | None = None, consensus_validators: list[ConsensusValidator] = None, consolidating_source_indexes: set[int] = None, consolidating_target_indexes: set[int] = None, From f046d070626b2a2364ac57aea7836c510be506b5 Mon Sep 17 00:00:00 2001 From: cyc60 Date: Wed, 11 Feb 2026 15:36:04 +0300 Subject: [PATCH 11/19] Use settings vault Signed-off-by: cyc60 --- src/commands/consolidate.py | 20 ++++++++----------- src/validators/consolidation_manager.py | 7 ++++--- .../tests/test_consolidation_manager.py | 9 ++++----- 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/src/commands/consolidate.py b/src/commands/consolidate.py index e9b69fe8..1d8c211c 100644 --- a/src/commands/consolidate.py +++ b/src/commands/consolidate.py @@ -256,7 +256,6 @@ def consolidate( try: asyncio.run( main( - vault_address=vault, source_public_keys=source_public_keys, target_public_key=target_public_key, exclude_public_keys=exclude_public_keys, @@ -272,7 +271,6 @@ def consolidate( # pylint: disable-next=too-many-arguments async def main( - vault_address: ChecksumAddress, source_public_keys: list[HexStr] | None, target_public_key: HexStr | None, exclude_public_keys: set[HexStr], @@ -284,7 +282,6 @@ async def main( await setup_clients() try: await process( - vault_address=vault_address, source_public_keys=source_public_keys, target_public_key=target_public_key, exclude_public_keys=exclude_public_keys, @@ -298,7 +295,6 @@ async def main( # pylint: disable-next=too-many-locals,too-many-arguments async def process( - vault_address: ChecksumAddress, source_public_keys: list[HexStr] | None, target_public_key: HexStr | None, exclude_public_keys: set[HexStr], @@ -315,7 +311,7 @@ async def process( """ chain_head = await get_chain_latest_head() - await _check_validators_manager(vault_address) + await _check_validators_manager() await _check_consolidations_queue(chain_head) consolidation_keys = None @@ -331,7 +327,7 @@ async def process( ) target_source = consolidation_selector.get_target_source() if not target_source: - raise click.ClickException(f'Validators in vault {vault_address} can\'t be consolidated') + raise click.ClickException(f'Validators in vault {settings.vault} can\'t be consolidated') for target_validator, source_validator in target_source: if source_validator.index == target_validator.index: @@ -383,13 +379,13 @@ async def process( # The oracles signatures are only required when switching from 0x01 to 0x02 oracle_signatures = await poll_consolidation_signature( target_public_keys=[target_source_public_keys[0][0]], - vault=vault_address, + vault=settings.vault, protocol_config=protocol_config, ) encoded_validators = _encode_validators(target_source_public_keys) validators_manager_signature = await _get_validators_manager_signature( - vault_address, target_source_public_keys + target_source_public_keys ) tx_hash = await submit_consolidate_validators( @@ -407,10 +403,10 @@ async def process( ) -async def _check_validators_manager(vault_address: ChecksumAddress) -> None: +async def _check_validators_manager() -> None: if settings.relayer_endpoint: return - vault_contract = VaultContract(vault_address) + vault_contract = VaultContract(settings.vault) validators_manager = await vault_contract.validators_manager() if validators_manager != wallet.account.address: raise click.ClickException( @@ -444,14 +440,14 @@ def _load_public_keys(public_keys_file: Path) -> list[HexStr]: async def _get_validators_manager_signature( - vault_address: ChecksumAddress, target_source_public_keys: list[tuple[HexStr, HexStr]] + target_source_public_keys: list[tuple[HexStr, HexStr]] ) -> HexStr: if not settings.relayer_endpoint: return HexStr('0x') relayer = RelayerClient() # fetch validator manager signature from relayer relayer_response = await relayer.consolidate_validators( - vault_address=vault_address, + vault_address=settings.vault, target_source_public_keys=target_source_public_keys, ) if not relayer_response.validators_manager_signature: diff --git a/src/validators/consolidation_manager.py b/src/validators/consolidation_manager.py index 4e46af9f..af0ae00b 100644 --- a/src/validators/consolidation_manager.py +++ b/src/validators/consolidation_manager.py @@ -248,15 +248,16 @@ def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator f'Validator {source_validator.public_key} ' f'is consolidating to another validator.' ) - source_validators.append(source_validator) # Validate the source validators has no pending withdrawals in the queue if source_validator.index in self.pending_partial_withdrawals_indexes: raise click.ClickException( f'Validator {source_validator.public_key} ' - f'has pending partial withdrawals in the queue. ' + f'has pending partial withdrawals in the queue.' ) + source_validators.append(source_validator) + # Validate the total balance won't exceed the max effective balance if ( sum(val.balance for val in self.consensus_validators) @@ -264,7 +265,7 @@ def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator ): raise click.ClickException( 'Cannot consolidate validators,' - f' total balance exceed {settings.max_validator_balance_gwei} Gwei' + f' total balance exceeds {settings.max_validator_balance_gwei} Gwei' ) return [(target_validator, source_validator) for source_validator in source_validators] diff --git a/src/validators/tests/test_consolidation_manager.py b/src/validators/tests/test_consolidation_manager.py index 4cb02207..c238e9da 100644 --- a/src/validators/tests/test_consolidation_manager.py +++ b/src/validators/tests/test_consolidation_manager.py @@ -464,7 +464,6 @@ async def test_consolidation_to_smallest_balance(self): (consensus_validators[2], consensus_validators[1]), ] - @pytest.mark.asyncio async def test_consolidation_max_balance(self): source_pk_1 = faker.validator_public_key() source_pk_2 = faker.validator_public_key() @@ -736,10 +735,10 @@ def create_manager( chain_head: ChainHead | None = None, exclude_public_keys: set[HexStr] | None = None, vault_validators: list[HexStr] | None = None, - consensus_validators: list[ConsensusValidator] = None, - consolidating_source_indexes: set[int] = None, - consolidating_target_indexes: set[int] = None, - pending_partial_withdrawals_indexes: set[int] = None, + consensus_validators: list[ConsensusValidator] | None = None, + consolidating_source_indexes: set[int] | None = None, + consolidating_target_indexes: set[int] | None = None, + pending_partial_withdrawals_indexes: set[int] | None = None, ) -> ConsolidationSelector | ConsolidationChecker: self: ConsolidationChecker | ConsolidationSelector if chain_head is None: From d4c8b93990f359034d2ca93335be7d215ff619f7 Mon Sep 17 00:00:00 2001 From: cyc60 Date: Wed, 11 Feb 2026 17:11:58 +0300 Subject: [PATCH 12/19] Add ConsolidationError Signed-off-by: cyc60 --- src/commands/consolidate.py | 6 ++- src/validators/consolidation_manager.py | 40 +++++++++---------- src/validators/exceptions.py | 3 ++ .../tests/test_consolidation_manager.py | 26 ++++++------ 4 files changed, 41 insertions(+), 34 deletions(-) diff --git a/src/commands/consolidate.py b/src/commands/consolidate.py index 1d8c211c..685ba934 100644 --- a/src/commands/consolidate.py +++ b/src/commands/consolidate.py @@ -33,6 +33,7 @@ from src.config.networks import AVAILABLE_NETWORKS, GNOSIS, MAINNET, NETWORKS from src.config.settings import DEFAULT_MAX_CONSOLIDATION_REQUEST_FEE_GWEI, settings from src.validators.consolidation_manager import ConsolidationManager +from src.validators.exceptions import ConsolidationError from src.validators.oracles import poll_consolidation_signature from src.validators.register_validators import submit_consolidate_validators from src.validators.relayer import RelayerClient @@ -325,7 +326,10 @@ async def process( chain_head=chain_head, exclude_public_keys=exclude_public_keys, ) - target_source = consolidation_selector.get_target_source() + try: + target_source = consolidation_selector.get_target_source() + except ConsolidationError as e: + raise click.ClickException(str(e)) if not target_source: raise click.ClickException(f'Validators in vault {settings.vault} can\'t be consolidated') diff --git a/src/validators/consolidation_manager.py b/src/validators/consolidation_manager.py index af0ae00b..89502854 100644 --- a/src/validators/consolidation_manager.py +++ b/src/validators/consolidation_manager.py @@ -1,6 +1,5 @@ import logging -import click from eth_typing import HexStr from sw_utils import ChainHead @@ -9,6 +8,7 @@ from src.common.withdrawals import get_pending_partial_withdrawals from src.config.settings import settings from src.validators.consensus import EXITING_STATUSES, fetch_consensus_validators +from src.validators.exceptions import ConsolidationError from src.validators.typings import ConsensusValidator, ConsolidationKeys logger = logging.getLogger(__name__) @@ -74,7 +74,7 @@ async def create( def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator]]: ''' - # Source validators must be: + # Source validators must be: - unique - in the vault - not exiting @@ -203,7 +203,7 @@ def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator # Validate the source and target validators are in the vault for public_key in self.source_public_keys + [self.target_public_key]: if public_key not in self.vault_validators: - raise click.ClickException( + raise ConsolidationError( f'Validator {public_key} is not registered in the vault {settings.vault}.' ) @@ -218,20 +218,20 @@ def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator for source_public_key in self.source_public_keys: source_validator = pubkey_to_validator.get(source_public_key) if not source_validator: - raise click.ClickException( + raise ConsolidationError( f'Validator {source_public_key} not found in the consensus layer.' ) # Validate the source validator status if source_validator.status in EXITING_STATUSES: - raise click.ClickException( + raise ConsolidationError( f'Validator {source_public_key} is in exiting ' f'status {source_validator.status.value}.' ) # Validate the source has been active long enough - if source_validator.activation_epoch > self.max_activation_epoch: - raise click.ClickException( + if source_validator.activation_epoch >= self.max_activation_epoch: + raise ConsolidationError( f'Validator {source_validator.public_key}' f' is not active enough for consolidation. ' f'It must be active for at least ' @@ -244,14 +244,14 @@ def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator source_validator.index in self.consolidating_source_indexes or source_validator.index in self.consolidating_target_indexes ): - raise click.ClickException( + raise ConsolidationError( f'Validator {source_validator.public_key} ' f'is consolidating to another validator.' ) - # Validate the source validators has no pending withdrawals in the queue + # Validate the source validator has no pending withdrawals in the queue if source_validator.index in self.pending_partial_withdrawals_indexes: - raise click.ClickException( + raise ConsolidationError( f'Validator {source_validator.public_key} ' f'has pending partial withdrawals in the queue.' ) @@ -263,7 +263,7 @@ def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator sum(val.balance for val in self.consensus_validators) > settings.max_validator_balance_gwei ): - raise click.ClickException( + raise ConsolidationError( 'Cannot consolidate validators,' f' total balance exceeds {settings.max_validator_balance_gwei} Gwei' ) @@ -273,11 +273,11 @@ def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator def _validate_public_keys(self) -> None: # Validate that source public keys are unique if len(self.source_public_keys) != len(set(self.source_public_keys)): - raise click.ClickException('Source public keys must be unique.') + raise ConsolidationError('Source public keys must be unique.') # Validate the switch from 0x01 to 0x02 and consolidation to another validator if len(self.source_public_keys) > 1 and self.target_public_key in self.source_public_keys: - raise click.ClickException( + raise ConsolidationError( 'Cannot switch from 0x01 to 0x02 and consolidate ' 'to another validator in the same request.' ) @@ -289,36 +289,36 @@ def _validate_target_validator( val for val in self.consensus_validators if val.public_key == self.target_public_key ] if not target_validators: - raise click.ClickException( + raise ConsolidationError( f'Validator {self.target_public_key} not found in the consensus layer.' ) target_validator = target_validators[0] if target_validator.status in EXITING_STATUSES: - raise click.ClickException( + raise ConsolidationError( f'Target validator {self.target_public_key} is in exiting ' f'status {target_validator.status.value}.' ) # Target validator cannot be used as source in ongoing consolidations if target_validator.index in self.consolidating_source_indexes: - raise click.ClickException( + raise ConsolidationError( f'Target validator {self.target_public_key} is involved in another consolidation.' ) if self.is_switch_to_compounding(): if target_validator.is_compounding: - raise click.ClickException( + raise ConsolidationError( f'Target validator {self.target_public_key} is already a compounding validator.' ) # switch the 0x01 to 0x02 - if target_validator.activation_epoch > self.max_activation_epoch: - raise click.ClickException( + if target_validator.activation_epoch >= self.max_activation_epoch: + raise ConsolidationError( f'Validator {self.target_public_key} is not active enough for consolidation. ' f'It must be active for at least ' f'{settings.network_config.SHARD_COMMITTEE_PERIOD} epochs before consolidation.' ) else: if not target_validator.is_compounding: - raise click.ClickException( + raise ConsolidationError( f'The target validator {self.target_public_key}' f' is not a compounding validator.' ) diff --git a/src/validators/exceptions.py b/src/validators/exceptions.py index bffcc848..04b6260b 100644 --- a/src/validators/exceptions.py +++ b/src/validators/exceptions.py @@ -10,3 +10,6 @@ class EmptyRelayerResponseException(Exception): ... class FundingException(Exception): ... + + +class ConsolidationError(Exception): ... diff --git a/src/validators/tests/test_consolidation_manager.py b/src/validators/tests/test_consolidation_manager.py index c238e9da..60405942 100644 --- a/src/validators/tests/test_consolidation_manager.py +++ b/src/validators/tests/test_consolidation_manager.py @@ -1,6 +1,5 @@ from unittest.mock import patch -import click import pytest from eth_typing import HexStr from sw_utils import ChainHead @@ -14,6 +13,7 @@ ConsolidationChecker, ConsolidationSelector, ) +from src.validators.exceptions import ConsolidationError from src.validators.tests.factories import create_consensus_validator from src.validators.typings import ConsensusValidator, ConsolidationKeys @@ -183,11 +183,11 @@ async def test_excludes_exiting_validators(self): assert result == [] async def test_min_activation_epoch(self): - epoch = 10 + epoch = 1000 consensus_validators = [ create_consensus_validator( index=10, - activation_epoch=epoch + settings.network_config.SHARD_COMMITTEE_PERIOD - 1, + activation_epoch=epoch - settings.network_config.SHARD_COMMITTEE_PERIOD + 1, is_compounding=False, ), ] @@ -332,7 +332,7 @@ async def test_empty_list_when_empty_vault(self): consensus_validators=[], ) with pytest.raises( - click.ClickException, + ConsolidationError, match=f'Validator {pk} is not registered in the vault {settings.vault}.', ): selector.get_target_source() @@ -380,7 +380,7 @@ async def test_switch_from_0x02_to_0x02(self): consensus_validators=consensus_validators, ) with pytest.raises( - click.ClickException, match=f'Target validator {pk} is already a compounding validator.' + ConsolidationError, match=f'Target validator {pk} is already a compounding validator.' ): selector.get_target_source() @@ -509,7 +509,7 @@ async def test_consolidation_max_balance(self): ) with pytest.raises( - click.ClickException, match='Cannot consolidate validators, total balance exceed' + ConsolidationError, match='Cannot consolidate validators, total balance exceed' ): selector.get_target_source() @@ -544,7 +544,7 @@ async def test_excludes_consolidating_validators(self): ) with pytest.raises( - click.ClickException, + ConsolidationError, match=f'Validator {source_pk} is consolidating to another validator.', ): selector.get_target_source() @@ -559,7 +559,7 @@ async def test_excludes_consolidating_validators(self): ) with pytest.raises( - click.ClickException, + ConsolidationError, match=f'Target validator {target_pk} is involved in another consolidation.', ): selector.get_target_source() @@ -594,7 +594,7 @@ async def test_excludes_pending_partial_withdrawals(self): ) with pytest.raises( - click.ClickException, + ConsolidationError, match=f'Validator {source_pk} has pending partial withdrawals in the queue.', ): selector.get_target_source() @@ -628,7 +628,7 @@ async def test_excludes_exiting_validators(self): ) with pytest.raises( - click.ClickException, + ConsolidationError, match=f'Validator {source_pk} is in exiting status {EXITING_STATUSES[0].value}.', ): selector.get_target_source() @@ -654,7 +654,7 @@ async def test_excludes_exiting_validators(self): ) with pytest.raises( - click.ClickException, + ConsolidationError, match=f'Target validator {target_pk} is in exiting status {EXITING_STATUSES[0].value}.', ): selector.get_target_source() @@ -688,7 +688,7 @@ async def test_rejects_non_compounding_target(self): ) with pytest.raises( - click.ClickException, + ConsolidationError, match=f'The target validator {target_pk} is not a compounding validator.', ): selector.get_target_source() @@ -724,7 +724,7 @@ async def test_min_activation_epoch(self): ) with pytest.raises( - click.ClickException, + ConsolidationError, match=f'Validator {consensus_validators[0].public_key} is not active enough for consolidation.', ): selector.get_target_source() From 620e5acdc5540c8e576b7eb5e9e16cf55658fac1 Mon Sep 17 00:00:00 2001 From: cyc60 Date: Wed, 11 Feb 2026 17:19:30 +0300 Subject: [PATCH 13/19] Fix grammar and syntax mistakes in consolidation code --- src/commands/consolidate.py | 2 +- src/validators/consolidation_manager.py | 27 ++++++++++--------- .../tests/test_consolidation_manager.py | 8 +++--- 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/commands/consolidate.py b/src/commands/consolidate.py index 685ba934..916206f8 100644 --- a/src/commands/consolidate.py +++ b/src/commands/consolidate.py @@ -210,7 +210,7 @@ def consolidate( ) if (source_public_keys or source_public_keys_file) and not target_public_key: raise click.ClickException( - 'target-public-key must be provided with one of these parameters:' + '--target-public-key must be provided when using' ' --source-public-keys-file or --source-public-keys.' ) diff --git a/src/validators/consolidation_manager.py b/src/validators/consolidation_manager.py index 89502854..5f043b44 100644 --- a/src/validators/consolidation_manager.py +++ b/src/validators/consolidation_manager.py @@ -73,7 +73,7 @@ async def create( return self def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator]]: - ''' + """ # Source validators must be: - unique - in the vault @@ -81,8 +81,8 @@ def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator - active for at least SHARD_COMMITTEE_PERIOD epochs - not consolidating to another validator - not consolidating from another validator - - have no pending partial withdrawals in the queue - - total balance that won't exceed the max effective balance when consolidated + - no pending partial withdrawals in the queue + - total balance not exceeding the max effective balance when consolidated # Target validator must be: - in the vault - not exiting @@ -94,7 +94,7 @@ def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator - in the vault - not exiting - active for at least SHARD_COMMITTEE_PERIOD epochs - ''' + """ raise NotImplementedError() @property @@ -114,15 +114,16 @@ def __init__( def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator]]: """ If there are no 0x02 validators, - take the oldest 0x01 validator and convert it to 0x02 with confirmation prompt. - If there is 0x02 validator, - take the oldest 0x01 validators to top up its balance to 2048 ETH / 64 GNO. + take the oldest 0x01 validator and convert it to 0x02 with a confirmation prompt. + If there is a 0x02 validator, + take the oldest 0x01 validators to top up the target's balance to MAX BALANCE. """ # Candidates on the role of either source or target validator - source_validators_candidates, target_validator_candidates = ( - self._find_validators_candidates() - ) + ( + source_validators_candidates, + target_validator_candidates, + ) = self._find_validators_candidates() if not source_validators_candidates or not target_validator_candidates: return [] @@ -195,7 +196,7 @@ def __init__( def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator]]: """ Validate that provided public keys can be consolidated - and returns the target and source validators info. + and return the target and source validators info. """ logger.info('Checking selected validators for consolidation...') self._validate_public_keys() @@ -229,7 +230,7 @@ def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator f'status {source_validator.status.value}.' ) - # Validate the source has been active long enough + # Validate the source validator has been active long enough if source_validator.activation_epoch >= self.max_activation_epoch: raise ConsolidationError( f'Validator {source_validator.public_key}' @@ -275,7 +276,7 @@ def _validate_public_keys(self) -> None: if len(self.source_public_keys) != len(set(self.source_public_keys)): raise ConsolidationError('Source public keys must be unique.') - # Validate the switch from 0x01 to 0x02 and consolidation to another validator + # Reject combining switch from 0x01 to 0x02 with consolidation to another validator if len(self.source_public_keys) > 1 and self.target_public_key in self.source_public_keys: raise ConsolidationError( 'Cannot switch from 0x01 to 0x02 and consolidate ' diff --git a/src/validators/tests/test_consolidation_manager.py b/src/validators/tests/test_consolidation_manager.py index 60405942..fdb0c3d7 100644 --- a/src/validators/tests/test_consolidation_manager.py +++ b/src/validators/tests/test_consolidation_manager.py @@ -281,13 +281,11 @@ async def test_allows_validator_in_target_indexes_as_target(self): vault_validators=[v.public_key for v in consensus_validators], consensus_validators=consensus_validators, consolidating_source_indexes=set(), - consolidating_target_indexes={ - 10 - }, # index 10 is in target consolidation, so can't be target again + consolidating_target_indexes={10}, ) result = selector.get_target_source() - # Should switch validator 11 from 0x01 to 0x02 since there are no valid targets - # but validator 11 is available as source + # Validator 10 can still be target even if already in consolidating_target_indexes, + # so validator 11 consolidates into validator 10 assert result == [(consensus_validators[0], consensus_validators[1])] async def test_excludes_validator_in_target_indexes_as_source(self): From 7ce2656dfc27e838f84bf96c5b3dc967089ded44 Mon Sep 17 00:00:00 2001 From: cyc60 Date: Wed, 11 Feb 2026 17:31:32 +0300 Subject: [PATCH 14/19] Fix variable naming Signed-off-by: cyc60 --- src/commands/consolidate.py | 4 ++-- src/validators/consolidation_manager.py | 4 ++-- .../tests/test_consolidation_manager.py | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/commands/consolidate.py b/src/commands/consolidate.py index 916206f8..d7827e24 100644 --- a/src/commands/consolidate.py +++ b/src/commands/consolidate.py @@ -321,13 +321,13 @@ async def process( source_public_keys=source_public_keys, target_public_key=target_public_key, ) - consolidation_selector = await ConsolidationManager.create( + consolidation_manager = await ConsolidationManager.create( consolidation_keys=consolidation_keys, chain_head=chain_head, exclude_public_keys=exclude_public_keys, ) try: - target_source = consolidation_selector.get_target_source() + target_source = consolidation_manager.get_target_source() except ConsolidationError as e: raise click.ClickException(str(e)) if not target_source: diff --git a/src/validators/consolidation_manager.py b/src/validators/consolidation_manager.py index 5f043b44..94bb0dd9 100644 --- a/src/validators/consolidation_manager.py +++ b/src/validators/consolidation_manager.py @@ -114,7 +114,7 @@ def __init__( def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator]]: """ If there are no 0x02 validators, - take the oldest 0x01 validator and convert it to 0x02 with a confirmation prompt. + take the oldest 0x01 validator and convert it to 0x02. If there is a 0x02 validator, take the oldest 0x01 validators to top up the target's balance to MAX BALANCE. """ @@ -162,7 +162,7 @@ def _find_validators_candidates( for val in self.consensus_validators: if val.status in EXITING_STATUSES: continue - # Target validator cannot be used as source in ongoing consolidations + # Exclude validators that are sources in ongoing consolidations if val.index in self.consolidating_source_indexes: continue if val.public_key in self.exclude_public_keys: diff --git a/src/validators/tests/test_consolidation_manager.py b/src/validators/tests/test_consolidation_manager.py index fdb0c3d7..e692150d 100644 --- a/src/validators/tests/test_consolidation_manager.py +++ b/src/validators/tests/test_consolidation_manager.py @@ -199,6 +199,21 @@ async def test_min_activation_epoch(self): result = selector.get_target_source() assert result == [] + consensus_validators = [ + create_consensus_validator( + index=10, + activation_epoch=epoch - settings.network_config.SHARD_COMMITTEE_PERIOD, + is_compounding=False, + ), + ] + selector = create_manager( + chain_head=create_chain_head(epoch), + vault_validators=[v.public_key for v in consensus_validators], + consensus_validators=consensus_validators, + ) + result = selector.get_target_source() + assert result == [] + async def test_excludes_source_as_target_validator(self): """Test that target validator is excluded if it's in consolidating_source_indexes""" consensus_validators = [ From 88ef8219f371c9a38bcb9e62c0e3c1e62a08a63d Mon Sep 17 00:00:00 2001 From: cyc60 Date: Thu, 12 Feb 2026 08:35:35 +0300 Subject: [PATCH 15/19] Fix epoch comparison Signed-off-by: cyc60 --- src/validators/consolidation_manager.py | 13 ++++++++----- .../tests/test_consolidation_manager.py | 17 +---------------- 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/src/validators/consolidation_manager.py b/src/validators/consolidation_manager.py index 94bb0dd9..6db3da7e 100644 --- a/src/validators/consolidation_manager.py +++ b/src/validators/consolidation_manager.py @@ -1,7 +1,9 @@ import logging +from abc import ABC, abstractmethod from eth_typing import HexStr from sw_utils import ChainHead +from web3.types import Gwei from src.common.consolidations import get_pending_consolidations from src.common.contracts import VaultContract @@ -14,7 +16,7 @@ logger = logging.getLogger(__name__) -class ConsolidationManager: +class ConsolidationManager(ABC): chain_head: ChainHead vault_validators: list[HexStr] consensus_validators: list[ConsensusValidator] @@ -72,6 +74,7 @@ async def create( self.pending_partial_withdrawals_indexes.add(withdrawal.validator_index) return self + @abstractmethod def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator]]: """ # Source validators must be: @@ -145,7 +148,7 @@ def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator if target_balance + val.balance > settings.max_validator_balance_gwei: break selected_source_validators.append(val) - target_balance += val.balance # type: ignore + target_balance = Gwei(target_balance + val.balance) if selected_source_validators: return [(target_validator, val) for val in selected_source_validators] @@ -173,7 +176,7 @@ def _find_validators_candidates( # Source validator must be non-compounding if val.is_compounding: continue - if val.activation_epoch >= self.max_activation_epoch: + if val.activation_epoch > self.max_activation_epoch: continue # Source validator cannot be in any ongoing consolidations (either as source or target) if val.index in self.consolidating_target_indexes: @@ -231,7 +234,7 @@ def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator ) # Validate the source validator has been active long enough - if source_validator.activation_epoch >= self.max_activation_epoch: + if source_validator.activation_epoch > self.max_activation_epoch: raise ConsolidationError( f'Validator {source_validator.public_key}' f' is not active enough for consolidation. ' @@ -311,7 +314,7 @@ def _validate_target_validator( f'Target validator {self.target_public_key} is already a compounding validator.' ) # switch the 0x01 to 0x02 - if target_validator.activation_epoch >= self.max_activation_epoch: + if target_validator.activation_epoch > self.max_activation_epoch: raise ConsolidationError( f'Validator {self.target_public_key} is not active enough for consolidation. ' f'It must be active for at least ' diff --git a/src/validators/tests/test_consolidation_manager.py b/src/validators/tests/test_consolidation_manager.py index e692150d..c2bb2336 100644 --- a/src/validators/tests/test_consolidation_manager.py +++ b/src/validators/tests/test_consolidation_manager.py @@ -199,21 +199,6 @@ async def test_min_activation_epoch(self): result = selector.get_target_source() assert result == [] - consensus_validators = [ - create_consensus_validator( - index=10, - activation_epoch=epoch - settings.network_config.SHARD_COMMITTEE_PERIOD, - is_compounding=False, - ), - ] - selector = create_manager( - chain_head=create_chain_head(epoch), - vault_validators=[v.public_key for v in consensus_validators], - consensus_validators=consensus_validators, - ) - result = selector.get_target_source() - assert result == [] - async def test_excludes_source_as_target_validator(self): """Test that target validator is excluded if it's in consolidating_source_indexes""" consensus_validators = [ @@ -522,7 +507,7 @@ async def test_consolidation_max_balance(self): ) with pytest.raises( - ConsolidationError, match='Cannot consolidate validators, total balance exceed' + ConsolidationError, match='Cannot consolidate validators, total balance exceeds' ): selector.get_target_source() From a2e753efed302bada066e3acb935c41448b9f461 Mon Sep 17 00:00:00 2001 From: cyc60 Date: Thu, 12 Feb 2026 11:17:23 +0300 Subject: [PATCH 16/19] Rm async from tests Signed-off-by: cyc60 --- .../tests/test_consolidation_manager.py | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/src/validators/tests/test_consolidation_manager.py b/src/validators/tests/test_consolidation_manager.py index c2bb2336..e70e2744 100644 --- a/src/validators/tests/test_consolidation_manager.py +++ b/src/validators/tests/test_consolidation_manager.py @@ -20,7 +20,7 @@ @pytest.mark.usefixtures('fake_settings') class TestConsolidationSelector: - async def test_empty_list_when_no_target_validators(self): + def test_empty_list_when_no_target_validators(self): selector = create_manager( vault_validators=[], consensus_validators=[], @@ -28,7 +28,7 @@ async def test_empty_list_when_no_target_validators(self): result = selector.get_target_source() assert result == [] - async def test_switches_oldest_0x01_to_0x02(self): + def test_switches_oldest_0x01_to_0x02(self): consensus_validators = [ create_consensus_validator( activation_epoch=1, @@ -42,7 +42,7 @@ async def test_switches_oldest_0x01_to_0x02(self): result = selector.get_target_source() assert result == [(consensus_validators[0], consensus_validators[0])] - async def test_consolidation_with_single_compounding(self): + def test_consolidation_with_single_compounding(self): consensus_validators = [ create_consensus_validator( activation_epoch=1, @@ -60,7 +60,7 @@ async def test_consolidation_with_single_compounding(self): result = selector.get_target_source() assert result == [(consensus_validators[1], consensus_validators[0])] - async def test_consolidation_to_smallest_balance(self): + def test_consolidation_to_smallest_balance(self): consensus_validators = [ create_consensus_validator( activation_epoch=1, @@ -80,7 +80,7 @@ async def test_consolidation_to_smallest_balance(self): result = selector.get_target_source() assert result == [(consensus_validators[1], consensus_validators[0])] - async def test_consolidation_max_balance(self): + def test_consolidation_max_balance(self): consensus_validators = [ create_consensus_validator( activation_epoch=1, is_compounding=True, balance=ether_to_gwei(32.0) @@ -107,7 +107,7 @@ async def test_consolidation_max_balance(self): (consensus_validators[0], consensus_validators[2]), ] - async def test_excludes_consolidating_validators(self): + def test_excludes_consolidating_validators(self): consensus_validators = [ create_consensus_validator( index=10, @@ -129,7 +129,7 @@ async def test_excludes_consolidating_validators(self): result = selector.get_target_source() assert result == [] - async def test_excludes_pending_partial_withdrawals(self): + def test_excludes_pending_partial_withdrawals(self): consensus_validators = [ create_consensus_validator( index=10, @@ -150,7 +150,7 @@ async def test_excludes_pending_partial_withdrawals(self): result = selector.get_target_source() assert result == [] - async def test_excludes_specified_public_keys(self): + def test_excludes_specified_public_keys(self): consensus_validators = [ create_consensus_validator( index=10, @@ -166,7 +166,7 @@ async def test_excludes_specified_public_keys(self): result = selector.get_target_source() assert result == [] - async def test_excludes_exiting_validators(self): + def test_excludes_exiting_validators(self): consensus_validators = [ create_consensus_validator( activation_epoch=1, @@ -182,7 +182,7 @@ async def test_excludes_exiting_validators(self): result = selector.get_target_source() assert result == [] - async def test_min_activation_epoch(self): + def test_min_activation_epoch(self): epoch = 1000 consensus_validators = [ create_consensus_validator( @@ -199,7 +199,7 @@ async def test_min_activation_epoch(self): result = selector.get_target_source() assert result == [] - async def test_excludes_source_as_target_validator(self): + def test_excludes_source_as_target_validator(self): """Test that target validator is excluded if it's in consolidating_source_indexes""" consensus_validators = [ create_consensus_validator( @@ -226,7 +226,7 @@ async def test_excludes_source_as_target_validator(self): # but validator 11 is available as source assert result == [(consensus_validators[1], consensus_validators[1])] - async def test_excludes_source_validator_in_both_indexes(self): + def test_excludes_source_validator_in_both_indexes(self): """Test that source validator is excluded if it's in either source or target consolidating indexes""" consensus_validators = [ create_consensus_validator( @@ -263,7 +263,7 @@ async def test_excludes_source_validator_in_both_indexes(self): # since it's in consolidating_target_indexes assert result == [] - async def test_allows_validator_in_target_indexes_as_target(self): + def test_allows_validator_in_target_indexes_as_target(self): """Test that target validator is not excluded if it's in consolidating_target_indexes""" consensus_validators = [ create_consensus_validator( @@ -288,7 +288,7 @@ async def test_allows_validator_in_target_indexes_as_target(self): # so validator 11 consolidates into validator 10 assert result == [(consensus_validators[0], consensus_validators[1])] - async def test_excludes_validator_in_target_indexes_as_source(self): + def test_excludes_validator_in_target_indexes_as_source(self): """Test that source validator is excluded if it's in consolidating_target_indexes""" consensus_validators = [ create_consensus_validator( @@ -318,7 +318,7 @@ async def test_excludes_validator_in_target_indexes_as_source(self): @pytest.mark.usefixtures('fake_settings') class TestConsolidationChecker: - async def test_empty_list_when_empty_vault(self): + def test_empty_list_when_empty_vault(self): pk = faker.validator_public_key() consolidation_keys = ConsolidationKeys( source_public_keys=[pk], @@ -335,7 +335,7 @@ async def test_empty_list_when_empty_vault(self): ): selector.get_target_source() - async def test_switch_from_0x01_to_0x02(self): + def test_switch_from_0x01_to_0x02(self): pk = faker.validator_public_key() consolidation_keys = ConsolidationKeys( source_public_keys=[pk], @@ -357,7 +357,7 @@ async def test_switch_from_0x01_to_0x02(self): result = selector.get_target_source() assert result == [(consensus_validators[0], consensus_validators[0])] - async def test_switch_from_0x02_to_0x02(self): + def test_switch_from_0x02_to_0x02(self): pk = faker.validator_public_key() consolidation_keys = ConsolidationKeys( source_public_keys=[pk], @@ -382,7 +382,7 @@ async def test_switch_from_0x02_to_0x02(self): ): selector.get_target_source() - async def test_consolidation_with_single_compounding(self): + def test_consolidation_with_single_compounding(self): source_pk = faker.validator_public_key() target_pk = faker.validator_public_key() consolidation_keys = ConsolidationKeys( @@ -410,7 +410,7 @@ async def test_consolidation_with_single_compounding(self): result = selector.get_target_source() assert result == [(consensus_validators[1], consensus_validators[0])] - async def test_consolidation_to_smallest_balance(self): + def test_consolidation_to_smallest_balance(self): source_pk_1 = faker.validator_public_key() source_pk_2 = faker.validator_public_key() target_pk_1 = faker.validator_public_key() @@ -462,7 +462,7 @@ async def test_consolidation_to_smallest_balance(self): (consensus_validators[2], consensus_validators[1]), ] - async def test_consolidation_max_balance(self): + def test_consolidation_max_balance(self): source_pk_1 = faker.validator_public_key() source_pk_2 = faker.validator_public_key() source_pk_3 = faker.validator_public_key() @@ -511,7 +511,7 @@ async def test_consolidation_max_balance(self): ): selector.get_target_source() - async def test_excludes_consolidating_validators(self): + def test_excludes_consolidating_validators(self): source_pk = faker.validator_public_key() target_pk = faker.validator_public_key() consolidation_keys = ConsolidationKeys( @@ -562,7 +562,7 @@ async def test_excludes_consolidating_validators(self): ): selector.get_target_source() - async def test_excludes_pending_partial_withdrawals(self): + def test_excludes_pending_partial_withdrawals(self): source_pk = faker.validator_public_key() target_pk = faker.validator_public_key() consolidation_keys = ConsolidationKeys( @@ -597,7 +597,7 @@ async def test_excludes_pending_partial_withdrawals(self): ): selector.get_target_source() - async def test_excludes_exiting_validators(self): + def test_excludes_exiting_validators(self): source_pk = faker.validator_public_key() target_pk = faker.validator_public_key() consolidation_keys = ConsolidationKeys( @@ -657,7 +657,7 @@ async def test_excludes_exiting_validators(self): ): selector.get_target_source() - async def test_rejects_non_compounding_target(self): + def test_rejects_non_compounding_target(self): source_pk = faker.validator_public_key() target_pk = faker.validator_public_key() consolidation_keys = ConsolidationKeys( @@ -691,7 +691,7 @@ async def test_rejects_non_compounding_target(self): ): selector.get_target_source() - async def test_min_activation_epoch(self): + def test_min_activation_epoch(self): source_pk = faker.validator_public_key() target_pk = faker.validator_public_key() consolidation_keys = ConsolidationKeys( From 2c59d197421c1913c3945535b7ce7a54ae219322 Mon Sep 17 00:00:00 2001 From: evgeny-stakewise <123374581+evgeny-stakewise@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:39:02 +0300 Subject: [PATCH 17/19] Add comments (#663) --- src/validators/consolidation_manager.py | 27 +++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/validators/consolidation_manager.py b/src/validators/consolidation_manager.py index 6db3da7e..063305a4 100644 --- a/src/validators/consolidation_manager.py +++ b/src/validators/consolidation_manager.py @@ -32,7 +32,10 @@ async def create( chain_head: ChainHead, exclude_public_keys: set[HexStr], ) -> 'ConsolidationManager': + # Instance to create self: ConsolidationManager + + # Switch to "check" logic or "select" logic if consolidation_keys is not None: self = ConsolidationChecker( consolidation_keys=consolidation_keys, @@ -43,6 +46,8 @@ async def create( chain_head=chain_head, exclude_public_keys=exclude_public_keys, ) + + # Fetch vault validators logger.info('Fetching vault validators...') self.vault_validators = await VaultContract( settings.vault @@ -50,6 +55,8 @@ async def create( from_block=settings.vault_first_block, to_block=self.chain_head.block_number, ) + + # Fetch consensus validators if consolidation_keys is not None: self.consensus_validators = await fetch_consensus_validators( consolidation_keys.all_public_keys @@ -57,9 +64,7 @@ async def create( else: self.consensus_validators = await fetch_consensus_validators(self.vault_validators) - pending_partial_withdrawals = await get_pending_partial_withdrawals( - chain_head, self.consensus_validators - ) + # Pending consolidations pending_consolidations = await get_pending_consolidations( chain_head, self.consensus_validators ) @@ -69,9 +74,14 @@ async def create( self.consolidating_source_indexes.add(cons.source_index) self.consolidating_target_indexes.add(cons.target_index) + # Pending withdrawals + pending_partial_withdrawals = await get_pending_partial_withdrawals( + chain_head, self.consensus_validators + ) self.pending_partial_withdrawals_indexes = set() for withdrawal in pending_partial_withdrawals: self.pending_partial_withdrawals_indexes.add(withdrawal.validator_index) + return self @abstractmethod @@ -106,6 +116,11 @@ def max_activation_epoch(self) -> int: class ConsolidationSelector(ConsolidationManager): + """ + Suited for the case when the user doesn't specify which validators to consolidate. + ConsolidationSelector picks the most appropriate validators for consolidation. + """ + def __init__( self, chain_head: ChainHead, @@ -122,7 +137,6 @@ def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator take the oldest 0x01 validators to top up the target's balance to MAX BALANCE. """ # Candidates on the role of either source or target validator - ( source_validators_candidates, target_validator_candidates, @@ -188,6 +202,11 @@ def _find_validators_candidates( class ConsolidationChecker(ConsolidationManager): + """ + Suited for the case when the user specifies which validators to consolidate. + We have to check if they are valid for consolidation. + """ + def __init__( self, consolidation_keys: ConsolidationKeys, From 4bed3d0c2846f7ee90c60cf888db3f2857b28d4d Mon Sep 17 00:00:00 2001 From: cyc60 Date: Wed, 25 Feb 2026 15:39:12 +0300 Subject: [PATCH 18/19] Copilot fixes Signed-off-by: cyc60 --- src/validators/consolidation_manager.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/validators/consolidation_manager.py b/src/validators/consolidation_manager.py index 063305a4..3d062392 100644 --- a/src/validators/consolidation_manager.py +++ b/src/validators/consolidation_manager.py @@ -255,11 +255,11 @@ def get_target_source(self) -> list[tuple[ConsensusValidator, ConsensusValidator # Validate the source validator has been active long enough if source_validator.activation_epoch > self.max_activation_epoch: raise ConsolidationError( - f'Validator {source_validator.public_key}' - f' is not active enough for consolidation. ' + f'Validator {source_validator.public_key} ' + f'is not active enough for consolidation. ' f'It must be active for at least ' - f'{settings.network_config.SHARD_COMMITTEE_PERIOD}' - f' epochs before consolidation.' + f'{settings.network_config.SHARD_COMMITTEE_PERIOD} ' + f'epochs before consolidation.' ) # Validate the source validator is not consolidating @@ -342,8 +342,8 @@ def _validate_target_validator( else: if not target_validator.is_compounding: raise ConsolidationError( - f'The target validator {self.target_public_key}' - f' is not a compounding validator.' + f'The target validator {self.target_public_key} ' + f'is not a compounding validator.' ) return target_validator From a48727bf6b31205a6f0751037b2acbfa89edb2f6 Mon Sep 17 00:00:00 2001 From: cyc60 Date: Wed, 25 Feb 2026 15:51:11 +0300 Subject: [PATCH 19/19] Bump version Signed-off-by: cyc60 --- README.md | 6 +++--- pyproject.toml | 2 +- scripts/install.sh | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index de7ddd79..0d7a8499 100644 --- a/README.md +++ b/README.md @@ -98,14 +98,14 @@ operator COMMAND --flagA=123 --flagB=xyz Pull the latest docker Operator docker image: ```bash -docker pull europe-west4-docker.pkg.dev/stakewiselabs/public/v3-operator:v4.1.8 +docker pull europe-west4-docker.pkg.dev/stakewiselabs/public/v3-operator:v4.1.9 ``` You can also build the docker image from source by cloning this repo and executing the following command from within the `v3-operator` folder: ```bash -docker build --pull -t europe-west4-docker.pkg.dev/stakewiselabs/public/v3-operator:v4.1.8 . +docker build --pull -t europe-west4-docker.pkg.dev/stakewiselabs/public/v3-operator:v4.1.9 . ``` You will execute Operator Service commands using the format below (note the use of flags are optional): @@ -114,7 +114,7 @@ You will execute Operator Service commands using the format below (note the use docker run --rm -ti \ -u $(id -u):$(id -g) \ -v ~/.stakewise/:/data \ -europe-west4-docker.pkg.dev/stakewiselabs/public/v3-operator:v4.1.8 \ +europe-west4-docker.pkg.dev/stakewiselabs/public/v3-operator:v4.1.9 \ src/main.py COMMAND \ --flagA=123 \ --flagB=xyz diff --git a/pyproject.toml b/pyproject.toml index 2c0e7a6c..8a0686cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "v3-operator" -version = "v4.1.8" +version = "v4.1.9" description = "StakeWise operator service for registering vault validators" authors = ["StakeWise Labs "] package-mode = false diff --git a/scripts/install.sh b/scripts/install.sh index 15bbfb9d..181b87ad 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -288,7 +288,7 @@ http_copy() { github_release() { owner_repo=$1 version=$2 - test -z "$version" && version="v4.1.8" + test -z "$version" && version="v4.1.9" giturl="https://github.com/${owner_repo}/releases/${version}" json=$(http_copy "$giturl" "Accept:application/json") test -z "$json" && return 1