Exploring Eth2: Previous Attesters
By Adrian Sutton
In the beacon chain spec, the chain is justified when at least 2/3rds of the active validating balance attests to the same target epoch. Simple enough, but there’s a couple of little quirks that are easy to miss.
The relevant part of the spec is:
def weigh_justification_and_finalization(state: BeaconState,
total_active_balance: Gwei,
previous_epoch_target_balance: Gwei,
current_epoch_target_balance: Gwei) -> None:
previous_epoch = get_previous_epoch(state)
current_epoch = get_current_epoch(state)
old_previous_justified_checkpoint = state.previous_justified_checkpoint
old_current_justified_checkpoint = state.current_justified_checkpoint
# Process justifications
state.previous_justified_checkpoint = state.current_justified_checkpoint
state.justification_bits[1:] = state.justification_bits[:JUSTIFICATION_BITS_LENGTH - 1]
state.justification_bits[0] = 0b0
if previous_epoch_target_balance * 3 >= total_active_balance * 2:
state.current_justified_checkpoint = Checkpoint(epoch=previous_epoch,
root=get_block_root(state, previous_epoch))
state.justification_bits[1] = 0b1
if current_epoch_target_balance * 3 >= total_active_balance * 2:
state.current_justified_checkpoint = Checkpoint(epoch=current_epoch,
root=get_block_root(state, current_epoch))
state.justification_bits[0] = 0b1
It then goes on to check if finalization should be updated. From this we can see there is already one quirk - both the previous_epoch_target_balance
and the current_epoch_target_balance
are compared to the same total_active_balance
, yet the total effective balance of all active validators can change between epochs.
The second quirk is similar but can’t be seen from this code itself. It’s a little hard to summarize where the previous_epoch_target_balance
value comes from by quoting the spec code as we have to follow the flow through a number of different functions. So let’s take a look at the Teku implementation which, for performance reasons, is a lot more direct:
UInt64 currentEpochActiveValidators = UInt64.ZERO;
UInt64 previousEpochActiveValidators = UInt64.ZERO;
UInt64 currentEpochSourceAttesters = UInt64.ZERO;
UInt64 currentEpochTargetAttesters = UInt64.ZERO;
UInt64 previousEpochSourceAttesters = UInt64.ZERO;
UInt64 previousEpochTargetAttesters = UInt64.ZERO;
UInt64 previousEpochHeadAttesters = UInt64.ZERO;
for (ValidatorStatus status : statuses) {
final UInt64 balance = status.getCurrentEpochEffectiveBalance();
if (status.isActiveInCurrentEpoch()) {
currentEpochActiveValidators = currentEpochActiveValidators.plus(balance);
}
if (status.isActiveInPreviousEpoch()) {
previousEpochActiveValidators = previousEpochActiveValidators.plus(balance);
}
if (status.isSlashed()) {
continue;
}
if (status.isCurrentEpochSourceAttester()) {
currentEpochSourceAttesters = currentEpochSourceAttesters.plus(balance);
}
if (status.isCurrentEpochTargetAttester()) {
currentEpochTargetAttesters = currentEpochTargetAttesters.plus(balance);
}
if (status.isPreviousEpochSourceAttester()) {
previousEpochSourceAttesters = previousEpochSourceAttesters.plus(balance);
}
if (status.isPreviousEpochTargetAttester()) {
previousEpochTargetAttesters = previousEpochTargetAttesters.plus(balance);
}
if (status.isPreviousEpochHeadAttester()) {
previousEpochHeadAttesters = previousEpochHeadAttesters.plus(balance);
}
}
Here we’re iterating through the ValidatorStatus
info which roughly maps to the Validator
object from the state but with some handy abstractions to make it easier to support both Phase0 and Altair with less duplication. The thing to notice here is that regardless of whether we’re adding to the current or previous epoch balances, we’re using the same balance
that we got from getCurrentEpochEffectiveBalance
. Part of the epoch transition involves adjusting effective balances though, so the effective balance of a validator might have been different in the previous epoch.
Why is it like this? Primarily because the state only maintains the current effective balance for validators. To get the effective balance for a previous epoch you’d need to have a state from in that epoch, but the state transition is designed to only need a single state and the block to apply (if any - you could just process empty slots). You could potentially make an argument that using the validator’s latest effective balance is better anyway since that’s what they actually have at stake now. In fact, any validators that are slashed in the current epoch are entirely excluded from the current and previous epoch attesting totals which makes sense - we know they’re unreliable so we ignore their attestations.
What impact does this have? Essentially none. The amount that effective balances would change is generally pretty limited and there are limits on the number of validators that can activate or exit each epoch. So the difference between the numbers you might expect and what you actually get are quite small, so you’d have to be right on the edge of justification for this to make any difference. In theory though it is possible for an epoch to be balanced just right so that it doesn’t justify immediately, but does at the next epoch transition without including any new attestations. The opposite is also possible where the epoch justifies but then effective balances change such that it doesn’t meet the threshold to justify as the previous epoch - that would just leave the state.current_justified_checkpoint
unchanged though which means the original justification stands.
But it may make for a very niche trivia question one day, and now you’re prepared with the answer…