Skip to content

EIP-7716 -- The Beacon Chain

Note: This document is a work-in-progress for researchers and implementers.

Introduction

This feature introduces anti-correlation attestation penalties for validators. The timely target penalty of a validator that produced no timely attestation at all — missing both the timely source and timely target flags, the signature of an infrastructure failure rather than a view disagreement — is scaled by a per-slot penalty_factor. The factor is proportional to the excess of that slot's offline balance over an exponential moving average of offline balance, and is 1 whenever the offline balance does not exceed the moving average, so uncorrelated failures pay exactly the pre-fork penalty. PENALTY_SLOPE is set to 3 * (MAX_PENALTY_FACTOR - 1) so that the factor saturates at its cap when one third of stake is newly offline — the point at which the inactivity leak takes over as the protocol's correlation pricing mechanism.

Note: This specification is built upon Heze.

Presets

Penalty factor

Name Value Description
MAX_PENALTY_FACTOR Uint64(2**8) (= 256) Maximum penalty factor, the single severity parameter
PENALTY_SLOPE Uint64(3 * (MAX_PENALTY_FACTOR - 1)) Slope of the penalty factor in excess offline balance
OFFLINE_BALANCE_SMOOTHING_FACTOR Uint64(2**16) (= 65,536) Smoothing divisor of the offline balance moving average

Containers

Modified containers

BeaconState

Note: The BeaconState container is modified to track smoothed_offline_balance, an exponential moving average of the per-slot offline balance.

class BeaconState(ProgressiveContainer):
    ACTIVE_FIELDS = active_fields(width=47)

    genesis_time: Uint64
    genesis_validators_root: Root
    slot: Slot
    fork: Fork
    latest_block_header: BeaconBlockHeader
    block_roots: BlockRoots
    state_roots: StateRoots
    historical_roots: HistoricalRoots
    eth1_data: Eth1Data
    eth1_data_votes: Eth1DataVotes
    eth1_deposit_index: Uint64
    validators: Validators
    balances: Balances
    randao_mixes: RandaoMixes
    slashings: Slashings
    previous_epoch_participation: EpochParticipation
    current_epoch_participation: EpochParticipation
    justification_bits: JustificationBits
    previous_justified_checkpoint: Checkpoint
    current_justified_checkpoint: Checkpoint
    finalized_checkpoint: Checkpoint
    inactivity_scores: InactivityScores
    current_sync_committee: SyncCommittee
    next_sync_committee: SyncCommittee
    latest_block_hash: Hash32
    next_withdrawal_index: WithdrawalIndex
    next_withdrawal_validator_index: ValidatorIndex
    historical_summaries: HistoricalSummaries
    deposit_requests_start_index: Uint64
    deposit_balance_to_consume: Gwei
    exit_balance_to_consume: Gwei
    earliest_exit_epoch: Epoch
    consolidation_balance_to_consume: Gwei
    earliest_consolidation_epoch: Epoch
    pending_deposits: PendingDeposits
    pending_partial_withdrawals: PendingPartialWithdrawals
    pending_consolidations: PendingConsolidations
    proposer_lookahead: ProposerLookahead
    builders: Builders
    next_withdrawal_builder_index: BuilderIndex
    execution_payload_availability: ExecutionPayloadAvailability
    builder_pending_payments: BuilderPendingPayments
    builder_pending_withdrawals: BuilderPendingWithdrawals
    latest_execution_payload_bid: ExecutionPayloadBid
    payload_expected_withdrawals: Withdrawals
    ptc_window: PayloadTimelinessCommitteeWindow
    # [New in EIP7716]
    smoothed_offline_balance: Gwei

Helper functions

Predicates

New is_offline_in_previous_epoch

def is_offline_in_previous_epoch(state: BeaconState, index: ValidatorIndex) -> bool:
    """
    Check if ``index`` produced no timely attestation at all in the previous epoch.
    Validators that attested with a correct and timely source but an incorrect
    target demonstrated liveness and are not considered offline.
    """
    return (
        not state.validators[index].slashed
        and not has_flag(state.previous_epoch_participation[index], TIMELY_SOURCE_FLAG_INDEX)
        and not has_flag(state.previous_epoch_participation[index], TIMELY_TARGET_FLAG_INDEX)
    )

Beacon state accessors

New get_slot_offline_balance

def get_slot_offline_balance(state: BeaconState, slot: Slot) -> Gwei:
    """
    Return the sum of effective balances of offline validators whose committees
    were assigned to ``slot``. ``slot`` must be within the previous epoch.
    """
    epoch = compute_epoch_at_slot(slot)
    offline_balance = Gwei(0)
    for committee_index in range(get_committee_count_per_slot(state, epoch)):
        committee = get_beacon_committee(state, slot, CommitteeIndex(committee_index))
        for index in committee:
            if is_offline_in_previous_epoch(state, index):
                offline_balance += state.validators[index].effective_balance
    return offline_balance

New get_slot_reference_balance

1
2
3
4
5
def get_slot_reference_balance(state: BeaconState) -> Gwei:
    """
    Return the average active balance per slot, the normalizer of the penalty factor.
    """
    return get_total_active_balance(state) // Uint64(SLOTS_PER_EPOCH)

New get_updated_smoothed_offline_balance

def get_updated_smoothed_offline_balance(smoothed_balance: Gwei, offline_balance: Gwei) -> Gwei:
    """
    Return the exponential moving average updated with one slot's offline balance.
    """
    if offline_balance > smoothed_balance:
        delta = offline_balance - smoothed_balance
        smoothed_delta = delta // OFFLINE_BALANCE_SMOOTHING_FACTOR
        return smoothed_balance + smoothed_delta
    else:
        delta = smoothed_balance - offline_balance
        smoothed_delta = delta // OFFLINE_BALANCE_SMOOTHING_FACTOR
        return smoothed_balance - smoothed_delta

New get_slot_penalty_factors

def get_slot_penalty_factors(state: BeaconState) -> Sequence[Uint64]:
    """
    Return the penalty factor for each slot of the previous epoch.
    Does not mutate ``state``; the moving average is persisted by
    ``process_smoothed_offline_balance``.
    """
    factors = []
    smoothed_balance = state.smoothed_offline_balance
    reference_balance = get_slot_reference_balance(state)
    start_slot = compute_start_slot_at_epoch(get_previous_epoch(state))
    for slot_offset in range(SLOTS_PER_EPOCH):
        slot = start_slot + slot_offset
        offline_balance = get_slot_offline_balance(state, slot)
        excess = offline_balance - min(offline_balance, smoothed_balance)
        excess_factor = PENALTY_SLOPE * excess // reference_balance
        penalty_factor = min(Uint64(1) + excess_factor, MAX_PENALTY_FACTOR)
        factors.append(penalty_factor)
        smoothed_balance = get_updated_smoothed_offline_balance(smoothed_balance, offline_balance)
    return factors

New get_validator_slot_offsets

def get_validator_slot_offsets(state: BeaconState) -> Sequence[Uint64]:
    """
    Return the slot offset within the previous epoch of each validator's
    committee assignment.
    """
    slot_offsets = [Uint64(0)] * len(state.validators)
    previous_epoch = get_previous_epoch(state)
    start_slot = compute_start_slot_at_epoch(previous_epoch)
    for slot_offset in range(SLOTS_PER_EPOCH):
        slot = start_slot + slot_offset
        for committee_index in range(get_committee_count_per_slot(state, previous_epoch)):
            committee = get_beacon_committee(state, slot, CommitteeIndex(committee_index))
            for index in committee:
                slot_offsets[index] = Uint64(slot_offset)
    return slot_offsets

Beacon chain state transition function

Epoch processing

Modified process_epoch

Note: The function process_epoch is modified to call the new helper process_smoothed_offline_balance after process_rewards_and_penalties, so that the penalty factors applied for the previous epoch are computed against the moving average as of the start of that epoch.

def process_epoch(state: BeaconState) -> None:
    process_justification_and_finalization(state)
    process_inactivity_updates(state)
    process_rewards_and_penalties(state)
    # [New in EIP7716]
    process_smoothed_offline_balance(state)
    process_registry_updates(state)
    process_slashings(state)
    process_eth1_data_reset(state)
    process_pending_deposits(state)
    process_pending_consolidations(state)
    process_builder_pending_payments(state)
    process_effective_balance_updates(state)
    process_slashings_reset(state)
    process_randao_mixes_reset(state)
    process_historical_summaries_update(state)
    process_participation_flag_updates(state)
    process_sync_committee_updates(state)
    process_proposer_lookahead(state)
    process_ptc_window(state)

Rewards and penalties

Modified get_flag_index_deltas

Note: The function get_flag_index_deltas is modified to scale the timely target penalty of offline validators by the penalty factor of the slot their committee was assigned to. The timely source penalty is unchanged, timely head misses remain unpenalized, and validators that missed the target but attested with a timely source pay the unscaled penalty.

def get_flag_index_deltas(
    state: BeaconState, flag_index: int
) -> tuple[Sequence[Gwei], Sequence[Gwei]]:
    """
    Return the deltas for a given ``flag_index`` by scanning through the participation flags.
    """
    rewards = [Gwei(0)] * len(state.validators)
    penalties = [Gwei(0)] * len(state.validators)
    previous_epoch = get_previous_epoch(state)
    unslashed_participating_indices = get_unslashed_participating_indices(
        state, flag_index, previous_epoch
    )
    weight = PARTICIPATION_FLAG_WEIGHTS[flag_index]
    unslashed_participating_balance = get_total_balance(state, unslashed_participating_indices)
    unslashed_participating_increments = (
        unslashed_participating_balance // EFFECTIVE_BALANCE_INCREMENT
    )
    active_increments = get_total_active_balance(state) // EFFECTIVE_BALANCE_INCREMENT

    # [New in EIP7716]
    if flag_index == TIMELY_TARGET_FLAG_INDEX:
        penalty_factors = get_slot_penalty_factors(state)
        slot_offsets = get_validator_slot_offsets(state)

    for index in get_eligible_validator_indices(state):
        base_reward = get_base_reward(state, index)
        if index in unslashed_participating_indices:
            if not is_in_inactivity_leak(state):
                reward_numerator = base_reward * weight * unslashed_participating_increments
                rewards[index] += reward_numerator // (active_increments * WEIGHT_DENOMINATOR)
        # [New in EIP7716]
        elif flag_index == TIMELY_TARGET_FLAG_INDEX:
            penalty_factor = Uint64(1)
            if is_offline_in_previous_epoch(state, index):
                penalty_factor = penalty_factors[slot_offsets[index]]
            penalties[index] += penalty_factor * base_reward * weight // WEIGHT_DENOMINATOR
        elif flag_index != TIMELY_HEAD_FLAG_INDEX:
            penalties[index] += base_reward * weight // WEIGHT_DENOMINATOR
    return rewards, penalties

New process_smoothed_offline_balance

1
2
3
4
5
6
7
def process_smoothed_offline_balance(state: BeaconState) -> None:
    start_slot = compute_start_slot_at_epoch(get_previous_epoch(state))
    for slot_offset in range(SLOTS_PER_EPOCH):
        slot = start_slot + slot_offset
        state.smoothed_offline_balance = get_updated_smoothed_offline_balance(
            state.smoothed_offline_balance, get_slot_offline_balance(state, slot)
        )