Alert Source Discuss
⚠️ Draft Standards Track: Core

EIP-7922: 动态退出队列速率限制

通过基于历史验证器退出情况动态调整流失限制来更新验证器退出流程。

Authors Mikhail Kalinin (@mkalinin), Mike Neuder (@michaelneuder), Mallesh Pai (@Mmp610)
Created 2025-03-24
Discussion Link https://ethereum-magicians.org/t/dynamic-exit-churn-limit-using-historical-unused-capacity/23280

摘要

本 EIP 提议通过基于历史验证器退出情况在每个 256 epoch 周期(“generation”)开始时动态调整流失限制来更新 Ethereum 的验证器退出流失计算。具体来说,每个 generation 中允许的最大流失量将根据过去 16 个 generation 中未使用的流失量进行调整。这种方法在不牺牲网络安全的情况下,减少了高退出需求期间验证器的等待时间。

动机

Ethereum 目前为验证器退出实施了固定的、速率限制的队列,以确保网络的安全性和稳定性。退出队列确保了验证器集最终确定的交易的经济安全性。假设一个恶意验证器可以立即退出集合而没有任何延迟。在这种情况下,他们可能会尝试通过发布一个区块,同时扣留一个冲突的区块来执行双重支付攻击,他们在他们的 stake 退出协议后发布该区块。削减机制无法再追究恶意验证器的责任,并且可能存在两个冲突的已最终确定的交易(如果攻击者拥有总 stake 的 1/3 并且成功地将 2/3 诚实多数分成两半)。

选择 CHURN_LIMIT_QUOTIENT=2^16 是根据粗略的启发式方法,即 10% 的 stake 退出大约需要一个月的时间。对于 1,053,742 个验证器,我们的流失限制为每个 epoch 16 个退出。每天 225 个 epoch $\implies$ 每天 3600 个退出 $\implies$ 每 30 天 108,000 个退出。然后 108,000/1,053,742 $\approx$ 0.10。我们可以将其解释为“已最终确定的交易的经济安全性在一个月内降低不超过 10%”。

理解每个 epoch 16 个退出的安全模型的另一种方式是,它围绕验证器退出编码了以下约束:

  1. 在下一个 epoch 中,最多 16 个验证器退出,并且
  2. 在接下来的两个 epoch 中,最多 32 个验证器退出,并且
  3. 在接下来的三个 epoch 中,最多 48 个验证器退出,并且 …
  4. 在接下来的 n 个 epoch 中,最多 16 $\cdot$ n 个验证器退出。

虽然这些约束很容易理解,但固定的每个 epoch 流失限制可能会导致高于平均退出需求的时期内不必要的长验证器提款延迟,例如在机构清算或市场事件期间。我们认为,我们应该从上述集合中选择一个单一约束,并灵活地实施该约束。

我们用一个例子来说明这一点。对于一百万个验证器,当前协议规定每个 epoch 可以退出 16 个验证器。在两周内,这对应于 50,400 个退出。这直接转化为“不超过 5.04% 的验证器(相当于 stake)可以在两周内退出。”现在想象一下,在过去的 13 天里,没有验证器退出协议,因此,两周的流失限制都没有被使用。如果一个拥有 3% 验证器集(30,000 个验证器)的大型 staking 运营商希望立即提款,他们应该能够这样做 – 这并不违反两周 5.04% 的限制。但是,由于每个 epoch 只能处理 16 个退出,因此他们被迫等待 1875 个 epoch(相当于 8.33 天)。

关键观察:如果我们使协议能够向后看退出历史,我们不再需要向前看的每个 epoch 限制。

例如,假设我们明确选择了以下约束:

建议的弱主观性约束: 两周内不超过 50,400 个退出。

然后,我们只需要确保在每个滚动的两周窗口内遵守该约束,而无需对每个 epoch 的退出设置硬性上限。基于历史验证器退出数据的动态调整的流失限制使 Ethereum 能够灵活地适应退出需求的峰值,同时在每两周窗口内保持相同的安全性。通过跟踪最近几代(周期)未使用的流失容量,我们可以安全地提高流失限制,当网络始终低于容量运行时,从而显着改善验证器退出体验。

规范

由于验证器退出过程很复杂,我们从 Electra 中的堆栈跟踪和端到端过程的口头描述开始。

  1. initiate_validator_exit – 验证器发出退出意图的信号,该信号通过基于 compute_exit_epoch_and_update_churn 的输出来设置 validator.exit_epochvalidator.withdrawable_epoch 来实现。
  2. compute_exit_epoch_and_update_churn – 用于确定验证器的退出 epoch。此函数通过以下方式实现退出队列:
    • get_balance_churn_limit - 通过将总活动余额除以 2**16 来返回每个 epoch 可提取的 ETH 数量。
    • exit_balance_to_consume 设置为当前最远 epoch 中可用的流失量,其中将处理一些退出。
    • 如果 exit_balance > exit_balance_to_consume,那么我们计算退出消耗的额外 epoch 数量以设置最终的 exit_epoch

本 EIP 通过检查过去 14 个 generation 中的退出数量来更改流失限制和退出 epoch 的计算方式。

  1. get_exit_churn_limit – 通过对历史 generation 求和来实现新的流失限制计算。
    • per_epoch_exit_churn 使用 get_activation_exit_churn_limit 设置,就像今天一样,通过将总 stake 除以 2**16 并将结果限制为 256 ETH。使用今天的数字,这将返回每个 epoch 可以流失的 256 ETH。
    • per_generation_exit_churn 通过将每个 epoch 退出流失乘以 256(一个 generation 中的 epoch 数量)来设置。使用今天的数字,这是 256 $\cdot$ 256 = 65536 ETH,每个 generation 都可以流失。
    • total_unused_exit_churn 通过循环遍历过去的 generation 并对未使用的容量量求和,per_generation_exit_churn - churn_usage 来计算。
    • 最终返回的值上限为 per_epoch_exit_churn * 8。使用今天的数字,此最大值为每个 epoch 256 $\cdot$ 8 = 2048 ETH。
  2. compute_exit_epoch_and_update_churn – 被修改为使用 get_exit_churn_limit 并考虑将在处理退出的 generation 中消耗的任何流失量。

定义

  • Generation: 一个由 256 个 epoch 组成的周期。
  • Historical Churn Vector: 一个固定大小的数组 (exit_churn_vector),用于记录过去 16 个 generation 中流失的总 ETH 量。
  • Unused Churn: 一个 generation 中最大可能流失量与实际发生的流失量之间的差值。

预设

名称 评论
EPOCHS_PER_CHURN_GENERATION uint64(2**8) (= 256) ~27 小时
GENERATIONS_PER_EXIT_CHURN_VECTOR uint64(2**4) (= 16) ~18 天
GENERATIONS_PER_EXIT_CHURN_LOOKAHEAD uint(2**1) (= 2)  
EXIT_CHURN_SLACK_MULTIPLIER uint(2**3) (= 8)  

新的状态变量

将以下内容添加到状态:

exit_churn_vector: Vector[uint64, GENERATIONS_PER_EXIT_CHURN_VECTOR]  # GENERATIONS_PER_EXIT_CHURN_VECTOR generation 的每个 generation 的总退出余额

初始化

在本 EIP 激活后,初始化新变量:

# 将每个 generation 的流失标记为完全消耗
state.exit_churn_vector = [UINT64_MAX] * GENERATIONS_PER_EXIT_CHURN_VECTOR

# 更新 lookahead generation
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)

设计说明: 最大化过去 generation 的流失使用量,因为它未知;根据 state.earliest_exit_epoch generation 更新当前和 lookahead generation。

新函数

Epoch 处理

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

    # 如果切换到下一个 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:
            # 如果 earliest_exit_epoch 早于 lookahead generation,
            # 将其流失使用量重置为 0,
            state.exit_churn_vector[lookahead_generation_index] = uint64(0)
        else:
            # 否则,将 lookahead generation 流失标记为完全消耗。
            state.historical_exit_churn_vector[lookahead_generation_index] = UINT64_MAX

设计说明:此函数在切换到下一个 generation 时重置 lookahead generation 流失量。如果 state.earliest_exit_epoch 属于早于 lookahead 的 generation,则 lookahead generation 流失使用量将被重置。否则,它将被标记为已完全使用。

get_exit_churn_limit

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)

    # 如果 earliest_exit_epoch generation 超出 lookahead,
    # 则不要使用 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

    # 计算过去 generation 的剩余流失量。
    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

    # 限制每个 epoch 的流失 slack。
    return max(total_unused_exit_churn + per_epoch_exit_churn,
        per_epoch_exit_churn * EXIT_CHURN_SLACK_MULTIPLIER)

设计说明: 给定过去 generation 的流失使用量和当前 epoch 流失大小,估计过去 generation 的剩余流失量。将返回的流失量上限设置为 per_epoch_exit_churn * EXIT_CHURN_SLACK_MULTIPLIER

修改后的函数

用这个简化的受 MINSLACK 启发的版本替换现有的 compute_exit_epoch_and_update_churn 函数:

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)))
    # 在 [EIP-XXXX] 中修改
    per_epoch_churn = get_exit_churn_limit(state)
    # 退出的新 epoch。
    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

    # 退出不适合当前最早的 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

    # 消耗余额并更新状态变量。
    state.exit_balance_to_consume = exit_balance_to_consume - exit_balance
    state.earliest_exit_epoch = earliest_exit_epoch

    # [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
    # 仅当退出属于 lookahead 期间且该退出 epoch generation 流失未完全使用时,才记录流失使用量。
    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

理由

正如我们之前描述的,通过计算前 14 个 generation 中未使用的流失量,流失限制会动态响应实际验证器行为。这种机制:

  • 减少拥塞期间验证器的等待时间。
  • 通过限制最大流失限制增加来确保安全性。
  • 与更复杂的动态机制相比,简化了实现。

256 个 epoch(约 27 小时)的 generation 长度和 14 个 generation(约 16 天)的历史记录平衡了响应性和稳定性,使 Ethereum 能够平稳地适应验证器退出行为的持续变化。

向后兼容性

此 EIP 需要硬分叉。

安全考虑

  • 验证器退出约束对于 Ethereum 的可问责安全性仍然至关重要。本提案通过严格限制流失限制的增加来维持核心安全保证。
  • 最大流失限制被限制为当前固定流失的八倍,即使在最坏的情况下也能确保安全假设成立。

版权

通过 CC0 放弃版权和相关权利。

Citation

Please cite this document as:

Mikhail Kalinin (@mkalinin), Mike Neuder (@michaelneuder), Mallesh Pai (@Mmp610), "EIP-7922: 动态退出队列速率限制 [DRAFT]," Ethereum Improvement Proposals, no. 7922, March 2025. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7922.