Part 3: Annotated Specification

Helper Functions

Predicates

is_active_validator

def is_active_validator(validator: Validator, epoch: Epoch) -> bool:
    """
    Check if ``validator`` is active.
    """
    return validator.activation_epoch <= epoch < validator.exit_epoch

Validators don't explicitly track their own state (eligible for activation, active, exited, withdrawable - the sole exception being whether they have been slashed or not). Instead, a validator's state is calculated by looking at the fields in the Validator record that store the epoch numbers of state transitions.

In this case, if the validator was activated in the past and has not yet exited, then it is active.

This is used a few times in the spec, most notably in get_active_validator_indices() which returns a list of all active validators at an epoch.

Used by get_active_validator_indices(), get_eligible_validator_indices(), process_registry_updates(), process_voluntary_exit()
See also Validator

is_eligible_for_activation_queue

def is_eligible_for_activation_queue(validator: Validator) -> bool:
    """
    Check if ``validator`` is eligible to be placed into the activation queue.
    """
    return (
        validator.activation_eligibility_epoch == FAR_FUTURE_EPOCH
        and validator.effective_balance == MAX_EFFECTIVE_BALANCE
    )

When a deposit is processed with a previously unseen public key, a new Validator record is created with all the state-transition fields set to the default value of FAR_FUTURE_EPOCH.

It is possible to deposit any amount over MIN_DEPOSIT_AMOUNT (currently 1 Ether) into the deposit contract. However, validators do not become eligible for activation until their effective balance is equal to MAX_EFFECTIVE_BALANCE, which corresponds to an actual balance of 32 Ether or more.

This predicate is used during epoch processing to find validators that have acquired the minimum necessary balance, but have not yet been added to the queue for activation. These validators are then marked as eligible for activation by setting the validator.activation_eligibility_epoch to the next epoch.

Used by process_registry_updates()
See also Validator, FAR_FUTURE_EPOCH, MAX_EFFECTIVE_BALANCE

is_eligible_for_activation

def is_eligible_for_activation(state: BeaconState, validator: Validator) -> bool:
    """
    Check if ``validator`` is eligible for activation.
    """
    return (
        # Placement in queue is finalized
        validator.activation_eligibility_epoch <= state.finalized_checkpoint.epoch
        # Has not yet been activated
        and validator.activation_epoch == FAR_FUTURE_EPOCH
    )

A validator that is_eligible_for_activation() has had its activation_eligibility_epoch set, but its activation_epoch is not yet set.

To avoid any ambiguity or confusion on the validator side about its state, we wait until its eligibility activation epoch has been finalised before adding it to the activation queue by setting its activation_epoch. Otherwise, it might at one point become active, and then the beacon chain could flip to a fork in which it is not active. This could happen if the latter fork had fewer blocks and had thus processed fewer deposits.

Note that state.finalized_checkpoint.epoch does not mean that all of the slots in that epoch are finalised. We finalise checkpoints, not epochs, so only the first slot (the checkpoint) of that epoch is finalised. This is accounted for in process_registry_updates() by adding one to the current epoch when setting the validator.activation_eligibility_epoch, so that we can be sure that the block containing the deposit has been finalised.1

Used by process_registry_updates()
See also Validator, FAR_FUTURE_EPOCH

is_slashable_validator

def is_slashable_validator(validator: Validator, epoch: Epoch) -> bool:
    """
    Check if ``validator`` is slashable.
    """
    return (not validator.slashed) and (validator.activation_epoch <= epoch < validator.withdrawable_epoch)

Validators can be slashed only once: the flag validator.slashed is set when the first correct slashing report for the validator is processed.

An unslashed validator remains eligible to be slashed from when it becomes active right up until it becomes withdrawable. This is MIN_VALIDATOR_WITHDRAWABILITY_DELAY epochs (around 27 hours) after it has exited from being a validator and ceased validation duties.

Used by process_proposer_slashing(), process_attester_slashing()
See also Validator

is_slashable_attestation_data

def is_slashable_attestation_data(data_1: AttestationData, data_2: AttestationData) -> bool:
    """
    Check if ``data_1`` and ``data_2`` are slashable according to Casper FFG rules.
    """
    return (
        # Double vote
        (data_1 != data_2 and data_1.target.epoch == data_2.target.epoch) or
        # Surround vote
        (data_1.source.epoch < data_2.source.epoch and data_2.target.epoch < data_1.target.epoch)
    )

This predicate is used by process_attester_slashing() to check that the two sets of alleged conflicting attestation data in an AttesterSlashing do in fact qualify as slashable.

There are two ways for validators to get slashed under Casper FFG:

  1. A double vote: voting more than once for the same target epoch, or
  2. A surround vote: the source–target interval of one attestation entirely contains the source–target interval of a second attestation from the same validator or validators. The reporting block proposer needs to take care to order the IndexedAttestations within the AttesterSlashing object so that the first set of votes surrounds the second. (The opposite ordering also describes a slashable offence, but is not checked for here, so the order of the arguments matters.)

It is far from obvious, but this predicate also enforces LMD GHOST slashing for attestation equivocation. The AttestationData objects contain the LMD GHOST head vote (beacon_block_root) as well as the Casper FFG votes. So, the Casper FFG checkpoint votes might be identical and non-slashable, but if the LMD GHOST vote differs between the two attestations then it will be deemed slashable.

Used by process_attester_slashing()
See also AttestationData, AttesterSlashing

is_valid_indexed_attestation

def is_valid_indexed_attestation(state: BeaconState, indexed_attestation: IndexedAttestation) -> bool:
    """
    Check if ``indexed_attestation`` is not empty, has sorted and unique indices and has a valid aggregate signature.
    """
    # Verify indices are sorted and unique
    indices = indexed_attestation.attesting_indices
    if len(indices) == 0 or not indices == sorted(set(indices)):
        return False
    # Verify aggregate signature
    pubkeys = [state.validators[i].pubkey for i in indices]
    domain = get_domain(state, DOMAIN_BEACON_ATTESTER, indexed_attestation.data.target.epoch)
    signing_root = compute_signing_root(indexed_attestation.data, domain)
    return bls.FastAggregateVerify(pubkeys, signing_root, indexed_attestation.signature)

is_valid_indexed_attestation() is used in attestation processing and attester slashing.

IndexedAttestations differ from Attestations in that the latter record the contributing validators in a bitlist and the former explicitly list the global indices of the contributing validators.

An IndexedAttestation passes this validity test only if all the following apply.

  1. There is at least one validator index present.
  2. The list of validators contains no duplicates (the Python set function performs deduplication).
  3. The indices of the validators are sorted. (It is not clear to me why this is required. It's used in the duplicate check here, but that could just be replaced by checking the set size.)
  4. Its aggregated signature verifies against the aggregated public keys of the listed validators.

Verifying the signature uses the magic of aggregated BLS signatures. The indexed attestation contains a BLS signature that is supposed to be the combined individual signatures of each of the validators listed in the attestation. This is verified by passing it to bls.FastAggregateVerify() along with the list of public keys from the same validators. The verification succeeds only if exactly the same set of validators signed the message (signing_root) as appear in the list of public keys. Note that get_domain() mixes in the fork version, so that attestations are not valid across forks.

No check is done here that the attesting_indices (which are the global validator indices) are all members of the correct committee for this attestation. In process_attestation() they must be, by construction. In process_attester_slashing() it doesn't matter: any validator signing conflicting attestations is liable to be slashed.

Used by process_attester_slashing(), process_attestation()
Uses get_domain(), compute_signing_root(), bls.FastAggregateVerify()
See also IndexedAttestation, Attestation

is_valid_merkle_branch

def is_valid_merkle_branch(leaf: Bytes32, branch: Sequence[Bytes32], depth: uint64, index: uint64, root: Root) -> bool:
    """
    Check if ``leaf`` at ``index`` verifies against the Merkle ``root`` and ``branch``.
    """
    value = leaf
    for i in range(depth):
        if index // (2**i) % 2:
            value = hash(branch[i] + value)
        else:
            value = hash(value + branch[i])
    return value == root

This is the classic algorithm for verifying a Merkle branch (also called a Merkle proof). Nodes are iteratively hashed as the tree is traversed from leaves to root. The bits of index select whether we are the right or left child of our parent at each level. The result should match the given root of the tree.

In this way we prove that we know that leaf is the value at position index in the list of leaves, and that we know the whole structure of the rest of the tree, as summarised in branch.

We use this function in process_deposit() to check whether the deposit data we've received is correct or not. Based on the deposit data they have seen, Eth2 clients build a replica of the Merkle tree of deposits in the deposit contract. The proposer of the block that includes the deposit constructs the Merkle proof using its view of the deposit contract, and all other nodes use is_valid_merkle_branch() to check that their view matches the proposer's. If any deposit fails Merkle branch verification then the entire block is invalid.

Used by process_deposit()

is_merge_transition_complete

def is_merge_transition_complete(state: BeaconState) -> bool:
    return state.latest_execution_payload_header != ExecutionPayloadHeader()

A simple test for whether the given beacon state is pre- or post-Merge. If the latest_execution_payload_header in the state is the default ExecutionPayloadHeader then the chain is pre-Merge, otherwise it is post-Merge. Upgrades normally occur at a predetermined block height (or epoch number on the beacon chain), and that's the usual way to test for them. The block height of the Merge, however, was unknown ahead of time, so a different kind of test was required.

Although the mainnet beacon chain is decidedly post-Merge now, this remains useful for syncing nodes from pre-Merge starting points.

This function was added in the Bellatrix pre-Merge upgrade.

Used by process_execution_payload(), is_merge_transition_block(), is_execution_enabled()
See also ExecutionPayloadHeader

is_merge_transition_block

def is_merge_transition_block(state: BeaconState, body: BeaconBlockBody) -> bool:
    return not is_merge_transition_complete(state) and body.execution_payload != ExecutionPayload()

If the Merge transition is not complete (meaning that the beacon state still has the default execution payload header in it), yet our block has a non-default execution payload, then this must be the first block we've seen with an execution payload. It is therefore the Merge transition block.

This function was added in the Bellatrix pre-Merge upgrade.

Uses is_merge_transition_complete()
Used by is_execution_enabled(), on_block() (Bellatrix version)
See also ExecutionPayload

is_execution_enabled

def is_execution_enabled(state: BeaconState, body: BeaconBlockBody) -> bool:
    return is_merge_transition_block(state, body) or is_merge_transition_complete(state)

If the block that we have is the first block with an execution payload (the Merge transition block), or we know from the state that we have previously seen a block with an execution payload then execution is enabled, the execution and consensus chains have Merged.

This function was added in the Bellatrix pre-Merge upgrade.

Uses is_merge_transition_block(), is_merge_transition_complete()
Used by process_block()

has_eth1_withdrawal_credential

def has_eth1_withdrawal_credential(validator: Validator) -> bool:
    """
    Check if ``validator`` has an 0x01 prefixed "eth1" withdrawal credential.
    """
    return validator.withdrawal_credentials[:1] == ETH1_ADDRESS_WITHDRAWAL_PREFIX

Only validators that have Eth1 withdrawal credentials are eligible for balance withdrawals of any sort.

Used by is_fully_withdrawable_validator(), is_partially_withdrawable_validator()
See also ETH1_ADDRESS_WITHDRAWAL_PREFIX

is_fully_withdrawable_validator

def is_fully_withdrawable_validator(validator: Validator, balance: Gwei, epoch: Epoch) -> bool:
    """
    Check if ``validator`` is fully withdrawable.
    """
    return (
        has_eth1_withdrawal_credential(validator)
        and validator.withdrawable_epoch <= epoch
        and balance > 0
    )

A validator is fully withdrawable only when (a) it has an Eth1 withdrawal credential to make the withdrawal to, (b) it has become withdrawable, meaning that its exit has been processed and it has passed through its MIN_VALIDATOR_WITHDRAWABILITY_DELAY period, and (c) it has a nonzero balance.

Uses has_eth1_withdrawal_credential()
Used by get_expected_withdrawals()

is_partially_withdrawable_validator

def is_partially_withdrawable_validator(validator: Validator, balance: Gwei) -> bool:
    """
    Check if ``validator`` is partially withdrawable.
    """
    has_max_effective_balance = validator.effective_balance == MAX_EFFECTIVE_BALANCE
    has_excess_balance = balance > MAX_EFFECTIVE_BALANCE
    return has_eth1_withdrawal_credential(validator) and has_max_effective_balance and has_excess_balance

A partial withdrawal is the withdrawal of excess Ether from an active (non-exited) validator.

A validator has excess Ether only when (a) it's effective balance is at MAX_EFFECTIVE_BALANCE, (b) its actual balance is greater than MAX_EFFECTIVE_BALANCE, and (c) it has an Eth1 withdrawal credential to make the withdrawal to.

The first of these conditions is related to the hysteresis in the effective balance. If a validator has previously suffered a drop in its balance, it's effective balance might be 31 Ether even while its actual balance is greater than 32 Ether. If we were to start skimming withdrawals in this situation, the validator's balance would never reach the 32.25 Ether necessary to bring its effective balance up to 32 Ether, and it would be forever stuck at 31 ETH. Therefore, only validators with the full effective balance are eligible for the excess to be withdrawn.

Used by get_expected_withdrawals()
Uses has_eth1_withdrawal_credential()
See also MAX_EFFECTIVE_BALANCE, hysteresis

  1. I'd have preferred not adding the one there, and using <, here. But it is what it is.

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