Part 3: Annotated Specification

Helper Functions

Beacon State Mutators


def increase_balance(state: BeaconState, index: ValidatorIndex, delta: Gwei) -> None:
    Increase the validator balance at index ``index`` by ``delta``.
    state.balances[index] += delta

After creating a validator with its deposit balance, this and decrease_balance() are the only places in the spec where validator balances are ever modified.

We need two separate functions to change validator balances, one to increase them and one to decrease them, since we are using only unsigned integers.

Fun fact: A typo around this led to Teku's one and only consensus failure at the initial client interop event. Unsigned integers induce bugs!

Used by slash_validator(), process_rewards_and_penalties(), process_attestation(), process_deposit(), process_sync_aggregate()
See also decrease_balance()


def decrease_balance(state: BeaconState, index: ValidatorIndex, delta: Gwei) -> None:
    Decrease the validator balance at index ``index`` by ``delta``, with underflow protection.
    state.balances[index] = 0 if delta > state.balances[index] else state.balances[index] - delta

The counterpart to increase_balance(). This has a little extra work to do to check for unsigned int underflow since balances may not go negative.

Used by slash_validator(), process_rewards_and_penalties(), process_slashings(), process_sync_aggregate()
See also increase_balance()


def initiate_validator_exit(state: BeaconState, index: ValidatorIndex) -> None:
    Initiate the exit of the validator with index ``index``.
    # Return if validator already initiated exit
    validator = state.validators[index]
    if validator.exit_epoch != FAR_FUTURE_EPOCH:

    # Compute exit queue epoch
    exit_epochs = [v.exit_epoch for v in state.validators if v.exit_epoch != FAR_FUTURE_EPOCH]
    exit_queue_epoch = max(exit_epochs + [compute_activation_exit_epoch(get_current_epoch(state))])
    exit_queue_churn = len([v for v in state.validators if v.exit_epoch == exit_queue_epoch])
    if exit_queue_churn >= get_validator_churn_limit(state):
        exit_queue_epoch += Epoch(1)

    # Set validator exit epoch and withdrawable epoch
    validator.exit_epoch = exit_queue_epoch
    validator.withdrawable_epoch = Epoch(validator.exit_epoch + MIN_VALIDATOR_WITHDRAWABILITY_DELAY)

Exits may be initiated voluntarily, as a result of being slashed, or by dropping to the EJECTION_BALANCE threshold.

In all cases, a dynamic "churn limit" caps the number of validators that may exit per epoch. This is calculated by get_validator_churn_limit(). The mechanism for enforcing this is the exit queue: the validator's exit_epoch is set such that it is at the end of the queue.

The exit queue is not maintained as a separate data structure, but is continually re-calculated from the exit epochs of all validators and allowing for a fixed number to exit per epoch. I expect there are some optimisations to be had around this in actual implementations.

An exiting validator is expected to continue with its proposing and attesting duties until its exit_epoch has passed, and will continue to receive rewards and penalties accordingly.

In addition, an exited validator remains eligible to be slashed until its withdrawable_epoch, which is set to MIN_VALIDATOR_WITHDRAWABILITY_DELAY epochs after its exit_epoch. This is to allow some extra time for any slashable offences by the validator to be detected and reported.

Used by slash_validator(), process_registry_updates(), process_voluntary_exit()
Uses compute_activation_exit_epoch(), get_validator_churn_limit()


def slash_validator(state: BeaconState,
                    slashed_index: ValidatorIndex,
                    whistleblower_index: ValidatorIndex=None) -> None:
    Slash the validator with index ``slashed_index``.
    epoch = get_current_epoch(state)
    initiate_validator_exit(state, slashed_index)
    validator = state.validators[slashed_index]
    validator.slashed = True
    validator.withdrawable_epoch = max(validator.withdrawable_epoch, Epoch(epoch + EPOCHS_PER_SLASHINGS_VECTOR))
    state.slashings[epoch % EPOCHS_PER_SLASHINGS_VECTOR] += validator.effective_balance
    slashing_penalty = validator.effective_balance // MIN_SLASHING_PENALTY_QUOTIENT_BELLATRIX
    decrease_balance(state, slashed_index, slashing_penalty)

    # Apply proposer and whistleblower rewards
    proposer_index = get_beacon_proposer_index(state)
    if whistleblower_index is None:
        whistleblower_index = proposer_index
    whistleblower_reward = Gwei(validator.effective_balance // WHISTLEBLOWER_REWARD_QUOTIENT)
    proposer_reward = Gwei(whistleblower_reward * PROPOSER_WEIGHT // WEIGHT_DENOMINATOR)
    increase_balance(state, proposer_index, proposer_reward)
    increase_balance(state, whistleblower_index, Gwei(whistleblower_reward - proposer_reward))

Both proposer slashings and attester slashings end up here when a report of a slashable offence has been verified during block processing.

When a validator is slashed, several things happen immediately:

  • The validator is processed for exit via initiate_validator_exit(), so it joins the exit queue.
  • The validator is marked as slashed. This information is used when calculating rewards and penalties: while being exited, whatever it does, a slashed validator receives penalties as if it had failed to propose or attest, including the inactivity leak if applicable.
  • Normally, as part of the exit process, the withdrawable_epoch for a validator (the point at which a validator's stake is in principle unlocked) is set to MIN_VALIDATOR_WITHDRAWABILITY_DELAY epochs after it exits. When a validator is slashed, a much longer period of lock-up applies, namely EPOCHS_PER_SLASHINGS_VECTOR. This is to allow a further, potentially much greater, slashing penalty to be applied later once the chain knows how many validators have been slashed together around the same time. The postponement of the withdrawable epoch is twice as long as required to apply the extra penalty, which is applied half-way through the period. This simply means that slashed validators continue to accrue attestation penalties for some 18 days longer than necessary. Treating slashed validators fairly is not a big priority for the protocol.
  • The effective balance of the validator is added to the accumulated effective balances of validators slashed this epoch, and stored in the circular list, state.slashings. This will later be used by the slashing penalty calculation mentioned in the previous point.
  • An initial "slap on the wrist" slashing penalty of the validator's effective balance (in Gwei) divided by the MIN_SLASHING_PENALTY_QUOTIENT_BELLATRIX is applied. For a validator with a full Effective Balance of 32 ETH, this initial penalty is 1 ETH.
  • The block proposer that included the slashing proof receives a reward.

In short, a slashed validator receives an initial minor penalty, can expect to receive a further penalty later, and is marked for exit.

Note that the whistleblower_index defaults to None in the parameter list. This is never used in Phase 0, with the result that the proposer that included the slashing gets the entire whistleblower reward; there is no separate whistleblower reward for the finder of proposer or attester slashings. One reason is simply that reports are too easy to steal: if I report a slashable event to a block proposer, there is nothing to prevent that proposer claiming the report as its own. We could introduce some fancy ZK protocol to make this trustless, but this is what we're going with for now. Later developments, such as the proof-of-custody game, may reward whistleblowers directly.

Used by process_proposer_slashing(), process_attester_slashing()
Uses initiate_validator_exit(), get_beacon_proposer_index(), decrease_balance(), increase_balance()

Created by Ben Edgington. Licensed under CC BY-SA 4.0. Published 2023-09-29 14:16 UTC. Commit ebfcf50.