This EIP proposes updating Ethereum’s validator exit churn calculation by dynamically adjusting the churn limit at the start of each 256-epoch period (“generation”) based on historical validator exits. Specifically, the maximum churn allowed in each generation will adjust according to the unused churn from the past 16 generations. This approach reduces validator wait times during periods of high exit demand without sacrificing network safety.
Motivation
Ethereum currently implements a fixed, rate-limited queue for validator exits to ensure the security and stability of the network. The exit queue ensures the economic security of transactions finalized by the validator set. Suppose a malicious validator could immediately exit the set without any delay. In that case, they may attempt to execute a double spend attack by publishing a block while withholding a conflicting block, which they release after their stake has exited the protocol. The slashing mechanism can no longer hold the malicious validator accountable, and two conflicting finalized transactions may exist (if the attacker has 1/3 of the total stake and successfully splits the 2/3 honest majority in half).
The CHURN_LIMIT_QUOTIENT=2^16 was selected according to the rough heuristic that it should take approximately one month for 10% of the stake to exit. With 1,053,742 validators, we have a churn limit of 16 exits per epoch. 225 epochs per day $\implies$ 3600 exits per day $\implies$ 108,000 exits per 30 days. Then 108,000/1,053,742 $\approx$ 0.10. We can interpret this as “the economic security of a finalized transaction decreases by no more than 10% within one month.”
Another way of understanding the 16 exits per epoch security model is that it encodes the following constraints around validator exits:
at most 16 validators exit in the next one epoch, and
at most 32 validators exit in the next two epochs, and
at most 48 validators exit in the next three epochs, and
…
at most 16 $\cdot$ n validators exit in the next n epochs.
While these constraints are simple to understand, the fixed per-epoch churn limit can result in unnecessarily long validator withdrawal delays during periods of higher-than-average exit demand, such as during institutional liquidations or market events. We argue that we should choose a single constraint from the above set and implement that flexibly.
We illustrate this with an example. With one million validators, the current protocol specifies that 16 validators may exit per epoch. Over two weeks, this corresponds to 50,400 exits. This translates directly to “no more than 5.04% of the validators (equiv. stake) can exit within two weeks.” Now imagine that in the past 13 days, no validators have exited the protocol, and thus, none of the two-week churn limit has been used. If a large staking operator with 3% of the validator set (30,000 validators) seeks to withdraw immediately, they should be able to – this doesn’t violate the two-week limit of 5.04%. However, since only 16 exits can be processed per epoch moving forward, they are forced to wait 1875 epochs (equiv. 8.33 days).
Key observation: If we enable the protocol to look backward at the exit history, we no longer need the per-epoch limit looking forward.
For example, say we chose the following constraint explicitly:
Proposed weak subjectivity constraint:No more than 50,400 exits in two weeks.
Then, we only need to ensure that the constraint is honored over every rolling two-week window without setting a hard cap on exits during every epoch. A dynamically adjusted churn limit based on historical validator exit data allows Ethereum to flexibly accommodate spikes in exit demand while preserving the same security over every two-week window. By tracking the unused churn capacity of recent generations (periods), we can safely increase the churn limit when the network consistently operates below capacity, significantly improving the validator exit experience.
Specification
Since the validator exit process is complex, we start with the stack trace and a verbal description of the end-to-end process in Electra.
initiate_validator_exit – a validator signals their intent to exit, which is actuated by setting validator.exit_epoch and validator.withdrawable_epoch based on the output of compute_exit_epoch_and_update_churn.
compute_exit_epoch_and_update_churn – is used to determine the exit epoch of a validator. This function implements the exit queue in the following way:
get_balance_churn_limit - returns the amount of withdrawable ETH per epoch by dividing the total active balance by 2**16.
set exit_balance_to_consume to the churn available in the current furthest epoch where some exits will be processed.
if exit_balance > exit_balance_to_consume, then we calculate the number of extra epochs the exit consumes to set the final exit_epoch.
This EIP changes how the churn limit and exit epochs are calculated by examining the number of exits in the previous 14 generations.
get_exit_churn_limit – implements the new churn limit calculation by summing over historical generations.
per_epoch_exit_churn is set using get_activation_exit_churn_limit as today by dividing the total stake by 2**16 and capping the result at 256 ETH. With today’s numbers, this returns 256 ETH that can churn per epoch.
per_generation_exit_churn is set by multiplying the per epoch exit churn by 256 (the number of epochs in a generation). With today’s numbers, this is 256 $\cdot$ 256 = 65536 ETH that can churn per generation.
total_unused_exit_churn is calculated by looping through the past generations and summing the amount of unused capacity, per_generation_exit_churn - churn_usage.
The final returned value is capped at per_epoch_exit_churn * 8. With today’s numbers, this max is 256 $\cdot$ 8 = 2048 ETH per epoch.
compute_exit_epoch_and_update_churn – is modified to use get_exit_churn_limit and account for any churn consumed in the generation where the exit will be processed.
Definitions
Generation: A period consisting of 256 epochs.
Historical Churn Vector: A fixed-size array (exit_churn_vector) that records the total amount of churned ETH in the previous 16 generations.
Unused Churn: The difference between the maximum possible churn and the actual churn that occurred within a generation.
Preset
Name
Value
Comment
EPOCHS_PER_CHURN_GENERATION
uint64(2**8) (= 256)
~27 hours
GENERATIONS_PER_EXIT_CHURN_VECTOR
uint64(2**4) (= 16)
~18 days
GENERATIONS_PER_EXIT_CHURN_LOOKAHEAD
uint(2**1) (= 2)
EXIT_CHURN_SLACK_MULTIPLIER
uint(2**3) (= 8)
New State Variables
Add the following to the state:
exit_churn_vector:Vector[uint64,GENERATIONS_PER_EXIT_CHURN_VECTOR]# total exited balance per generation for GENERATIONS_PER_EXIT_CHURN_VECTOR generations
Initialization
Upon activation of this EIP, initialize new variables:
# Mark the churn of each generation as fully consumed
state.exit_churn_vector=[UINT64_MAX]*GENERATIONS_PER_EXIT_CHURN_VECTOR# Update lookahead generations
earliest_exit_epoch_generation=state.earliest_exit_epoch//EPOCHS_PER_CHURN_GENERATIONcurrent_epoch_generation=get_current_epoch(state)//EPOCHS_PER_CHURN_GENERATIONlookahead_generation=current_epoch_generation+GENERATIONS_PER_EXIT_CHURN_LOOKAHEADforgenerationinrange(current_epoch_generation,lookahead_generation):ifearliest_exit_epoch_generation<generation:state.exit_churn_vector[generation%GENERATIONS_PER_EXIT_CHURN_VECTOR]=uint64(0)
Design note: Max out churn usage for the past generations as it is unknown; update the current and lookahead generations according to the state.earliest_exit_epoch generation.
New Functions
Epoch Processing
defprocess_historical_exit_churn_vector(state:BeaconState)->None:current_epoch=get_current_epoch(state)next_epoch=current_epoch+1current_epoch_generation=current_epoch//EPOCHS_PER_CHURN_GENERATIONearliest_exit_epoch_generation=state.earliest_exit_epoch//EPOCHS_PER_CHURN_GENERATIONnext_epoch_generation=next_epoch//EPOCHS_PER_CHURN_GENERATION# Update the vector if switching over to the next generation.
ifnext_epoch_generation>current_epoch_generation:lookahead_generation=next_epoch_generation+GENERATIONS_PER_EXIT_CHURN_LOOKAHEADlookahead_generation_index=lookahead_generation%GENERATIONS_PER_HISTORICAL_CHURN_VECTORifearliest_exit_epoch_generation<lookahead_generation:# If earliest_exit_epoch is earlier than the lookahead generation,
# reset its churn usage to 0,
state.exit_churn_vector[lookahead_generation_index]=uint64(0)else:# otherwise, mark the lookahead generation churn as fully consumed.
state.historical_exit_churn_vector[lookahead_generation_index]=UINT64_MAX
Design note: This function resets the lookahead generation churn upon switching to the next generation. If state.earliest_exit_epoch falls into the generation earlier than the lookahead, the lookahead generation churn usage is reset. Otherwise, it is marked as fully used.
get_exit_churn_limit
defget_exit_churn_limit(state:BeaconState)->Gwei:current_epoch=get_current_epoch(state)earliest_exit_epoch=max(state.earliest_exit_epoch,compute_activation_exit_epoch(get_current_epoch(state)))per_epoch_exit_churn=get_activation_exit_churn_limit(state)# If the earliest_exit_epoch generation is beyond the lookahead,
# don't use the slack.
current_generation=current_epoch//EPOCHS_PER_CHURN_GENERATIONlookahead_generation=current_generation+GENERATIONS_PER_EXIT_CHURN_LOOKAHEADearliest_exit_epoch_generation=earliest_exit_epoch//EPOCHS_PER_CHURN_GENERATIONifearliest_exit_epoch_generation>lookahead_generation:returnper_epoch_exit_churn# Compute churn leftover from past generations.
past_generations=GENERATIONS_PER_EXIT_CHURN_VECTOR-GENERATIONS_PER_EXIT_CHURN_LOOKAHEADifcurrent_generation>past_generations:oldest_generation=current_generation-past_generationselse:oldest_generation=uint64(0)per_generation_exit_churn=per_epoch_exit_churn*EPOCHS_PER_CHURN_GENERATIONtotal_unused_exit_churn=0forgenerationinrange(oldest_generation,current_generation):generation_index=generation%GENERATIONS_PER_EXIT_CHURN_VECTORchurn_usage=state.exit_churn_vector[generation_index]ifchurn_usage<per_generation_exit_churn:total_unused_exit_churn+=per_generation_exit_churn-churn_usage# Limit churn slack per epoch.
returnmax(total_unused_exit_churn+per_epoch_exit_churn,per_epoch_exit_churn*EXIT_CHURN_SLACK_MULTIPLIER)
Design note: Given churn usage for past generations and current epoch churn size estimates the churn leftover from past generations. Caps the returned churn at per_epoch_exit_churn * EXIT_CHURN_SLACK_MULTIPLIER.
Modified Functions
Replace the existing compute_exit_epoch_and_update_churn function with this simplified MINSLACK-inspired version:
defcompute_exit_epoch_and_update_churn(state:BeaconState,exit_balance:Gwei)->Epoch:earliest_exit_epoch=max(state.earliest_exit_epoch,compute_activation_exit_epoch(get_current_epoch(state)))# Modified in [EIP-XXXX]
per_epoch_churn=get_exit_churn_limit(state)# New epoch for exits.
ifstate.earliest_exit_epoch<earliest_exit_epoch:exit_balance_to_consume=per_epoch_churnelse:exit_balance_to_consume=state.exit_balance_to_consume# Exit doesn't fit in the current earliest epoch.
ifexit_balance>exit_balance_to_consume:balance_to_process=exit_balance-exit_balance_to_consumeadditional_epochs=(balance_to_process-1)//per_epoch_churn+1earliest_exit_epoch+=additional_epochsexit_balance_to_consume+=additional_epochs*per_epoch_churn# Consume the balance and update state variables.
state.exit_balance_to_consume=exit_balance_to_consume-exit_balancestate.earliest_exit_epoch=earliest_exit_epoch# New in [EIP-XXXX]
current_epoch_generation=current_epoch//EPOCHS_PER_CHURN_GENERATIONexit_epoch_generation=state.earliest_exit_epoch//EPOCHS_PER_CHURN_GENERATIONcurrent_generation_index=current_epoch_generation%GENERATIONS_PER_HISTORICAL_CHURN_VECTOR# Record churn usage only if exit falls into the lookahead period
# and the exit epoch generation churn isn't fully used.
lookahead_generation=current_epoch_generation+GENERATIONS_PER_EXIT_CHURN_LOOKAHEADexit_epoch_generation_index=exit_epoch_generation%GENERATIONS_PER_EXIT_CHURN_VECTORif(exit_epoch_generation<=lookahead_generationandstate.historical_exit_churn_vector[exit_epoch_generation_index]<UINT64_MAX):state.exit_churn_vector[exit_epoch_generation_index]+=exit_balancereturnstate.earliest_exit_epoch
Rationale
As we described earlier, by computing unused churn from the previous 14 generations, the churn limit dynamically responds to actual validator behavior. This mechanism:
Reduces validator waiting times during periods of congestion.
Ensures security by restricting maximum churn limit increases.
Simplifies implementation compared to more complex dynamic mechanisms.
A generation length of 256 epochs (~27 hours) and a history of 14 generations (~16 days) balances responsiveness and stability, enabling Ethereum to adapt smoothly to sustained changes in validator exit behavior.
Backwards Compatibility
This EIP requires a hard fork.
Security Considerations
Validator exit constraints remain crucial for Ethereum’s accountable safety. This proposal maintains the core safety guarantee by strictly limiting the increase of the churn limit.
The maximum churn limit is capped at eight times the current fixed churn, ensuring safety assumptions hold even in worst-case scenarios.
Copyright
Copyright and related rights waived via CC0.
Citation
Please cite this document as:
Mikhail Kalinin (@mkalinin), Mike Neuder (@michaelneuder), Mallesh Pai (@Mmp610), "EIP-7922: Dynamic exit queue rate limit [DRAFT]," Ethereum Improvement Proposals, no. 7922, March 2025. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7922.