Part 3: Annotated Specification
Helper Functions
Beacon State Mutators
increase_balance
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() |
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() |
initiate_validator_exit
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:
return
# 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.
slash_validator
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 toMIN_VALIDATOR_WITHDRAWABILITY_DELAY
epochs after it exits. When a validator is slashed, a much longer period of lock-up applies, namelyEPOCHS_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.