Skip to content

EIP-8198 -- The Beacon Chain

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

Introduction

EIP-8198 ("Quick Slots") makes the slot duration schedulable, with a first reduction from 12 to 10 seconds intended at the fork epoch. The duration schedule records the historical slot lengths. Intra-slot deadlines are configured separately in basis points of the slot duration at EIP8198_FORK_EPOCH; later forks may change the duties and their deadlines independently. The remaining duration-dependent parameters are rescaled by the ratio r = get_slot_duration_ms(epoch) / get_slot_duration_ms(GENESIS_EPOCH) to keep their wall-clock behavior constant: issuance and churn are per-epoch rates and scale by r, while the inactivity penalty scales by r**2 so that the cumulative leak over a fixed wall-clock duration is unchanged. Each formula applies the ratio inline rather than pre-computing rounded constants. Epoch- and slot-denominated quantities — withdrawability and slashing windows, sync committee periods, per-payload and per-epoch processing limits — keep their counts, so their wall-clock spans scale with the slot duration.

Note: This specification is built upon Heze.

Configuration

Slot duration schedule

The standalone SLOT_DURATION_MS configuration variable is deprecated in favor of SLOT_DURATION_SCHEDULE.

[New in EIP8198] This schedule MUST list slot durations in strictly increasing epoch order, beginning at GENESIS_EPOCH with the historical slot duration. The genesis duration is the baseline for issuance, penalty, and churn calculations. Entries contain only an activation epoch and a slot duration; deadline changes do not require an entry.

The slot duration MUST be a positive multiple of 1000, so that every slot boundary has an integer-second timestamp.

The intended first reduction is to 10 seconds on mainnet. Its epoch and accompanying blob parameters are not yet scheduled. Blob targets and limits must be chosen jointly with the execution layer; integer rounding does not in general preserve throughput per unit time exactly.

Gas targets should be coordinated ahead of the upgrade, or as part of the upgrade's overall capacity increase, using the advisory GAS_LIMIT_SCHEDULE and proposer preferences. The usual gas-limit adjustment rule applies at the transition, so reaching a lower target requires advance coordination.

Epoch Slot Duration Ms Date
0 12000 December 1, 2020, 12:00:23pm UTC

Helpers

Misc

New get_slot_duration_ms

1
2
3
4
5
6
7
8
def get_slot_duration_ms(epoch: Epoch) -> Uint64:
    """
    Return the slot duration in effect at ``epoch``.
    """
    for entry in reversed(SLOT_DURATION_SCHEDULE):
        if epoch >= entry["EPOCH"]:
            break
    return entry["SLOT_DURATION_MS"]

Modified compute_time_at_slot_ms

def compute_time_at_slot_ms(genesis_time_ms: Uint64, slot: Slot) -> Uint64:
    """
    Return the Unix time in milliseconds at the start of ``slot``.
    """
    # [Modified in EIP8198]
    end_slot = slot
    time_ms = genesis_time_ms
    for entry in reversed(SLOT_DURATION_SCHEDULE):
        entry_slot = compute_start_slot_at_epoch(entry["EPOCH"])
        if entry_slot < end_slot:
            slots = end_slot - entry_slot
            time_ms += slots * entry["SLOT_DURATION_MS"]
            end_slot = entry_slot
    return time_ms

Modified compute_slot_at_time_ms

def compute_slot_at_time_ms(genesis_time_ms: Uint64, time_ms: Uint64) -> Slot:
    """
    Return the slot at Unix time ``time_ms``.
    """
    # [Modified in EIP8198]
    for entry in reversed(SLOT_DURATION_SCHEDULE):
        entry_slot = compute_start_slot_at_epoch(entry["EPOCH"])
        entry_time_ms = compute_time_at_slot_ms(genesis_time_ms, entry_slot)
        if time_ms >= entry_time_ms:
            break
    time_diff_ms = time_ms - entry_time_ms
    slots = time_diff_ms // entry["SLOT_DURATION_MS"]
    return entry_slot + slots

Beacon state accessors

Modified get_base_reward_per_increment

def get_base_reward_per_increment(
    state: BeaconState,
    # [New in EIP8198]
    epoch: Epoch,
) -> Gwei:
    """
    Return the base reward per increment, priced at the slot duration in
    effect at ``epoch``.
    """
    # [Modified in EIP8198]
    return Gwei(
        EFFECTIVE_BALANCE_INCREMENT
        * BASE_REWARD_FACTOR
        * get_slot_duration_ms(epoch)
        // get_slot_duration_ms(GENESIS_EPOCH)
        // integer_squareroot(get_total_active_balance(state))
    )

Modified get_base_reward

def get_base_reward(
    state: BeaconState,
    index: ValidatorIndex,
    # [New in EIP8198]
    epoch: Epoch,
) -> Gwei:
    """
    Return the base reward for ``index``, priced at the slot duration in
    effect at ``epoch``.
    """
    increments = state.validators[index].effective_balance // EFFECTIVE_BALANCE_INCREMENT
    # [Modified in EIP8198]
    return increments * get_base_reward_per_increment(state, epoch)

Modified get_flag_index_deltas

Note: Participation deltas pay for the previous epoch, so they are priced at the slot duration in effect at that epoch, which differs from the current one in the first epoch after a slot duration change.

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
    for index in get_eligible_validator_indices(state):
        # [Modified in EIP8198]
        base_reward = get_base_reward(state, index, previous_epoch)
        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)
        elif flag_index != TIMELY_HEAD_FLAG_INDEX:
            penalties[index] += base_reward * weight // WEIGHT_DENOMINATOR
    return rewards, penalties

Modified get_inactivity_penalty_deltas

Note: The inactivity penalty scales with the square of the epoch duration, so the cumulative penalty over a fixed wall-clock leak duration is unchanged. The penalty pays for the previous epoch and is priced at its slot duration.

def get_inactivity_penalty_deltas(state: BeaconState) -> Tuple[Sequence[Gwei], Sequence[Gwei]]:
    """
    Return the inactivity penalty deltas by considering timely target participation flags and inactivity scores.
    """
    rewards = [Gwei(0) for _ in range(len(state.validators))]
    penalties = [Gwei(0) for _ in range(len(state.validators))]
    previous_epoch = get_previous_epoch(state)
    matching_target_indices = get_unslashed_participating_indices(
        state, TIMELY_TARGET_FLAG_INDEX, previous_epoch
    )
    for index in get_eligible_validator_indices(state):
        if index not in matching_target_indices:
            penalty_numerator = (
                state.validators[index].effective_balance * state.inactivity_scores[index]
            )
            # [Modified in EIP8198]
            penalty_denominator = (
                INACTIVITY_SCORE_BIAS
                * INACTIVITY_PENALTY_QUOTIENT_BELLATRIX
                * get_slot_duration_ms(GENESIS_EPOCH) ** 2
                // get_slot_duration_ms(previous_epoch) ** 2
            )
            penalties[index] += penalty_numerator // penalty_denominator
    return rewards, penalties

Modified get_activation_churn_limit

Note: The cap is applied before scaling, so the maximum activation rate is scaled too, and the increment rounding is applied once, after scaling.

def get_activation_churn_limit(state: BeaconState) -> Gwei:
    """
    Per-epoch churn limit for activations, rounded to
    ``EFFECTIVE_BALANCE_INCREMENT``.
    """
    churn = max(
        MIN_PER_EPOCH_CHURN_LIMIT_ELECTRA,
        get_total_active_balance(state) // CHURN_LIMIT_QUOTIENT_GLOAS,
    )
    # [Modified in EIP8198]
    churn = min(MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT_GLOAS, churn)
    churn = (
        churn
        * get_slot_duration_ms(get_current_epoch(state))
        // get_slot_duration_ms(GENESIS_EPOCH)
    )
    return churn - churn % EFFECTIVE_BALANCE_INCREMENT

Modified get_exit_churn_limit

Note: Exit and consolidation epochs assigned before a duration change keep their assigned epochs and consumed quota.

def get_exit_churn_limit(state: BeaconState) -> Gwei:
    """
    Per-epoch churn limit for exits, rounded to
    ``EFFECTIVE_BALANCE_INCREMENT``.
    """
    churn = max(
        MIN_PER_EPOCH_CHURN_LIMIT_ELECTRA,
        get_total_active_balance(state) // CHURN_LIMIT_QUOTIENT_GLOAS,
    )
    # [Modified in EIP8198]
    churn = (
        churn
        * get_slot_duration_ms(get_current_epoch(state))
        // get_slot_duration_ms(GENESIS_EPOCH)
    )
    return churn - churn % EFFECTIVE_BALANCE_INCREMENT

Modified get_consolidation_churn_limit

def get_consolidation_churn_limit(state: BeaconState) -> Gwei:
    """
    Per-epoch churn limit reserved for consolidations (EIP-7521).
    Derived from total active balance and rounded to
    ``EFFECTIVE_BALANCE_INCREMENT``.
    """
    churn = get_total_active_balance(state) // CONSOLIDATION_CHURN_LIMIT_QUOTIENT
    # [Modified in EIP8198]
    churn = (
        churn
        * get_slot_duration_ms(get_current_epoch(state))
        // get_slot_duration_ms(GENESIS_EPOCH)
    )
    return churn - churn % EFFECTIVE_BALANCE_INCREMENT

Beacon chain state transition function

Block processing

Operations

Attestations
Modified process_attestation

Note: The proposer reward for a newly included attestation is priced at the attestation's target epoch, so around a slot duration change the proposer's share matches the attesters' rewards for the same epoch.

def process_attestation(
    state: BeaconState,
    attestation: Attestation,
    parent_slot: Slot,
) -> None:
    data = attestation.data
    assert data.target.epoch in (get_previous_epoch(state), get_current_epoch(state))
    assert data.target.epoch == compute_epoch_at_slot(data.slot)
    assert data.slot + MIN_ATTESTATION_INCLUSION_DELAY <= state.slot

    assert data.index < 2
    committee_indices = get_committee_indices(attestation.committee_bits)
    committee_offset = 0
    for committee_index in committee_indices:
        assert committee_index < get_committee_count_per_slot(state, data.target.epoch)
        committee = get_beacon_committee(state, data.slot, committee_index)
        committee_attesters = {
            attester_index
            for i, attester_index in enumerate(committee)
            if attestation.aggregation_bits[committee_offset + i]
        }
        assert len(committee_attesters) > 0
        committee_offset += len(committee)

    # Bitfield length matches total number of participants
    assert len(attestation.aggregation_bits) == committee_offset

    # Participation flag indices
    participation_flag_indices = get_attestation_participation_flag_indices(
        state, data, state.slot - data.slot, parent_slot
    )

    # Verify signature
    assert is_valid_indexed_attestation(state, get_indexed_attestation(state, attestation))

    if data.target.epoch == get_current_epoch(state):
        current_epoch_target = True
        epoch_participation = state.current_epoch_participation
        payment = state.builder_pending_payments[SLOTS_PER_EPOCH + data.slot % SLOTS_PER_EPOCH]
    else:
        current_epoch_target = False
        epoch_participation = state.previous_epoch_participation
        payment = state.builder_pending_payments[data.slot % SLOTS_PER_EPOCH]

    proposer_reward_numerator = 0
    for index in get_attesting_indices(state, attestation):
        had_no_participation = epoch_participation[index] == 0b0000_0000
        will_set_new_flag = False

        for flag_index, weight in enumerate(PARTICIPATION_FLAG_WEIGHTS):
            if flag_index in participation_flag_indices and not has_flag(
                epoch_participation[index], flag_index
            ):
                epoch_participation[index] = add_flag(epoch_participation[index], flag_index)
                # [Modified in EIP8198]
                proposer_reward_numerator += (
                    get_base_reward(state, index, data.target.epoch) * weight
                )
                will_set_new_flag = True

        if (
            will_set_new_flag
            and had_no_participation
            and is_attestation_same_slot(state, data)
            and payment.withdrawal.amount > 0
        ):
            payment.weight += state.validators[index].effective_balance

    # Reward proposer
    proposer_reward_denominator = (
        (WEIGHT_DENOMINATOR - PROPOSER_WEIGHT) * WEIGHT_DENOMINATOR // PROPOSER_WEIGHT
    )
    proposer_reward = Gwei(proposer_reward_numerator // proposer_reward_denominator)
    increase_balance(state, get_beacon_proposer_index(state), proposer_reward)

    # Update builder payment weight
    if current_epoch_target:
        state.builder_pending_payments[SLOTS_PER_EPOCH + data.slot % SLOTS_PER_EPOCH] = payment
    else:
        state.builder_pending_payments[data.slot % SLOTS_PER_EPOCH] = payment

Sync aggregate processing

Modified process_sync_aggregate
def process_sync_aggregate(state: BeaconState, sync_aggregate: SyncAggregate) -> None:
    # Verify sync committee aggregate signature signing over the previous slot block root
    committee_pubkeys = state.current_sync_committee.pubkeys
    committee_bits = sync_aggregate.sync_committee_bits
    if get_set_bit_count(committee_bits) == SYNC_COMMITTEE_SIZE:
        # All members participated - use precomputed aggregate key
        participant_pubkeys = [state.current_sync_committee.aggregate_pubkey]
    elif get_set_bit_count(committee_bits) > SYNC_COMMITTEE_SIZE // 2:
        # More than half participated - subtract non-participant keys.
        # First determine nonparticipating members
        non_participant_pubkeys = [
            pubkey for pubkey, bit in zip(committee_pubkeys, committee_bits, strict=True) if not bit
        ]
        # Compute aggregate of non-participants
        non_participant_aggregate = eth_aggregate_pubkeys(non_participant_pubkeys)
        # Subtract non-participants from the full aggregate
        # This is equivalent to: aggregate_pubkey + (-non_participant_aggregate)
        participant_pubkey = bls.add(
            bls.bytes48_to_G1(state.current_sync_committee.aggregate_pubkey),
            bls.neg(bls.bytes48_to_G1(non_participant_aggregate)),
        )
        participant_pubkeys = [BLSPubkey(bls.G1_to_bytes48(participant_pubkey))]
    else:
        # Less than half participated - aggregate participant keys
        participant_pubkeys = [
            pubkey
            for pubkey, bit in zip(
                committee_pubkeys, sync_aggregate.sync_committee_bits, strict=True
            )
            if bit
        ]
    previous_slot = saturating_sub(state.slot, 1)
    domain = get_domain(state, DOMAIN_SYNC_COMMITTEE, compute_epoch_at_slot(previous_slot))
    signing_root = compute_signing_root(get_block_root_at_slot(state, previous_slot), domain)
    # Note: eth_fast_aggregate_verify works with a singleton list containing an aggregated key
    assert eth_fast_aggregate_verify(
        participant_pubkeys, signing_root, sync_aggregate.sync_committee_signature
    )

    # Compute participant and proposer rewards
    total_active_increments = get_total_active_balance(state) // EFFECTIVE_BALANCE_INCREMENT
    # [Modified in EIP8198]
    total_base_rewards = (
        get_base_reward_per_increment(state, get_current_epoch(state)) * total_active_increments
    )
    max_participant_rewards = (
        total_base_rewards * SYNC_REWARD_WEIGHT // WEIGHT_DENOMINATOR // Uint64(SLOTS_PER_EPOCH)
    )
    participant_reward = max_participant_rewards // SYNC_COMMITTEE_SIZE
    proposer_reward = participant_reward * PROPOSER_WEIGHT // (WEIGHT_DENOMINATOR - PROPOSER_WEIGHT)

    # Apply participant and proposer rewards
    all_pubkeys = [v.pubkey for v in state.validators]
    committee_indices = [
        ValidatorIndex(all_pubkeys.index(pubkey)) for pubkey in state.current_sync_committee.pubkeys
    ]
    for participant_index, participation_bit in zip(
        committee_indices, sync_aggregate.sync_committee_bits, strict=True
    ):
        if participation_bit:
            increase_balance(state, participant_index, participant_reward)
            increase_balance(state, get_beacon_proposer_index(state), proposer_reward)
        else:
            decrease_balance(state, participant_index, participant_reward)