Skip to content

Add EIP: Dynamic exit queue rate limit #9552

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Apr 10, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 234 additions & 0 deletions EIPS/eip-7922.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
---
eip: 7922
title: Dynamic exit queue rate limit
description: Update the validator exit process by dynamically adjusting the churn limit based on historical validator exits.
author: Mikhail Kalinin (@mkalinin), Mike Neuder (@michaelneuder), Mallesh Pai (@Mmp610)
discussions-to: https://ethereum-magicians.org/t/dynamic-exit-churn-limit-using-historical-unused-capacity/23280
status: Draft
type: Standards Track
category: Core
created: 2025-03-24
---

## Abstract

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:

1. at most 16 validators exit in the next one epoch, and
2. at most 32 validators exit in the next two epochs, and
3. at most 48 validators exit in the next three epochs, and
...
5. 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.

1. `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`.
2. `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.

1. `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.
2. `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:

```python
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:

```python
# 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_GENERATION
current_epoch_generation = get_current_epoch(state) // EPOCHS_PER_CHURN_GENERATION
lookahead_generation = current_epoch_generation + GENERATIONS_PER_EXIT_CHURN_LOOKAHEAD
for generation in range(current_epoch_generation, lookahead_generation):
if earliest_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

```python
def process_historical_exit_churn_vector(state: BeaconState) -> None:
current_epoch = get_current_epoch(state)
next_epoch = current_epoch + 1

current_epoch_generation = current_epoch // EPOCHS_PER_CHURN_GENERATION
earliest_exit_epoch_generation = state.earliest_exit_epoch // EPOCHS_PER_CHURN_GENERATION

next_epoch_generation = next_epoch // EPOCHS_PER_CHURN_GENERATION

# Update the vector if switching over to the next generation.
if next_epoch_generation > current_epoch_generation:
lookahead_generation = next_epoch_generation + GENERATIONS_PER_EXIT_CHURN_LOOKAHEAD
lookahead_generation_index = lookahead_generation % GENERATIONS_PER_HISTORICAL_CHURN_VECTOR
if earliest_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`

```python
def get_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_GENERATION
lookahead_generation = current_generation + GENERATIONS_PER_EXIT_CHURN_LOOKAHEAD
earliest_exit_epoch_generation = earliest_exit_epoch // EPOCHS_PER_CHURN_GENERATION
if earliest_exit_epoch_generation > lookahead_generation:
return per_epoch_exit_churn

# Compute churn leftover from past generations.
past_generations = GENERATIONS_PER_EXIT_CHURN_VECTOR - GENERATIONS_PER_EXIT_CHURN_LOOKAHEAD
if current_generation > past_generations:
oldest_generation = current_generation - past_generations
else:
oldest_generation = uint64(0)

per_generation_exit_churn = per_epoch_exit_churn * EPOCHS_PER_CHURN_GENERATION
total_unused_exit_churn = 0
for generation in range(oldest_generation, current_generation):
generation_index = generation % GENERATIONS_PER_EXIT_CHURN_VECTOR
churn_usage = state.exit_churn_vector[generation_index]
if churn_usage < per_generation_exit_churn:
total_unused_exit_churn += per_generation_exit_churn - churn_usage

# Limit churn slack per epoch.
return max(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:

```python
def compute_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.
if state.earliest_exit_epoch < earliest_exit_epoch:
exit_balance_to_consume = per_epoch_churn
else:
exit_balance_to_consume = state.exit_balance_to_consume

# Exit doesn't fit in the current earliest epoch.
if exit_balance > exit_balance_to_consume:
balance_to_process = exit_balance - exit_balance_to_consume
additional_epochs = (balance_to_process - 1) // per_epoch_churn + 1
earliest_exit_epoch += additional_epochs
exit_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_balance
state.earliest_exit_epoch = earliest_exit_epoch

# New in [EIP-XXXX]
current_epoch_generation = current_epoch // EPOCHS_PER_CHURN_GENERATION
exit_epoch_generation = state.earliest_exit_epoch // EPOCHS_PER_CHURN_GENERATION
current_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_LOOKAHEAD
exit_epoch_generation_index = exit_epoch_generation % GENERATIONS_PER_EXIT_CHURN_VECTOR
if (exit_epoch_generation <= lookahead_generation
and state.historical_exit_churn_vector[exit_epoch_generation_index] < UINT64_MAX):
state.exit_churn_vector[exit_epoch_generation_index] += exit_balance

return state.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.