|
| 1 | +--- |
| 2 | +eip: 8070 |
| 3 | +title: Prevent using consolidations as withdrawals |
| 4 | +description: Cancels consolidation if the max effective balance of the target validator will be exceeded, preventing the withdrawal of the unused balance |
| 5 | +author: Mikhail Kalinin (@mkalinin), Francesco D'Amato (@fradamt) |
| 6 | +discussions-to: <URL> |
| 7 | +status: Draft |
| 8 | +type: Standards Track |
| 9 | +category: Core |
| 10 | +created: 2025-10-29 |
| 11 | +--- |
| 12 | + |
| 13 | +## Abstract |
| 14 | + |
| 15 | +Cancels a consolidation request if the effective balance of the target validator would exceed the max effective balance after processing it, which would result in the excess balance being withdrawn. This is an unintended way to speed up withdrawals when the consolidation queue is faster than the exit queue. |
| 16 | + |
| 17 | +## Motivation |
| 18 | + |
| 19 | +The existing design of consolidation mechanism leaves an opportunity to use consolidation queue for exits which becomes appealing to be abused when there is an imbalance between exit and consolidation queues favoring the latter. |
| 20 | + |
| 21 | +At the date of writing this EIP, the consolidation flaw is being heavily exploited. There are public write ups on how to speed up withdrawals by using this vulnerability. |
| 22 | + |
| 23 | +Even though this is a UX rather than security issue, consolidation queue was never meant to be used for withdrawals, which makes the fix introduced by this EIP an important modification. |
| 24 | + |
| 25 | +## Specification |
| 26 | + |
| 27 | +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) and [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174). |
| 28 | + |
| 29 | +Starting from the beginning of the epoch when this EIP is activated, Consensus Layer client **MUST** use modified `process_consolidation_request` function which code is outlined below. |
| 30 | + |
| 31 | +### New `get_pending_balance_to_consolidate` |
| 32 | + |
| 33 | +```python |
| 34 | +def get_pending_balance_to_consolidate(state: BeaconState, target_index: ValidatorIndex) -> Gwei: |
| 35 | + pending_balance_to_consolidate = Gwei(0) |
| 36 | + for pending_consolidation in state.pending_consolidations: |
| 37 | + if pending_consolidation.target_index == target_index: |
| 38 | + source_validator = state.validators[pending_consolidation.source_index] |
| 39 | + pending_balance_to_consolidate += source_validator.effective_balance |
| 40 | + return pending_balance_to_consolidate |
| 41 | +``` |
| 42 | + |
| 43 | +### Modified `process_consolidation_request` |
| 44 | + |
| 45 | +*Note*: This function is extended with the check of the target's balance after consolidation and cancels consolidation request if the balance exceedes the max effective balance. |
| 46 | + |
| 47 | +```python |
| 48 | +def process_consolidation_request( |
| 49 | + state: BeaconState, consolidation_request: ConsolidationRequest |
| 50 | +) -> None: |
| 51 | + if is_valid_switch_to_compounding_request(state, consolidation_request): |
| 52 | + validator_pubkeys = [v.pubkey for v in state.validators] |
| 53 | + request_source_pubkey = consolidation_request.source_pubkey |
| 54 | + source_index = ValidatorIndex(validator_pubkeys.index(request_source_pubkey)) |
| 55 | + switch_to_compounding_validator(state, source_index) |
| 56 | + return |
| 57 | + |
| 58 | + # Verify that source != target, so a consolidation cannot be used as an exit |
| 59 | + if consolidation_request.source_pubkey == consolidation_request.target_pubkey: |
| 60 | + return |
| 61 | + # If the pending consolidations queue is full, consolidation requests are ignored |
| 62 | + if len(state.pending_consolidations) == PENDING_CONSOLIDATIONS_LIMIT: |
| 63 | + return |
| 64 | + # If there is too little available consolidation churn limit, consolidation requests are ignored |
| 65 | + if get_consolidation_churn_limit(state) <= MIN_ACTIVATION_BALANCE: |
| 66 | + return |
| 67 | + |
| 68 | + validator_pubkeys = [v.pubkey for v in state.validators] |
| 69 | + # Verify pubkeys exists |
| 70 | + request_source_pubkey = consolidation_request.source_pubkey |
| 71 | + request_target_pubkey = consolidation_request.target_pubkey |
| 72 | + if request_source_pubkey not in validator_pubkeys: |
| 73 | + return |
| 74 | + if request_target_pubkey not in validator_pubkeys: |
| 75 | + return |
| 76 | + source_index = ValidatorIndex(validator_pubkeys.index(request_source_pubkey)) |
| 77 | + target_index = ValidatorIndex(validator_pubkeys.index(request_target_pubkey)) |
| 78 | + source_validator = state.validators[source_index] |
| 79 | + target_validator = state.validators[target_index] |
| 80 | + |
| 81 | + # Verify source withdrawal credentials |
| 82 | + has_correct_credential = has_execution_withdrawal_credential(source_validator) |
| 83 | + is_correct_source_address = ( |
| 84 | + source_validator.withdrawal_credentials[12:] == consolidation_request.source_address |
| 85 | + ) |
| 86 | + if not (has_correct_credential and is_correct_source_address): |
| 87 | + return |
| 88 | + |
| 89 | + # Verify that target has compounding withdrawal credentials |
| 90 | + if not has_compounding_withdrawal_credential(target_validator): |
| 91 | + return |
| 92 | + |
| 93 | + # Verify the source and the target are active |
| 94 | + current_epoch = get_current_epoch(state) |
| 95 | + if not is_active_validator(source_validator, current_epoch): |
| 96 | + return |
| 97 | + if not is_active_validator(target_validator, current_epoch): |
| 98 | + return |
| 99 | + # Verify exits for source and target have not been initiated |
| 100 | + if source_validator.exit_epoch != FAR_FUTURE_EPOCH: |
| 101 | + return |
| 102 | + if target_validator.exit_epoch != FAR_FUTURE_EPOCH: |
| 103 | + return |
| 104 | + # Verify the source has been active long enough |
| 105 | + if current_epoch < source_validator.activation_epoch + SHARD_COMMITTEE_PERIOD: |
| 106 | + return |
| 107 | + # Verify the source has no pending withdrawals in the queue |
| 108 | + if get_pending_balance_to_withdraw(state, source_index) > 0: |
| 109 | + return |
| 110 | + |
| 111 | + # [New in EIPXXXX] |
| 112 | + # Verify that the consolidating balance will |
| 113 | + # end up as active balance, not as excess balance |
| 114 | + target_balance_after_consolidation = ( |
| 115 | + get_pending_balance_to_consolidate(state, target_index) |
| 116 | + + source_validator.effective_balance |
| 117 | + + state.balances[target_index] |
| 118 | + ) |
| 119 | + if target_balance_after_consolidation > get_max_effective_balance(target_validator): |
| 120 | + return |
| 121 | + |
| 122 | + # Initiate source validator exit and append pending consolidation |
| 123 | + source_validator.exit_epoch = compute_consolidation_epoch_and_update_churn( |
| 124 | + state, source_validator.effective_balance |
| 125 | + ) |
| 126 | + source_validator.withdrawable_epoch = Epoch( |
| 127 | + source_validator.exit_epoch + MIN_VALIDATOR_WITHDRAWABILITY_DELAY |
| 128 | + ) |
| 129 | + state.pending_consolidations.append( |
| 130 | + PendingConsolidation(source_index=source_index, target_index=target_index) |
| 131 | + ) |
| 132 | +``` |
| 133 | + |
| 134 | +## Rationale |
| 135 | + |
| 136 | +### Iterating over pending consolidaitons |
| 137 | + |
| 138 | +The new design introduces an iteration over pending consolidations which increases complexity of consolidation processing. |
| 139 | + |
| 140 | +This is done to handle the case when there are multiple consolidations with the same target and each of them doesn't exceed the max effective balance while all of them together does. |
| 141 | + |
| 142 | +## Backwards Compatibility |
| 143 | + |
| 144 | +This EIP introduces backwards-incompatible changes to the Consensus Layer and must be activated via scheduled network upgrade. |
| 145 | + |
| 146 | +## Test Cases |
| 147 | + |
| 148 | +* test_single_consolidation_request_at_max_eb |
| 149 | +* test_no_pending_consolidations_exceeding_max_eb |
| 150 | +* test_single_pending_consolidation_exceeding_max_eb |
| 151 | +* test_multiple_pending_consolidations_at_max_eb |
| 152 | +* test_multiple_pending_consolidations_exceeding_max_eb |
| 153 | +* test_exceeding_max_eb_with_the_target_balance_but_not_eb |
| 154 | +* test_exceeding_max_eb_with_the_source_eb_but_not_the_balance |
| 155 | +* test_multiple_pending_consolidations_exceeding_max_eb_with_the_source_eb_but_not_the_balance |
| 156 | + |
| 157 | +All of the above test cases are implemented [here](../assets/eip-8070/test_process_consolidation_request.py). |
| 158 | + |
| 159 | +## Security Considerations |
| 160 | + |
| 161 | +When consolidation reuqest results in max effective balance exceed it is cancelled on the Consensus Layer, |
| 162 | +neither request fee nor transaction gast cost are refunded in this case. |
| 163 | + |
| 164 | +## Copyright |
| 165 | + |
| 166 | +Copyright and related rights waived via [CC0](../LICENSE.md). |
0 commit comments