Develop Push Based Custom Ceiling Partial Withdrawal Feature for EIP-7251 (MaxEB) (V1) ======= Note: after speaking to @dapplion, this solution needs to update into using deposit messaging to update custom ceiling rather than validator construct. I will update my soluton with a v2. TL;DR: ------ 1. [EIP-7251](https://eips.ethereum.org/EIPS/eip-7251) improves MaxEB, and is [scoping](https://eips.ethereum.org/EIPS/eip-7600) into [Electra](https://ethereum.github.io/consensus-specs/specs/electra/beacon-chain/) Upgrade ([~2025/Q1](https://x.com/TimBeiko/status/1793684244612407687)), and with the mission to [sustain validator set size growth](https://ethresear.ch/t/sticking-to-8192-signatures-per-slot-post-ssf-how-and-why/17989), and preparing for [SSF](https://ethereum.org/en/roadmap/single-slot-finality/) and [ePBS](https://ethereum.org/en/roadmap/pbs/). 2. I have [studied](https://hackmd.io/@georgesheth/HJKkx3NSR) that [EIP-7251](https://eips.ethereum.org/EIPS/eip-7251) has 6 features and explains what they are. Custom Ceiling Partial Withdrawal is still left for design and implementation. 3. This article aims to explain how to implement Custom Ceiling Partial Withdrawal at coding level ([Spec](https://ethereum.github.io/consensus-specs/specs/electra/beacon-chain/) and [Test](https://github.com/ethereum/execution-spec-tests)). In summary, 4. Welcome suggestion and collaboration. 5. My other relevent articles on EIP-7251: - [BeaconState and Validator Balance for EIP-7251](https://hackmd.io/@georgesheth/BJGl24HYA) - [History of EIP-7251](https://hackmd.io/@georgesheth/rJxnQBrtC) - [Feature Lists of EIP-7251](https://hackmd.io/@georgesheth/HJKkx3NSR) - [Implement EIP-7251](https://hackmd.io/@georgesheth/Hk2r2BHFC) - [Develop Push Based Custom Ceiling Partial Feature Withdraw for EIP-7251](https://hackmd.io/@georgesheth/BJ5tkLBtA) Before and After EIP-7251 ========================= ![whiteboard_exported_image (49)](https://hackmd.io/_uploads/BJDPl8BKC.png) Introduction to Ethereum Staking Withdrawals ============================================ 1. **[Staking withdrawals](https://ethereum.org/en/staking/withdrawals/)** refer to transfers of ETH from a validator account on Ethereum's consensus layer (the Beacon Chain), to the execution layer where it can be transacted with. 1. Before the Shanghai/Capella upgrade, you couldn't use or access your staked ETH. 2. After Shanghai/Capella upgrade, you can opt-in to automatically receive your rewards into a chosen account, and you can also withdraw your staked ETH whenever you want. 1. You can set a withdraw address on [Ethereum Staking Launchpad](https://launchpad.ethereum.org/en/). 3. **Each validator account can only be assigned a single withdrawal address, one time.** Once an address is chosen and submitted to the consensus layer, this cannot be undone or changed again. 2. Two UX of triggering withdrawals has been proposed as described in this [article](https://luozhu.mirror.xyz/ojI7HibWU8JcHR2DBUdWZ7WitIYpWXoZDuyEpyRwduk): 1. Pull-based withdrawals trigger (e.g. EIP-4788): 2. Push-based withdrawals trigger (e.g. EIP-4895): it wins out, see [Luozhu's article](https://luozhu.mirror.xyz/ojI7HibWU8JcHR2DBUdWZ7WitIYpWXoZDuyEpyRwduk) for more details. 1. This article focuses on push-based solution 3. Before EIP-7251, staking withdrawals looks like: 1. Requirement: Providing a withdrawal address is required before *any* funds can be transferred out of a validator account balance. 2. Push-based full exit withdrawal via voluntary exit: Users can **exit staking entirely**, unlocking their full validator balance. 1. Users sign and broadcast a "**voluntary exit**" message with validator keys which will start the process of exiting from staking. This is done with your validator client and submitted to your consensus node, and does not require gas. 2. The process of a validator exiting from staking takes variable amounts of time, depending on how many others are exiting at the same time. Once complete, this account will no longer be responsible for performing validator network duties, is no longer eligible for rewards, and no longer has their ETH "at stake". At this time the account will be marked as fully "withdrawable". 3. Once an account is flagged as "withdrawable", and withdrawal credentials have been provided, there is nothing more a user needs to do aside from wait. Accounts are automatically and continuously swept by block proposers for eligible exited funds, and your account balance will be transferred in full (also known as a "full withdrawal") during the next [sweep](https://ethereum.org/en/staking/withdrawals/#validator-sweeping). 3. Push-based MaxEB ceiling Partial withdrawal: **Reward payments of excess balance** over `MAX_EFFECTIVE_BALANCE` (currently set as 32 ETH) will automatically and regularly be sent to a withdrawal address linked to each validator, once provided by the user. 1. Any balance above 32 ETH earned through rewards does not actually contribute to principal, or increase the weight of this validator on the network, and is thus automatically withdrawn as a reward payment every few days. 2. Aside from providing a withdrawal address one time, these rewards do not require any action from the validator operator. This is all initiated on the consensus layer, thus no gas (transaction fee) is required at any step. 4. Push-based custom-ceiling partial withdrawal: **Reward payments of excess balance** over a custom-ceiling is not needed. 1. Becasue `MAX_EFFECTIVE_BALANCE` is both the floor and ceiling. So there is no space for a custom ceiling. 2. Since EIP-7251 increases the `MAX_EFFECTIVE_BALANCE` from 32 ETH to 2048 ETH. The space for custom ceilings appears now. 3. This article aims to design a custom ceiling. 5. Manual withdrawal with custom amount is not allowed. 1. "It is not possible to manually request specific amounts of ETH to be withdrawn." as confirmed here "https://ethereum.org/en/staking/withdrawals/". 4. Faq: 1. Validator Sweeping: 1. When a validator is scheduled to propose the next block, it is required to build a withdrawal queue, of up to 16 (controlled by constant) eligible withdrawals. 2. This is done by originally starting with validator index 0, determining if there is an eligible withdrawal for this account per the rules of the protocol, and adding it to the queue if there is. 3. The validator set to propose the following block will pick up where the last one left off, progressing in order indefinitely. 2. Checking an account for withdrawals: 1. While a proposer is sweeping through validators for possible withdrawals, each validator being checked is evaluated against a short series of questions to determine if a withdrawal should be triggered, and if so, how much ETH should be withdrawn. 1. **Has a withdrawal address been provided?** If no withdrawal address has been provided, the account is skipped and no withdrawal initiated. 2. **Is the validator exited and withdrawable?** If the validator has fully exited, and we have reached the epoch where their account is considered to be "withdrawable", then a full withdrawal will be processed. This will transfer the entire remaining balance to the withdrawal address. 3. **Is the effective balance maxed out at MAX_EFFECTIVE_BALANCE (32 ETH currently)?** If the account has withdrawal credentials, is not fully exited, and has rewards above 32 waiting, a partial withdrawal will be processed which transfers only the rewards above 32 to the user's withdrawal address. 3. Gas fee: 1. This approach to staking withdrawals avoids requiring stakers to manually submit a transaction requesting a particular amount of ETH to be withdrawn. This means there is **no gas (transaction fee) required**, and withdrawals also do not compete for existing execution layer block space. 4. Why withdrawals address only be set once? 1. Withdraw address are EOA or CA, have no way to communicate a message back to the consensus layer that would signal a change of validator credentials, and adding this functionality would add unnecessary complexity to the protocol. 2. As an alternative to changing the withdrawal address for a particular validator, users may choose to set a smart contract as their withdrawal address which could handle key rotating, such as a Safe. Users who set their funds to their own EOA can perform a full exit to withdraw all of their staked funds, and then re-stake using new credentials. Review of existing discussion around custom-ceiling: ==================================================== 1. After EIP-7251, there is a space for custom ceiling between 32 ETH to 2048 ETH which is defined by `MIN_ACTIVATION_BALANCE` and `MAX_EFFECTIVE_BALANCE`. 1. Be more specific, any excess balance beyond the custom ceiling should enjoy the same mechanism as partial withdraw beyond MaxEB. 2. Mike briefly discussed the need for custom-ceiling, but did not have a solution, in EIP-7251: 1. ***Permitting validators to set custom ceilings for their validator to indicate where the partial withdrawal sweep activates.*** Allows more flexibility in defining the "ceiling" of a validator's effective balance. 2. ***Permitting validators to set custom ceilings for their validator to indicate where the partial withdrawal sweep activates.*** 1. *To get access to rewards, validators might want the flexibility to set custom ceilings for their effective balance. This gives them more optionality and is a clean way to continue supporting the partial-withdrawal sweep (a gasless way to extract rewards).* 2. *For validators that choose to raise their effective balance ceiling, allowing for custom partial withdrawals triggered from the execution layer increases the flexibility of the staking configurations. Validators can choose when and how much they withdraw but will have to pay gas for the EL transaction.* 3. Then the community discussed custom ceiling in EIP-7251 breakout room. 1. In CALL #1, https://hackmd.io/@wmoBhF17RAOH2NZ5bNXJVg/S1U86pzgR 1. It recommends implementing execution layer initialted partial withdrawals. 2. But custom ceiling is excluded to save on complexity. 2. In CALL #3, https://hackmd.io/@philknows/BJCaLJf1A#Custom-celings-To-be-continued-in-next-meeting 1. It discussed the impacts to validator consolidation and the necessary custom ceiling again. 2. It also discussed the solution to enable custom ceiling, which is simple, but not conclusion. 3. In CALL #4, https://hackmd.io/@philknows/Sy2kQAq1C?#Custom-Ceilings 1. Mikhail likes to have custom ceilings, otherwise causing a partial withdrawal queue on EL. 2. Lido feels the custom ceiling is a nice feature to rebalance stake across node operators, but may not be a high priority as it takes longer time to accumulate into 2048 ETH. 4. In CALL #5, https://hackmd.io/@philknows/S1JbLXmlA#Custom-Ceilings 1. Custom ceilings are discussed again, but pools have no strong opinion, as they feel they help solo staker mostly. 2. Concerns about not being implemented is strong, as it would make EL withdraw requests heavily. 3. The current decision is to leave it. 5. In CALL #6, https://hackmd.io/@philknows/Hywht12eR#Custom-Ceilings 1. Again, staking pools not spoken up for this implementation. 2. The decision is to first focus on technical solutions and even push it into another EIP with a solid approach. 4. This is why the current Electra spec has no custom ceiling being implemented. 5. Be giving the demand, this article aims to design a solid solution. Design push-based custom-ceiling partial withdrawal =================================================== ![whiteboard_exported_image (51)](https://hackmd.io/_uploads/BJ0-ZLrF0.png) Implement push-based custom-ceiling partial withdrawal ====================================================== 1. Electra Spec already defined `PendingPartialWithdrawal` container and we can reuse 2. We need add a variable for custom ceiling in Validator construct ```python= class Validator(Container): pubkey: BLSPubkey withdrawal_credentials: Bytes32 # Commitment to pubkey for withdrawals effective_balance: Gwei # Balance at stake slashed: boolean # Status epochs activation_eligibility_epoch: Epoch # When criteria for activation were met activation_epoch: Epoch exit_epoch: Epoch withdrawable_epoch: Epoch # When validator can withdraw funds #set a custom ceiling for partial withdrawal effective_balance_ceiling: Gwei # a value of (32, 33, ..., 2047, 2048) ``` 1. We need a help function to get the correct custom ceiling as there are some exceptions we need to handle. ```python= def get_validator_effective_balance_ceiling(validator: Validator) -> Gwei: """ Get max effective balance for ``validator``. """ # get the correct effective_balance_ceiling effective_balance_ceiling = MAX_EFFECTIVE_BALANCE # deal with case that effective_balance_ceiling is not set if validator.effective_balance_ceiling not None: # we can not let effective_balance_ceiling to be lower than MIN_ACTIVATION_BALANCE if validator.effective_balance_ceiling <= MIN_ACTIVATION_BALANCE: effective_balance_ceiling = MIN_ACTIVATION_BALANCE else: if effective_balance_ceiling < MAX_EFFECTIVE_BALANCE: effective_balance_ceiling = validator.effective_balance_ceiling return effective_balance_ceiling ``` 1. We need to update `process_withdrawal_request()` to update excess balance logic based on custom ceiling ```python= def process_withdrawal_request( state: BeaconState, withdrawal_request: WithdrawalRequest ) -> None: amount = withdrawal_request.amount is_full_exit_request = amount == FULL_EXIT_REQUEST_AMOUNT # If partial withdrawal queue is full, only full exits are processed if len(state.pending_partial_withdrawals) == PENDING_PARTIAL_WITHDRAWALS_LIMIT and not is_full_exit_request: return validator_pubkeys = [v.pubkey for v in state.validators] # Verify pubkey exists request_pubkey = withdrawal_request.validator_pubkey if request_pubkey not in validator_pubkeys: return index = ValidatorIndex(validator_pubkeys.index(request_pubkey)) validator = state.validators[index] # Verify withdrawal credentials has_correct_credential = has_execution_withdrawal_credential(validator) is_correct_source_address = ( validator.withdrawal_credentials[12:] == withdrawal_request.source_address ) if not (has_correct_credential and is_correct_source_address): return # Verify the validator is active if not is_active_validator(validator, get_current_epoch(state)): return # Verify exit has not been initiated if validator.exit_epoch != FAR_FUTURE_EPOCH: return # Verify the validator has been active long enough if get_current_epoch(state) < validator.activation_epoch + SHARD_COMMITTEE_PERIOD: return pending_balance_to_withdraw = get_pending_balance_to_withdraw(state, index) if is_full_exit_request: # Only exit validator if it has no pending withdrawals in the queue if pending_balance_to_withdraw == 0: initiate_validator_exit(state, index) return # use help function to get effective_balance_ceiling effective_balance_ceiling = get_validator_effective_balance_ceiling(validator) has_sufficient_effective_balance = validator.effective_balance >= MIN_ACTIVATION_BALANCE # - has_excess_balance = state.balances[index] > MIN_ACTIVATION_BALANCE + pending_balance_to_withdraw # update the excess balance logic to based on effective_balance_ceiling has_excess_balance = state.balances[index] > effective_balance_ceiling + pending_balance_to_withdraw # Only allow partial withdrawals with compounding withdrawal credentials if has_compounding_withdrawal_credential(validator) and has_sufficient_effective_balance and has_excess_balance: to_withdraw = min( # - state.balances[index] - MIN_ACTIVATION_BALANCE - pending_balance_to_withdrawal state.balances[index] - effective_balance_ceiling - pending_balance_to_withdraw, amount ) exit_queue_epoch = compute_exit_epoch_and_update_churn(state, to_withdraw) withdrawable_epoch = Epoch(exit_queue_epoch + MIN_VALIDATOR_WITHDRAWABILITY_DELAY) state.pending_partial_withdrawals.append(PendingPartialWithdrawal( index=index, amount=to_withdraw, withdrawable_epoch=withdrawable_epoch, )) ``` 1. We need to update `process_effective_balance_updates()` to take custom ceiling into considtion. ```python= def process_effective_balance_updates(state: BeaconState) -> None: # Update effective balances with hysteresis for index, validator in enumerate(state.validators): balance = state.balances[index] HYSTERESIS_INCREMENT = uint64(EFFECTIVE_BALANCE_INCREMENT // HYSTERESIS_QUOTIENT) DOWNWARD_THRESHOLD = HYSTERESIS_INCREMENT * HYSTERESIS_DOWNWARD_MULTIPLIER UPWARD_THRESHOLD = HYSTERESIS_INCREMENT * HYSTERESIS_UPWARD_MULTIPLIER # use help function to get effective_balance_ceiling # - EFFECTIVE_BALANCE_LIMIT = ( # - MAX_EFFECTIVE_BALANCE_ELECTRA if has_compounding_withdrawal_credential(validator) # - else if MIN_ACTIVATION_BALANCE # -) EFFECTIVE_BALANCE_LIMIT = get_validator_effective_balance_ceiling(validator) if ( balance + DOWNWARD_THRESHOLD < validator.effective_balance or validator.effective_balance + UPWARD_THRESHOLD < balance ): validator.effective_balance = min(balance - balance % EFFECTIVE_BALANCE_INCREMENT, EFFECTIVE_BALANCE_LIMIT) ``` 1. We need a way to let the execution layer set/update the validator's custom ceiling. 2. Firstly, we need to create a `PendingEffectiveBalanceCeilingUpdates()`**.** ```python= class PendingEffectiveBalanceCeilingUpdates(Container): index: ValidatorIndex amount: Gwei ``` 1. Secondly, we need to ask if should control the custom ceiling update speed: **`PENDING_EFFECTIVE_BALANCE_CEILING_UPDATES_LIMIT`** 1. TODO: more study is need 2. Thirdly, we need to get the request from the exeuction layer to update BeaconState. ```python= class BeaconState(Container): # Versioning genesis_time: uint64 genesis_validators_root: Root slot: Slot fork: Fork # History latest_block_header: BeaconBlockHeader block_roots: Vector[Root, SLOTS_PER_HISTORICAL_ROOT] state_roots: Vector[Root, SLOTS_PER_HISTORICAL_ROOT] historical_roots: List[Root, HISTORICAL_ROOTS_LIMIT] # Eth1 eth1_data: Eth1Data eth1_data_votes: List[Eth1Data, EPOCHS_PER_ETH1_VOTING_PERIOD * SLOTS_PER_EPOCH] eth1_deposit_index: uint64 # Registry validators: List[Validator, VALIDATOR_REGISTRY_LIMIT] balances: List[Gwei, VALIDATOR_REGISTRY_LIMIT] # Randomness randao_mixes: Vector[Bytes32, EPOCHS_PER_HISTORICAL_VECTOR] # Slashings slashings: Vector[Gwei, EPOCHS_PER_SLASHINGS_VECTOR] # Per-epoch sums of slashed effective balances # Participation previous_epoch_participation: List[ParticipationFlags, VALIDATOR_REGISTRY_LIMIT] current_epoch_participation: List[ParticipationFlags, VALIDATOR_REGISTRY_LIMIT] # Finality justification_bits: Bitvector[JUSTIFICATION_BITS_LENGTH] # Bit set for every recent justified epoch previous_justified_checkpoint: Checkpoint current_justified_checkpoint: Checkpoint finalized_checkpoint: Checkpoint # Inactivity inactivity_scores: List[uint64, VALIDATOR_REGISTRY_LIMIT] # Sync current_sync_committee: SyncCommittee next_sync_committee: SyncCommittee # Execution latest_execution_payload_header: ExecutionPayloadHeader # [Modified in Electra:EIP6110:EIP7002] # Withdrawals next_withdrawal_index: WithdrawalIndex next_withdrawal_validator_index: ValidatorIndex # Deep history valid from Capella onwards historical_summaries: List[HistoricalSummary, HISTORICAL_ROOTS_LIMIT] deposit_requests_start_index: uint64 # [New in Electra:EIP6110] deposit_balance_to_consume: Gwei # [New in Electra:EIP7251] exit_balance_to_consume: Gwei # [New in Electra:EIP7251] earliest_exit_epoch: Epoch # [New in Electra:EIP7251] consolidation_balance_to_consume: Gwei # [New in Electra:EIP7251] earliest_consolidation_epoch: Epoch # [New in Electra:EIP7251] pending_balance_deposits: List[PendingBalanceDeposit, PENDING_BALANCE_DEPOSITS_LIMIT] # [New in Electra:EIP7251] # [New in Electra:EIP7251] pending_partial_withdrawals: List[PendingPartialWithdrawal, PENDING_PARTIAL_WITHDRAWALS_LIMIT] pending_consolidations: List[PendingConsolidation, PENDING_CONSOLIDATIONS_LIMIT] # [New in Electra:EIP7251] # Enable to set/update effective balance ceiling pending_effective_balance_ceiling_updates: List[PendingEffectiveBalanceCeilingUpdates, PENDING_EFFECTIVE_BALANCE_CEILING_UPDATES_LIMIT] ``` 1. We need to update `process_epoch()` to process pending effective balance ceiling set/update. ```python= def process_epoch(state: BeaconState) -> None: process_justification_and_finalization(state) process_inactivity_updates(state) process_rewards_and_penalties(state) process_registry_updates(state) # [Modified in Electra:EIP7251] process_slashings(state) process_eth1_data_reset(state) process_pending_balance_deposits(state) # [New in Electra:EIP7251] process_pending_consolidations(state) # [New in Electra:EIP7251] process_effective_balance_updates(state) # [Modified in Electra:EIP7251] process_slashings_reset(state) process_randao_mixes_reset(state) process_historical_summaries_update(state) process_participation_flag_updates(state) process_sync_committee_updates(state) # need to process pending effective balance ceiling update process_pending_effective_balance_ceiling_updates(state) ``` 1. We need to create `process_pending_effective_balance_ceiling_updates()` . ```python= def process_pending_effective_balance_ceiling_updates(state: BeaconState) -> None: available_for_processing = state.effective_balance_ceiling_updates_to_consume processed_amount = 0 next_updates_index = 0 updates_to_postpone = [] for update in state.PendingEffectiveBalanceCeilingUpdates: validator = state.validators[update.index] # Validator is exiting, postpone the deposit until after withdrawable epoch if validator.exit_epoch < FAR_FUTURE_EPOCH: if get_current_epoch(state) <= validator.withdrawable_epoch: updates_to_postpone.append(update) # Validator is not exiting, attempt to process update else: # Deposit does not fit in the churn, no more deposit processing in this epoch. if processed_amount > available_for_processing: # set/update the effective_balance_ceiling validator.effective_balance_ceiling = update.amount break # Regardless of how the deposit was handled, we move on in the queue. next_updates_index += 1 state.pending_effective_balance_ceiling_updates = state.pending_effective_balance_ceiling_updates[next_updates_index:] if len(state.pending_effective_balance_ceiling_updates) == 0: state.effective_balance_ceiling_updates_to_consume = Gwei(0) else: state.effective_balance_ceiling_updates_to_consume = available_for_processing - processed_amount state.pending_effective_balance_ceiling_updates += updates_to_postpone ``` Test push-based custom-ceiling partial withdrawal ================================================= 1. To be continued on writing