diff --git a/specs/electra/beacon-chain.md b/specs/electra/beacon-chain.md index 9037dd74d..10c0eebf3 100644 --- a/specs/electra/beacon-chain.md +++ b/specs/electra/beacon-chain.md @@ -61,6 +61,7 @@ - [Modified `get_next_sync_committee_indices`](#modified-get_next_sync_committee_indices) - [Beacon state mutators](#beacon-state-mutators) - [Modified `initiate_validator_exit`](#modified-initiate_validator_exit) + - [New `switch_to_compounding_validator`](#new-switch_to_compounding_validator) - [New `queue_excess_active_balance`](#new-queue_excess_active_balance) - [New `queue_entire_balance_and_reset_validator`](#new-queue_entire_balance_and_reset_validator) - [New `compute_exit_epoch_and_update_churn`](#new-compute_exit_epoch_and_update_churn) @@ -96,6 +97,7 @@ - [Deposit requests](#deposit-requests) - [New `process_deposit_request`](#new-process_deposit_request) - [Execution layer consolidation requests](#execution-layer-consolidation-requests) + - [New `is_valid_switch_to_compounding_request`](#new-is_valid_switch_to_compounding_request) - [New `process_consolidation_request`](#new-process_consolidation_request) - [Testing](#testing) @@ -677,6 +679,15 @@ def initiate_validator_exit(state: BeaconState, index: ValidatorIndex) -> None: validator.withdrawable_epoch = Epoch(validator.exit_epoch + MIN_VALIDATOR_WITHDRAWABILITY_DELAY) ``` +#### New `switch_to_compounding_validator` + +```python +def switch_to_compounding_validator(state: BeaconState, index: ValidatorIndex) -> None: + validator = state.validators[index] + validator.withdrawal_credentials = COMPOUNDING_WITHDRAWAL_PREFIX + validator.withdrawal_credentials[1:] + queue_excess_active_balance(state, index) +``` + #### New `queue_excess_active_balance` ```python @@ -1383,6 +1394,45 @@ def process_deposit_request(state: BeaconState, deposit_request: DepositRequest) ##### Execution layer consolidation requests +###### New `is_valid_switch_to_compounding_request` + +```python +def is_valid_switch_to_compounding_request( + state: BeaconState, + consolidation_request: ConsolidationRequest +) -> bool: + # Switch to compounding requires source and target be equal + if consolidation_request.source_pubkey != consolidation_request.target_pubkey: + return False + + # Verify pubkey exists + source_pubkey = consolidation_request.source_pubkey + validator_pubkeys = [v.pubkey for v in state.validators] + if source_pubkey not in validator_pubkeys: + return False + + source_validator = state.validators[ValidatorIndex(validator_pubkeys.index(source_pubkey))] + + # Verify request has been authorized + if source_validator.withdrawal_credentials[12:] != consolidation_request.source_address: + return False + + # Verify source withdrawal credentials + if not has_eth1_withdrawal_credential(source_validator): + return False + + # Verify the source is active + current_epoch = get_current_epoch(state) + if not is_active_validator(source_validator, current_epoch): + return False + + # Verify exit for source have not been initiated + if source_validator.exit_epoch != FAR_FUTURE_EPOCH: + return False + + return True +``` + ###### New `process_consolidation_request` ```python @@ -1390,6 +1440,16 @@ def process_consolidation_request( state: BeaconState, consolidation_request: ConsolidationRequest ) -> None: + if is_valid_switch_to_compounding_request(state, consolidation_request): + validator_pubkeys = [v.pubkey for v in state.validators] + request_source_pubkey = consolidation_request.source_pubkey + source_index = ValidatorIndex(validator_pubkeys.index(request_source_pubkey)) + switch_to_compounding_validator(state, source_index) + return + + # Verify that source != target, so a consolidation cannot be used as an exit. + if consolidation_request.source_pubkey == consolidation_request.target_pubkey: + return # If the pending consolidations queue is full, consolidation requests are ignored if len(state.pending_consolidations) == PENDING_CONSOLIDATIONS_LIMIT: return @@ -1434,28 +1494,21 @@ def process_consolidation_request( if target_validator.exit_epoch != FAR_FUTURE_EPOCH: return - # Churn any target excess active balance of target and raise its max - if has_eth1_withdrawal_credential(target_validator): - state.validators[target_index].withdrawal_credentials = ( - COMPOUNDING_WITHDRAWAL_PREFIX + target_validator.withdrawal_credentials[1:] - ) - queue_excess_active_balance(state, target_index) - - # Verify that source != target, so a consolidation cannot be used as an exit. - if source_index == target_index: - return - # Initiate source validator exit and append pending consolidation - state.validators[source_index].exit_epoch = compute_consolidation_epoch_and_update_churn( + source_validator.exit_epoch = compute_consolidation_epoch_and_update_churn( state, source_validator.effective_balance ) - state.validators[source_index].withdrawable_epoch = Epoch( - state.validators[source_index].exit_epoch + MIN_VALIDATOR_WITHDRAWABILITY_DELAY + source_validator.withdrawable_epoch = Epoch( + source_validator.exit_epoch + MIN_VALIDATOR_WITHDRAWABILITY_DELAY ) state.pending_consolidations.append(PendingConsolidation( source_index=source_index, target_index=target_index )) + + # Churn any target excess active balance of target and raise its max + if has_eth1_withdrawal_credential(target_validator): + switch_to_compounding_validator(state, target_index) ``` ## Testing diff --git a/tests/core/pyspec/eth2spec/test/electra/block_processing/test_process_consolidation_request.py b/tests/core/pyspec/eth2spec/test/electra/block_processing/test_process_consolidation_request.py index dd4f0b944..4bc4617c8 100644 --- a/tests/core/pyspec/eth2spec/test/electra/block_processing/test_process_consolidation_request.py +++ b/tests/core/pyspec/eth2spec/test/electra/block_processing/test_process_consolidation_request.py @@ -66,6 +66,106 @@ def test_basic_consolidation_in_current_consolidation_epoch(spec, state): assert state.validators[source_index].exit_epoch == expected_exit_epoch +@with_electra_and_later +@with_presets([MINIMAL], "need sufficient consolidation churn limit") +@with_custom_state( + balances_fn=scaled_churn_balances_exceed_activation_exit_churn_limit, + threshold_fn=default_activation_threshold, +) +@spec_test +@single_phase +def test_basic_consolidation_with_excess_target_balance(spec, state): + # This state has 256 validators each with 32 ETH in MINIMAL preset, 128 ETH consolidation churn + current_epoch = spec.get_current_epoch(state) + source_index = spec.get_active_validator_indices(state, current_epoch)[0] + target_index = spec.get_active_validator_indices(state, current_epoch)[1] + + # Set source to eth1 credentials + source_address = b"\x22" * 20 + set_eth1_withdrawal_credential_with_balance( + spec, state, source_index, address=source_address + ) + # Make consolidation with source address + consolidation = spec.ConsolidationRequest( + source_address=source_address, + source_pubkey=state.validators[source_index].pubkey, + target_pubkey=state.validators[target_index].pubkey, + ) + + # Set target to eth1 credentials + set_eth1_withdrawal_credential_with_balance(spec, state, target_index) + + # Set earliest consolidation epoch to the expected exit epoch + expected_exit_epoch = spec.compute_activation_exit_epoch(current_epoch) + state.earliest_consolidation_epoch = expected_exit_epoch + consolidation_churn_limit = spec.get_consolidation_churn_limit(state) + # Set the consolidation balance to consume equal to churn limit + state.consolidation_balance_to_consume = consolidation_churn_limit + + # Add excess balance + state.balances[target_index] = state.balances[target_index] + spec.EFFECTIVE_BALANCE_INCREMENT + + yield from run_consolidation_processing(spec, state, consolidation) + + # Check consolidation churn is decremented correctly + assert ( + state.consolidation_balance_to_consume + == consolidation_churn_limit - spec.MIN_ACTIVATION_BALANCE + ) + # Check exit epoch + assert state.validators[source_index].exit_epoch == expected_exit_epoch + + +@with_electra_and_later +@with_presets([MINIMAL], "need sufficient consolidation churn limit") +@with_custom_state( + balances_fn=scaled_churn_balances_exceed_activation_exit_churn_limit, + threshold_fn=default_activation_threshold, +) +@spec_test +@single_phase +def test_basic_consolidation_with_excess_target_balance_and_compounding_credentials(spec, state): + # This state has 256 validators each with 32 ETH in MINIMAL preset, 128 ETH consolidation churn + current_epoch = spec.get_current_epoch(state) + source_index = spec.get_active_validator_indices(state, current_epoch)[0] + target_index = spec.get_active_validator_indices(state, current_epoch)[1] + + # Set source to eth1 credentials + source_address = b"\x22" * 20 + set_eth1_withdrawal_credential_with_balance( + spec, state, source_index, address=source_address + ) + # Make consolidation with source address + consolidation = spec.ConsolidationRequest( + source_address=source_address, + source_pubkey=state.validators[source_index].pubkey, + target_pubkey=state.validators[target_index].pubkey, + ) + + # Set target to eth1 credentials + set_compounding_withdrawal_credential(spec, state, target_index) + + # Set earliest consolidation epoch to the expected exit epoch + expected_exit_epoch = spec.compute_activation_exit_epoch(current_epoch) + state.earliest_consolidation_epoch = expected_exit_epoch + consolidation_churn_limit = spec.get_consolidation_churn_limit(state) + # Set the consolidation balance to consume equal to churn limit + state.consolidation_balance_to_consume = consolidation_churn_limit + + # Add excess balance + state.balances[target_index] = state.balances[target_index] + spec.EFFECTIVE_BALANCE_INCREMENT + + yield from run_consolidation_processing(spec, state, consolidation) + + # Check consolidation churn is decremented correctly + assert ( + state.consolidation_balance_to_consume + == consolidation_churn_limit - spec.MIN_ACTIVATION_BALANCE + ) + # Check exit epoch + assert state.validators[source_index].exit_epoch == expected_exit_epoch + + @with_electra_and_later @with_presets([MINIMAL], "need sufficient consolidation churn limit") @with_custom_state( @@ -235,7 +335,7 @@ def test_basic_consolidation_with_compounding_credentials(spec, state): target_pubkey=state.validators[target_index].pubkey, ) - # Set target to eth1 credentials + # Set target to compounding credentials set_compounding_withdrawal_credential(spec, state, target_index) # Set the consolidation balance to consume equal to churn limit @@ -396,14 +496,8 @@ def test_consolidation_balance_through_two_churn_epochs(spec, state): @with_electra_and_later -@with_presets([MINIMAL], "need sufficient consolidation churn limit") -@with_custom_state( - balances_fn=scaled_churn_balances_exceed_activation_exit_churn_limit, - threshold_fn=default_activation_threshold, -) -@spec_test -@single_phase -def test_source_equals_target_switches_to_compounding(spec, state): +@spec_state_test +def test_basic_switch_to_compounding(spec, state): current_epoch = spec.get_current_epoch(state) source_index = spec.get_active_validator_indices(state, current_epoch)[0] @@ -419,23 +513,14 @@ def test_source_equals_target_switches_to_compounding(spec, state): target_pubkey=state.validators[source_index].pubkey, ) - # Check the the return condition - assert consolidation.source_pubkey == consolidation.target_pubkey - - yield from run_consolidation_processing( + yield from run_switch_to_compounding_processing( spec, state, consolidation, success=True ) @with_electra_and_later -@with_presets([MINIMAL], "need sufficient consolidation churn limit") -@with_custom_state( - balances_fn=scaled_churn_balances_exceed_activation_exit_churn_limit, - threshold_fn=default_activation_threshold, -) -@spec_test -@single_phase -def test_source_equals_target_switches_to_compounding_with_excess(spec, state): +@spec_state_test +def test_switch_to_compounding_with_excess(spec, state): current_epoch = spec.get_current_epoch(state) source_index = spec.get_active_validator_indices(state, current_epoch)[0] @@ -453,10 +538,36 @@ def test_source_equals_target_switches_to_compounding_with_excess(spec, state): target_pubkey=state.validators[source_index].pubkey, ) - # Check the the return condition - assert consolidation.source_pubkey == consolidation.target_pubkey + yield from run_switch_to_compounding_processing( + spec, state, consolidation, success=True + ) - yield from run_consolidation_processing( + +@with_electra_and_later +@spec_state_test +def test_switch_to_compounding_with_pending_consolidations_at_limit(spec, state): + state.pending_consolidations = [ + spec.PendingConsolidation(source_index=0, target_index=1) + ] * spec.PENDING_CONSOLIDATIONS_LIMIT + + current_epoch = spec.get_current_epoch(state) + source_index = spec.get_active_validator_indices(state, current_epoch)[0] + + # Set source to eth1 credentials + source_address = b"\x22" * 20 + set_eth1_withdrawal_credential_with_balance( + spec, state, source_index, address=source_address + ) + # Add excess balance + state.balances[source_index] = state.balances[source_index] + spec.EFFECTIVE_BALANCE_INCREMENT + # Make consolidation from source to source + consolidation = spec.ConsolidationRequest( + source_address=source_address, + source_pubkey=state.validators[source_index].pubkey, + target_pubkey=state.validators[source_index].pubkey, + ) + + yield from run_switch_to_compounding_processing( spec, state, consolidation, success=True ) @@ -831,6 +942,156 @@ def test_incorrect_unknown_target_pubkey(spec, state): ) +@with_electra_and_later +@spec_state_test +def test_switch_to_compounding_exited_source(spec, state): + # Set up an otherwise correct request + current_epoch = spec.get_current_epoch(state) + source_index = spec.get_active_validator_indices(state, current_epoch)[0] + source_address = b"\x22" * 20 + set_eth1_withdrawal_credential_with_balance( + spec, state, source_index, address=source_address + ) + consolidation = spec.ConsolidationRequest( + source_address=source_address, + source_pubkey=state.validators[source_index].pubkey, + target_pubkey=state.validators[source_index].pubkey, + ) + + # exit source + spec.initiate_validator_exit(state, source_index) + + # Check the the return condition + assert state.validators[source_index].exit_epoch != spec.FAR_FUTURE_EPOCH + + yield from run_switch_to_compounding_processing( + spec, state, consolidation, success=False + ) + + +@with_electra_and_later +@spec_state_test +def test_switch_to_compounding_inactive_source(spec, state): + # Set up an otherwise correct request + current_epoch = spec.get_current_epoch(state) + source_index = spec.get_active_validator_indices(state, current_epoch)[0] + source_address = b"\x22" * 20 + set_eth1_withdrawal_credential_with_balance( + spec, state, source_index, address=source_address + ) + consolidation = spec.ConsolidationRequest( + source_address=source_address, + source_pubkey=state.validators[source_index].pubkey, + target_pubkey=state.validators[source_index].pubkey, + ) + + # set source validator as not yet activated + state.validators[source_index].activation_epoch = spec.FAR_FUTURE_EPOCH + + # Check the the return condition + assert not spec.is_active_validator(state.validators[source_index], current_epoch) + + yield from run_switch_to_compounding_processing( + spec, state, consolidation, success=False + ) + + +@with_electra_and_later +@spec_state_test +def test_switch_to_compounding_source_bls_withdrawal_credential(spec, state): + # Set up a correct request, but source does have + # a bls withdrawal credential + current_epoch = spec.get_current_epoch(state) + source_index = spec.get_active_validator_indices(state, current_epoch)[0] + source_address = b"\x22" * 20 + consolidation = spec.ConsolidationRequest( + source_address=source_address, + source_pubkey=state.validators[source_index].pubkey, + target_pubkey=state.validators[source_index].pubkey, + ) + + # Check the the return condition + assert not spec.has_eth1_withdrawal_credential(state.validators[source_index]) + + yield from run_switch_to_compounding_processing( + spec, state, consolidation, success=False + ) + + +@with_electra_and_later +@spec_state_test +def test_switch_to_compounding_source_coumpounding_withdrawal_credential(spec, state): + # Set up a correct request, but source does have + # a compounding withdrawal credential and excess balance + current_epoch = spec.get_current_epoch(state) + source_index = spec.get_active_validator_indices(state, current_epoch)[0] + source_address = b"\x22" * 20 + consolidation = spec.ConsolidationRequest( + source_address=source_address, + source_pubkey=state.validators[source_index].pubkey, + target_pubkey=state.validators[source_index].pubkey, + ) + set_compounding_withdrawal_credential(spec, state, source_index) + state.balances[source_index] = spec.MIN_ACTIVATION_BALANCE + spec.EFFECTIVE_BALANCE_INCREMENT + + # Check the the return condition + assert not spec.has_eth1_withdrawal_credential(state.validators[source_index]) + + yield from run_switch_to_compounding_processing( + spec, state, consolidation, success=False + ) + + +@with_electra_and_later +@spec_state_test +def test_switch_to_compounding_not_authorized(spec, state): + # Set up an otherwise correct request + current_epoch = spec.get_current_epoch(state) + source_index = spec.get_active_validator_indices(state, current_epoch)[0] + source_address = b"\x22" * 20 + set_eth1_withdrawal_credential_with_balance( + spec, state, source_index, address=source_address + ) + # Make request with different source address + consolidation = spec.ConsolidationRequest( + source_address=b"\x33" * 20, + source_pubkey=state.validators[source_index].pubkey, + target_pubkey=state.validators[source_index].pubkey, + ) + + # Check the the return condition + assert not state.validators[source_index].withdrawal_credentials[12:] == consolidation.source_address + + yield from run_switch_to_compounding_processing( + spec, state, consolidation, success=False + ) + + +@with_electra_and_later +@spec_state_test +def test_switch_to_compounding_unknown_source_pubkey(spec, state): + # Set up an otherwise correct request + current_epoch = spec.get_current_epoch(state) + source_index = spec.get_active_validator_indices(state, current_epoch)[0] + source_address = b"\x22" * 20 + set_eth1_withdrawal_credential_with_balance( + spec, state, source_index, address=source_address + ) + # Make consolidation with different source pubkey + consolidation = spec.ConsolidationRequest( + source_address=source_address, + source_pubkey=b"\x00" * 48, + target_pubkey=b"\x00" * 48, + ) + + # Check the the return condition + assert not state.validators[source_index].pubkey == consolidation.source_pubkey + + yield from run_switch_to_compounding_processing( + spec, state, consolidation, success=False + ) + + def run_consolidation_processing(spec, state, consolidation, success=True): """ Run ``process_consolidation``, yielding: @@ -865,12 +1126,28 @@ def run_consolidation_processing(spec, state, consolidation, success=True): # Check source has execution credentials assert spec.has_execution_withdrawal_credential(source_validator) # Check target has compounding credentials - assert spec.has_execution_withdrawal_credential(target_validator) + assert spec.has_compounding_withdrawal_credential(state.validators[target_index]) # Check source address in the consolidation fits the withdrawal credentials assert source_validator.withdrawal_credentials[12:] == consolidation.source_address + # Check source and target are not the same + assert source_index != target_index # Check source and target were not exiting assert pre_exit_epoch_source == spec.FAR_FUTURE_EPOCH assert pre_exit_epoch_target == spec.FAR_FUTURE_EPOCH + # Check source is now exiting + assert state.validators[source_index].exit_epoch < spec.FAR_FUTURE_EPOCH + # Check that the exit epoch matches earliest_consolidation_epoch + assert state.validators[source_index].exit_epoch == state.earliest_consolidation_epoch + # Check that the withdrawable_epoch is set correctly + assert state.validators[source_index].withdrawable_epoch == ( + state.validators[source_index].exit_epoch + spec.config.MIN_VALIDATOR_WITHDRAWABILITY_DELAY + ) + # Check that the correct consolidation has been appended + expected_new_pending_consolidation = spec.PendingConsolidation( + source_index=source_index, + target_index=target_index, + ) + assert state.pending_consolidations == pre_pending_consolidations + [expected_new_pending_consolidation] # Check excess balance is queued if the target switched to compounding if pre_target_withdrawal_credentials[:1] == spec.ETH1_ADDRESS_WITHDRAWAL_PREFIX: assert state.validators[target_index].withdrawal_credentials == ( @@ -879,23 +1156,58 @@ def run_consolidation_processing(spec, state, consolidation, success=True): if pre_target_balance > spec.MIN_ACTIVATION_BALANCE: assert state.pending_balance_deposits == [spec.PendingBalanceDeposit( index=target_index, amount=(pre_target_balance - spec.MIN_ACTIVATION_BALANCE))] - # If source and target are same, no consolidation must have been initiated - if source_index == target_index: - assert state.validators[source_index].exit_epoch == spec.FAR_FUTURE_EPOCH - assert state.pending_consolidations == [] else: - # Check source is now exiting - assert state.validators[source_index].exit_epoch < spec.FAR_FUTURE_EPOCH - # Check that the exit epoch matches earliest_consolidation_epoch - assert state.validators[source_index].exit_epoch == state.earliest_consolidation_epoch - # Check that the withdrawable_epoch is set correctly - assert state.validators[source_index].withdrawable_epoch == ( - state.validators[source_index].exit_epoch + spec.config.MIN_VALIDATOR_WITHDRAWABILITY_DELAY) - # Check that the correct consolidation has been appended - expected_new_pending_consolidation = spec.PendingConsolidation( - source_index=source_index, - target_index=target_index, - ) - assert state.pending_consolidations == pre_pending_consolidations + [expected_new_pending_consolidation] + assert state.balances[target_index] == pre_target_balance + else: + assert pre_state == state + + +def run_switch_to_compounding_processing(spec, state, consolidation, success=True): + """ + Run ``process_consolidation``, yielding: + - pre-state ('pre') + - consolidation_request ('consolidation_request') + - post-state ('post'). + If ``success == False``, ``process_consolidation_request`` would return without any state change. + """ + + if success: + validator_pubkeys = [v.pubkey for v in state.validators] + source_index = spec.ValidatorIndex(validator_pubkeys.index(consolidation.source_pubkey)) + target_index = spec.ValidatorIndex(validator_pubkeys.index(consolidation.target_pubkey)) + source_validator = state.validators[source_index] + pre_exit_epoch = source_validator.exit_epoch + pre_pending_consolidations = state.pending_consolidations.copy() + pre_withdrawal_credentials = source_validator.withdrawal_credentials + pre_balance = state.balances[source_index] + else: + pre_state = state.copy() + + yield 'pre', state + yield 'consolidation_request', consolidation + + spec.process_consolidation_request(state, consolidation) + + yield 'post', state + + if success: + # Check that source and target are same + assert source_index == target_index + # Check that the credentials before the switch are of ETH1 type + assert pre_withdrawal_credentials[:1] == spec.ETH1_ADDRESS_WITHDRAWAL_PREFIX + # Check source address in the consolidation fits the withdrawal credentials + assert state.validators[source_index].withdrawal_credentials[12:] == consolidation.source_address + # Check that the source has switched to compounding + assert state.validators[source_index].withdrawal_credentials == ( + spec.COMPOUNDING_WITHDRAWAL_PREFIX + pre_withdrawal_credentials[1:] + ) + # Check excess balance is queued + assert state.balances[source_index] == spec.MIN_ACTIVATION_BALANCE + if pre_balance > spec.MIN_ACTIVATION_BALANCE: + assert state.pending_balance_deposits == [spec.PendingBalanceDeposit( + index=source_index, amount=(pre_balance - spec.MIN_ACTIVATION_BALANCE))] + # Check no consolidation has been initiated + assert state.validators[source_index].exit_epoch == spec.FAR_FUTURE_EPOCH + assert state.pending_consolidations == pre_pending_consolidations else: assert pre_state == state diff --git a/tests/core/pyspec/eth2spec/test/helpers/withdrawals.py b/tests/core/pyspec/eth2spec/test/helpers/withdrawals.py index 0ce476c86..6247c5476 100644 --- a/tests/core/pyspec/eth2spec/test/helpers/withdrawals.py +++ b/tests/core/pyspec/eth2spec/test/helpers/withdrawals.py @@ -30,7 +30,10 @@ def set_validator_fully_withdrawable(spec, state, index, withdrawable_epoch=None def set_eth1_withdrawal_credential_with_balance(spec, state, index, balance=None, address=None): if balance is None: - balance = spec.MAX_EFFECTIVE_BALANCE + if is_post_electra(spec): + balance = spec.MIN_ACTIVATION_BALANCE + else: + balance = spec.MAX_EFFECTIVE_BALANCE if address is None: address = b'\x11' * 20