This is the first installment of an annotated Gloas spec. It’s aimed mostly for an audience of client implementers or spec researchers. Text will be terse and assumes deeper knowledge of the previous forks’ code. In this post we will go over some changes in the file beacon-chain.md. As requested by many in the Prysm team, we will restrict ourselves to describe the why of these changes rather than the how. We will simply omit any section that is self-descriptive. Text follows the order of the beacon-chain.md file, therefore some explanations have to be deferred until the corresponding helper functions and classes are declared.

My gratitude goes to Justin Traglia, Paul Harris and Manu Nalepa for reading this and fixing it on the way.

Containers

Trustless payments

BuilderPendingPayment

class BuilderPendingPayment(Container):
    weight: Gwei
    withdrawal: BuilderPendingWithdrawal

BuilderPendingWithdrawal

class BuilderPendingWithdrawal(Container):
    fee_recipient: ExecutionAddress
    amount: Gwei
    builder_index: BuilderIndex

These two classes are there to implement trustless payments from builders to proposers. Whenever a builder’s bid is processed as part of a beacon block, a commitment to the builders payment is added to the beacon state in the form of a BuilderPendingPayment. This commitment has the minimum necessary information to fulfil the payment: the fee recipient that will be credited in the EL (this is communicated to the builder by the proposer ahead of time), the amount of the payment and the builder index that points to the builder that will need to have its balance deducted. The remaining field weight is there to prevent grief attacks on builders. The mechanism works as follows. When the beacon block is processed, the BuilderPendingPayment is recorded in the beacon state, but the builder is not immediately deducted, and the proposer is not immediately paid. The proposer is paid in two different situations. In both situations, rather than immediately crediting the proposer, a BuilderPendingWithdrawal is stored in the beacon state for later processing. The two situations in which the proposer is paid are as follows

  • If later the payload committed to by the builder is processed in process_execution_payload then in this case a BuilderPendingWithdrawal is immediately added to the beacon state.
  • If no payload has been processed until the end of the next epoch, at epoch transition the committed BuilderPendingPayment is processed, the weight parameter is there to track how much of the slot’s beacon committee actually saw the block timely and attested to it.

On the one hand we have to wait up to two epochs to process these payments because attestations can be included up to the next epoch. It may be that some of these attestations are being withheld or haven’t been seen by the network on time. On the other hand we keep track of the weight that attested to this block during its slot to prevent a situation in which a block becomes canonical but without any attestations (for example the proposer controls two slots in a row and submits both blocks together during the second slot, there is no way that the first block is attested in this case); in this case, the builder could not possibly know that he had to reveal a payload (or that it was even selected to reveal a payload) so if the weight field is below 60% of the total committee, the builder is exempt from paying its bid when the payload has not been processed.

Instead of processing payments later as a withdrawal in the EL, payment could be credited directly to the proposer’s balance. This would simplify considerably withdrawal processing. Proposers would receive payment later in the withdrawal sweep or by performing a withdrawal request themselves at their preferred time. This however was a request from decentralized staking pools because the withdrawal credentials (the target of the usual withdrawal sweep) is typically a different contract than the fee recipient contract. Modifying the withdrawal credentials contract to account for EL payments was deemed too invasive to pools. Another reason (which is no longer valid) was used to justify the withdrawal pipeline, at the time of this modification, builders were active validators, and immediate balance transfers would raise a security concern with transferring of slashed funds. This would anyway force payments to be deferred under a churn like the withdrawals/deposits churn. This reason is no longer valid as builders are no longer part of the validator slice as we will see below.

Payload timeliness

PayloadAttestationData

class PayloadAttestationData(Container):
    beacon_block_root: Root
    slot: Slot
    payload_present: boolean
    blob_data_available: boolean

PayloadAttestation

class PayloadAttestation(Container):
    aggregation_bits: Bitvector[PTC_SIZE]
    data: PayloadAttestationData
    signature: BLSSignature

PayloadAttestationMessage

class PayloadAttestationMessage(Container):
    validator_index: ValidatorIndex
    data: PayloadAttestationData
    signature: BLSSignature

IndexedPayloadAttestation

class IndexedPayloadAttestation(Container):
    attesting_indices: List[ValidatorIndex, PTC_SIZE]
    data: PayloadAttestationData
    signature: BLSSignature

These classes deal with the Payload timeliness committee that is in charge of attesting to the timeliness of the payload and the availability of its blob data. Each slot, a committee of 512 validators are selected from the attesting committee to also submit an attestation to 1) the timeliness of the payload and 2) the availability of the blob data. PTC members submit a PayloadAttestationMessage which contains minimum information to identify the beacon block (the slot and root) and the two independent booleans. The payload_present boolean is set to true if the payload has been received on time, regardless of its validity. The blob_data_available boolean is set to true if the PTC attester has received all the corresponding data column sidecars that it needed to custody for this payload. A few words are in order with timeliness. The current spec does not yet implement a dual deadline PTC vote yet, but this is already open for review and most probably it will be merged by the time you read this document. In this dual deadline approach, the PTC attester records the time at which it has received the payload and continues waiting for the data to arrive. It submits its attestation if it has either

  • Received the payload and all the needed data.
  • The final PAYLOAD_ATTESTATION_DUE_BPS deadline has been reached.

Having different deadlines for timeliness and DA has many advantages. On the one hand it allows us to maximize payload execution, by minimizing the time it needs to be on the flight on broadcast. It also allows us to maximize the data throughput by maximizing the final deadline to send the attestation. This reflects the fact that payload and data have very different nature: the former needs to be received and then executed, while the second one only needs to be received, verification is almost immediate. This explains why the two booleans are independent. In the happy case the attester has both received the payload on time and the blob data on time. In this case both are true. It may happen that the payload was timely, but the data never arrived on time. It may happen that neither the payload nor the data arrived on time to the attester. Finally it may happen that the payload has not arrived on time but the attester has received all the data column sidecars by the deadline. This is the reason why we have two independent booleans as well. Different set of PTC members may be used to reach a consensus on the payload timeliness than the ones used to reach a consensus on the data being available. If the next proposer is a super node, it’s own view of the data availability is good enough to guarantee the data is available. But if the proposer is not, it can take the PTC as a hint that the data is (un)available.

Another change with respect to normal attestations is that the message sent by a single attester is actually different than the message that goes on-chain. The signed message includes an actual validator index to identify the signer, instead of using the same format as the aggregated object and forcing clients to check it has a single bit set and get the index from the bit.

Bids

ExecutionPayloadBid

class ExecutionPayloadBid(Container):
    parent_block_hash: Hash32
    parent_block_root: Root
    block_hash: Hash32
    prev_randao: Bytes32
    fee_recipient: ExecutionAddress
    gas_limit: uint64
    builder_index: BuilderIndex
    slot: Slot
    value: Gwei
    execution_payment: Gwei
    blob_kzg_commitments_root: Root

SignedExecutionPayloadBid

class SignedExecutionPayloadBid(Container):
    message: ExecutionPayloadBid
    signature: BLSSignature

These objects propagate over the P2P network and are also requested directly from the builder to be included in the block. They do not include all the elements that the ExecutionPayloadHeader currently has. The reason is simply that the beacon chain has zero usage for the full header and we couldn’t find anyone that is proving execution against that header in the beacon state (anyway execution can be proven against the block hash from the beacon state, or can be proven also from the EL directly). The fields that are included in the bid are the minimum required to be able to identify that the promise of the payload, constructed by the builder, is compatible with the proposer’s head. The parent block root and the parent block hash commit to both parents in the CL and the EL. The reason why the two are needed instead of just one, is that a CL block root commits to a head in the CL, but two possible heads in the EL: either the payload for that block was available and it’s the EL head, or the payload wasn’t and then the parent payload is the head in the EL. The fields for gas_limit and prev_randao are checked in the CL, one explicitly in state transition and the other to check that it’s consistent with the proposer’s configurations. This is so that proposers (which are expected to be more decentralized) set the gas limit, instead of builders. The slot is to fully identify the proposer, fee recipient is so that the proposer knows that they’ll be paid in the right address. The value field specifies the amount the proposer is sure to have in a trustless manner by the mechanism described above and the execution_payment field contains a promise to pay the proposer by any other means if the proposer so decides to take it. The blob_kzg_commitments_root is there so that data column sidecars can be validated even if the payload envelope is not received. We will describe this later, but this enables the builder to start immediately broadcasting blobs even if they aren’t yet willing to broadcast the payload. Avoiding any bandwith bottleneck. The data column sidecars contain all of the kzg commitments, and as long as they hash to this root then we know we can trust them to correspond to this bid. It would be better if we had the full list of commitments in the bid already so that clients could request the blobs from the EL directly before even any sidecar arrives, but this would bloat the p2p network for bids, and the proposer <-> builder direct connection when requesting the bids.

The most controversial field in the bid is the block_hash. It commits to a specific payload, something that is not strictly necessary. It also creates the dreaded Free Option Problem. Another option would be to omit this field and let the builder just produce any payload at reveal time. But the problem of that approach would be that proposers would not commit to any bid, just sign as if they were self-building and then carry the auction just in time right before the maximum payload deadline, missing most of the scaling properties of EIP-7732.

Payloads

ExecutionPayloadEnvelope

class ExecutionPayloadEnvelope(Container):
    payload: ExecutionPayload
    execution_requests: ExecutionRequests
    builder_index: BuilderIndex
    beacon_block_root: Root
    slot: Slot
    blob_kzg_commitments: List[KZGCommitment, MAX_BLOB_COMMITMENTS_PER_BLOCK]
    state_root: Root

SignedExecutionPayloadEnvelope

class SignedExecutionPayloadEnvelope(Container):
    message: ExecutionPayloadEnvelope
    signature: BLSSignature

The builder broadcast these objects when they find out that their signed bid was included in a beacon block. In addition to the payload, execution requests are added (they are needed to perform an extra state transition in the beacon chain), the builder index is to identify the signature (this is just a convenience since in principle this data can be taken from the beacon block root). Same with the slot, it is not strictly needed, but it ended up being useful in the earlier interops Teku/Prysm, in which payloads were being broadcast before the beacon block (we were testing with empty payloads) and therefore clients needed to ofter rely on pending payloads cache until they saw the beacon block root. The blob kzg commitments are completely useless in consensus and can probably be removed, they are all included anyway in every data column sidecar, but removing so would complicate the payload processing that requires them and this is the reason why this pull request has been closed. The state root is also a convenience and can probably be removed, it gives a commitment to prove immediately against the post-beacon state of any payload.

Notice that while the beacon block processing is a single state transition, not touching the EL at all since Gloas, the execution payload processing does two state transitions, one in the EL and another in the CL because of the execution requests.

Execution requests could not be processed in the consensus block: to process them in the same slot they would have to be on the signed bid, and the state transition would have to be reverted if the payload is not included, or the next payload would have to be forced to include the same execution requests, either way, coordination would be quite complicated. Similarly if the requests are processed in the next consensus block.

Beacon block

class BeaconBlockBody(Container):
    randao_reveal: BLSSignature
    eth1_data: Eth1Data
    graffiti: Bytes32
    proposer_slashings: List[ProposerSlashing, MAX_PROPOSER_SLASHINGS]
    attester_slashings: List[AttesterSlashing, MAX_ATTESTER_SLASHINGS_ELECTRA]
    attestations: List[Attestation, MAX_ATTESTATIONS_ELECTRA]
    deposits: List[Deposit, MAX_DEPOSITS]
    voluntary_exits: List[SignedVoluntaryExit, MAX_VOLUNTARY_EXITS]
    sync_aggregate: SyncAggregate
    # [Modified in Gloas:EIP7732]
    # Removed `execution_payload`
    bls_to_execution_changes: List[SignedBLSToExecutionChange, MAX_BLS_TO_EXECUTION_CHANGES]
    # [Modified in Gloas:EIP7732]
    # Removed `blob_kzg_commitments`
    # [Modified in Gloas:EIP7732]
    # Removed `execution_requests`
    # [New in Gloas:EIP7732]
    signed_execution_payload_bid: SignedExecutionPayloadBid
    # [New in Gloas:EIP7732]
    payload_attestations: List[PayloadAttestation, MAX_PAYLOAD_ATTESTATIONS]

Nothing new here, removed fields are now included in other containers and the new objects are the signed bid – committing to a payload and a payment, and the payload attestations. Payload attestations are included on-chain to allow the proposer to assert its view of head. If the proposer wants to build on top of a full payload that attesters haven’t seen timely (because they haven’t received all PTC attestations for example) then the proposer can show them that there was indeed quorum. Similarly to assert the opposite: if there was a quorum that the payload was not timely, the proposer can include these votes to justify it’s reorging of the payload, even though the proposer itself may have had the payload.

Beacon state

The changes are as follows:

class BeaconState(Container):
    genesis_time: uint64
    ...
    # [Modified in Gloas:EIP7732]
    # Removed `latest_execution_payload_header`
    # [New in Gloas:EIP7732]
    latest_execution_payload_bid: ExecutionPayloadBid
    ...
    # [New in Gloas:EIP7732]
    builders: List[Builder, BUILDER_REGISTRY_LIMIT]
    # [New in Gloas:EIP7732]
    next_withdrawal_builder_index: BuilderIndex
    # [New in Gloas:EIP7732]
    execution_payload_availability: Bitvector[SLOTS_PER_HISTORICAL_ROOT]
    # [New in Gloas:EIP7732]
    builder_pending_payments: Vector[BuilderPendingPayment, 2 * SLOTS_PER_EPOCH]
    # [New in Gloas:EIP7732]
    builder_pending_withdrawals: List[BuilderPendingWithdrawal, BUILDER_PENDING_WITHDRAWALS_LIMIT]
    # [New in Gloas:EIP7732]
    latest_block_hash: Hash32
    # [New in Gloas:EIP7732]
    payload_expected_withdrawals: List[Withdrawal, MAX_WITHDRAWALS_PER_PAYLOAD]

As mentioned before, the execution payload header was not useful in consensus, the only thing that was checked against was the parent block hash which is now included in the bid for the payload to be processed and in latest_block_hash for the latest payload that was processed. There is a new list of builders whose stake is not actively validating, therefore it is not subject to slashing and thus does not affect any weak subjectivity computations. The stake held in the builders’ balances can only be used to pay for bids or be withdrawn.

The bitvector execution_payload_availability keeps track of which payloads have been seen. Since for each slot we effectively have now two different state transitions, there are three possible outcomes: the block is full, that is both CL and EL block have been processed. The block is empty, that is the CL block has been processed but the EL block hasn’t, or the block is skipped, that is neither CL block nor EL block have been processed. The bit in execution_payload_availability records if the payload was or not processed for the given slot. Notice that it cannot happen for a payload to be included without its corresponding beacon block committing to it.

Data clases

ExpectedWithdrawals

@dataclass
class ExpectedWithdrawals(object):
    withdrawals: Sequence[Withdrawal]
    # [New in Gloas:EIP7732]
    processed_builder_withdrawals_count: uint64
    processed_partial_withdrawals_count: uint64
    # [New in Gloas:EIP7732]
    processed_builders_sweep_count: uint64
    processed_sweep_withdrawals_count: uint64

processing of withdrawals has become much more complicated, thus we decided to add a special data class for the return of the helper get_expected_withdrawals. Along the previous withdrawals, processed_partial_withdrawals_count and processed_sweep_withdrawals_count we added the number of builder withdrawals that were processed (those are the payments from builders to proposers) and builders sweeps. In principle the latter could be removed, but it could eventually lead to locked dead capital in case a builder deposited into an exited builder. The sweep serves only the purpose of preventing this capital loss.

Predicates for builder status

is_builder_index

def is_builder_index(validator_index: ValidatorIndex) -> bool:
    return (validator_index & BUILDER_INDEX_FLAG) != 0

is_active_builder

def is_active_builder(state: BeaconState, builder_index: BuilderIndex) -> bool:
    """
    Check if the builder at ``builder_index`` is active for the given ``state``.
    """
    builder = state.builders[builder_index]
    return (
        # Placement in builder list is finalized
        builder.deposit_epoch < state.finalized_checkpoint.epoch
        # Has not initiated exit
        and builder.withdrawable_epoch == FAR_FUTURE_EPOCH
    )

This function just checks if the deposit is finalized and the builder has not exited.

is_builder_withdrawal_credential

def is_builder_withdrawal_credential(withdrawal_credentials: Bytes32) -> bool:
    return withdrawal_credentials[:1] == BUILDER_WITHDRAWAL_PREFIX

We use the same validator index type to check if a given index belongs to a builder. This is because the offset in the slice of builders would also be a validator index. Instead we set the bit 1<<40 (BUILDER_INDEX_FLAG) to mean that the given validator index should be treated as a builder index. Notice that 2^40 is the limit for the validator slice, so it is guaranteed to be out of bounds, even without reusing validator indices. Builders are considered active after finalization of their deposit processing and if they haven’t withdrawn. Finally builders have a withdrawal credential of 0x03 which is used to identify deposits for builders.

The reason we went with this mechanism of overloading the validator index and share the same space of uint64 and conversions with BUILDER_INDEX_FLAG is that the alternative option was to use the public key explicitly in all helpers involving builders. This seemed too invasive for implementers as the pubkey is typically a large cache that is seldom accessed. This decision may bite us back if there aren’t many builders registered at the given time.

Helpers

is_attestation_same_slot

def is_attestation_same_slot(state: BeaconState, data: AttestationData) -> bool:
    """
    Check if the attestation is for the block proposed at the attestation slot.
    """
    if data.slot == 0:
        return True

    blockroot = data.beacon_block_root
    slot_blockroot = get_block_root_at_slot(state, data.slot)
    prev_blockroot = get_block_root_at_slot(state, Slot(data.slot - 1))

    return blockroot == slot_blockroot and blockroot != prev_blockroot

This helper is needed for two reasons. Firstly, when a validator on the beacon committee for slot N attest to the timely block of slot N, that validator is not committing to any payload content of that slot. The payload for slot N may or may not be included, it will be revealed later if ever and it is not up to the attester to know what will happen to that payload. However if that attester attests to a past block, for example the parent in N-1 then that attester has to signal if it is attesting for that block with its corresponding payload or not. Thus, an attester for N that attests for the block at N is asserting: the head of the CL chain is N and of the EL chain is N-1 (in the happy case that N built on top of both the CL and EL blocks of N-1), while if the same attester attests for N-1 it needs to make explicit the EL head, since the beacon block root N-1 does not make it explicit that the payload for N-1 was available and was valid.

The checks correspond to the fact that the attestation is for the block root that is expected for that slot and that it is different than the block root of the previous slot: if they were equal then the block root would be for a past slot and conversely, if the block root expected at this slot was also expected in the previous slot, then the roots would be equal.

is_valid_indexed_payload_attestation

def is_valid_indexed_payload_attestation(
    state: BeaconState, attestation: IndexedPayloadAttestation
) -> bool:
    """
    Check if ``attestation`` is non-empty, has sorted indices, and has
    a valid aggregate signature.
    """
    # Verify indices are non-empty and sorted
    indices = attestation.attesting_indices
    if len(indices) == 0 or not indices == sorted(indices):
        return False

    # Verify aggregate signature
    pubkeys = [state.validators[i].pubkey for i in indices]
    domain = get_domain(state, DOMAIN_PTC_ATTESTER, compute_epoch_at_slot(attestation.data.slot))
    signing_root = compute_signing_root(attestation.data, domain)
    return bls.FastAggregateVerify(pubkeys, signing_root, attestation.signature)

This is just standard copy of the usual attestation path, we just check signatures.

is_parent_block_full

Note: This function returns true if the last committed payload bid was fulfilled with a payload, which can only happen when both beacon block and payload were present. This function must be called on a beacon state before processing the execution payload bid in the block.

def is_parent_block_full(state: BeaconState) -> bool:
    return state.latest_execution_payload_bid.block_hash == state.latest_block_hash

This function, as the note calls attention to, is dangerous, is one of the few helpers whose validity depends on the timing of processing of the state in which it is called. If this function is called before block for slot N is processed, then the bid for that slot will not be processed, that means that the latest_execution_payload_bid corresponds still to the latest processed block. If the hash committed in that bid is the state.latest_block_hash then this last block payload was indeed processed. If however this same function is called after the CL block is processed but before the payload is processed, then these hashes will differ.

Perhaps we should consider renaming this function to is_latest_block_full or using the state’s payload availability bitvector instead.

convert_builder_index_to_validator_index

def convert_builder_index_to_validator_index(builder_index: BuilderIndex) -> ValidatorIndex:
    return ValidatorIndex(builder_index | BUILDER_INDEX_FLAG)

convert_validator_index_to_builder_index

def convert_validator_index_to_builder_index(validator_index: ValidatorIndex) -> BuilderIndex:
    return BuilderIndex(validator_index & ~BUILDER_INDEX_FLAG)

These two helpers are needed because we overload the validator indices and builder indices to share the same range space (uint64) while they mean offsets on different slices. So for example, the second builder, the one in builders[1], its index being 1, is treated as a validator with index 2^40 + 1. This ensures that it is indeed out of bounds from the validator slice (that has a limit of 2^40). Thus for example, a withdrawal for that builder (that will occur when the builder is exited) will use this index instead of 1 which otherwise would withdraw to the validator at index 1. The voluntary exit will include the index 2^40 + 1 and in process_voluntary_exit the helper convert_validator_index_to_builder_index will be called to obtain the index 1 and thus exit the right builder. Similarly, when processing builder withdrawals, usual withdrawals are added for the validator index 2^40 + 1 instead of the index 1. For this, the helper convert_builder_index_to_validator_index is called in get_builder_withdrawals.

Builders stake

get_pending_balance_to_withdraw_for_builder

def get_pending_balance_to_withdraw_for_builder(
    state: BeaconState, builder_index: BuilderIndex
) -> Gwei:
    return sum(
        withdrawal.amount
        for withdrawal in state.builder_pending_withdrawals
        if withdrawal.builder_index == builder_index
    ) + sum(
        payment.withdrawal.amount
        for payment in state.builder_pending_payments
        if payment.withdrawal.builder_index == builder_index
    )

can_builder_cover_bid

def can_builder_cover_bid(
    state: BeaconState, builder_index: BuilderIndex, bid_amount: Gwei
) -> bool:
    builder_balance = state.builders[builder_index].balance
    pending_withdrawals_amount = get_pending_balance_to_withdraw_for_builder(state, builder_index)
    min_balance = MIN_DEPOSIT_AMOUNT + pending_withdrawals_amount
    if builder_balance < min_balance:
        return False
    return builder_balance - min_balance >= bid_amount

Builders’ capital can be in flight because of deferred payments or withdrawals. These helpers just count the total amount that are already committed to be deducted from the builder, first in the builder pending withdrawals (those that are already previous payments to proposers) and those in the pending payments (those are not yet committed to be paid, but are locked until epoch processing to find out if the payload should have paid the bid even if it wasn’t included). Builders are only allowed to bid for up to their stake exceeding 1 ETH plus whatever they have already committed to pay.

Weight selection refactor

compute_balance_weighted_selection

def compute_balance_weighted_selection(
    state: BeaconState,
    indices: Sequence[ValidatorIndex],
    seed: Bytes32,
    size: uint64,
    shuffle_indices: bool,
) -> Sequence[ValidatorIndex]:
    """
    Return ``size`` indices sampled by effective balance, using ``indices``
    as candidates. If ``shuffle_indices`` is ``True``, candidate indices
    are themselves sampled from ``indices`` by shuffling it, otherwise
    ``indices`` is traversed in order.
    """
    total = uint64(len(indices))
    assert total > 0
    selected: List[ValidatorIndex] = []
    i = uint64(0)
    while len(selected) < size:
        next_index = i % total
        if shuffle_indices:
            next_index = compute_shuffled_index(next_index, total, seed)
        candidate_index = indices[next_index]
        if compute_balance_weighted_acceptance(state, candidate_index, seed, i):
            selected.append(candidate_index)
        i += 1
    return selected

compute_balance_weighted_acceptance

def compute_balance_weighted_acceptance(
    state: BeaconState, index: ValidatorIndex, seed: Bytes32, i: uint64
) -> bool:
    """
    Return whether to accept the selection of the validator ``index``, with probability
    proportional to its ``effective_balance``, and randomness given by ``seed`` and ``i``.
    """
    MAX_RANDOM_VALUE = 2**16 - 1
    random_bytes = hash(seed + uint_to_bytes(i // 16))
    offset = i % 16 * 2
    random_value = bytes_to_uint64(random_bytes[offset : offset + 2])
    effective_balance = state.validators[index].effective_balance
    return effective_balance * MAX_RANDOM_VALUE >= MAX_EFFECTIVE_BALANCE_ELECTRA * random_value

compute_proposer_indices

Note: compute_proposer_indices is modified to use compute_balance_weighted_selection as a helper for the balance-weighted sampling process.

def compute_proposer_indices(
    state: BeaconState, epoch: Epoch, seed: Bytes32, indices: Sequence[ValidatorIndex]
) -> Vector[ValidatorIndex, SLOTS_PER_EPOCH]:
    """
    Return the proposer indices for the given ``epoch``.
    """
    start_slot = compute_start_slot_at_epoch(epoch)
    seeds = [hash(seed + uint_to_bytes(Slot(start_slot + i))) for i in range(SLOTS_PER_EPOCH)]
    # [Modified in Gloas:EIP7732]
    return [
        compute_balance_weighted_selection(state, indices, seed, size=1, shuffle_indices=True)[0]
        for seed in seeds
    ]

get_next_sync_committee_indices

Note: get_next_sync_committee_indices is modified to use compute_balance_weighted_selection as a helper for the balance-weighted sampling process.

def get_next_sync_committee_indices(state: BeaconState) -> Sequence[ValidatorIndex]:
    """
    Return the sync committee indices, with possible duplicates, for the next sync committee.
    """
    epoch = Epoch(get_current_epoch(state) + 1)
    seed = get_seed(state, epoch, DOMAIN_SYNC_COMMITTEE)
    indices = get_active_validator_indices(state, epoch)
    return compute_balance_weighted_selection(
        state, indices, seed, size=SYNC_COMMITTEE_SIZE, shuffle_indices=True
    )

For all randomized selections in the beacon chain we use the same shuffling algorithm but then we sample from the shuffled list weighting by the effective balance of the validators. This refactors makes it explicitly by adding the new helper compute_balance_weighted_selection. The usual paths of selections to compute proposer indices and sync committee indices pass the full ordered active validator indices to the helper. The PTC however, is computed sampling from the beacon committee which is already computed shuffled in compute_committee, this makes for the boolean control flag shuffle_indices (which IMO are always a code smell).

Attestation counting

get_attestation_participation_flag_indices

Note: The function get_attestation_participation_flag_indices is modified to include a new payload matching constraint to is_matching_head.

def get_attestation_participation_flag_indices(
    state: BeaconState, data: AttestationData, inclusion_delay: uint64
) -> Sequence[int]:
    """
    Return the flag indices that are satisfied by an attestation.
    """
    # Matching source
    if data.target.epoch == get_current_epoch(state):
        justified_checkpoint = state.current_justified_checkpoint
    else:
        justified_checkpoint = state.previous_justified_checkpoint
    is_matching_source = data.source == justified_checkpoint

    # Matching target
    target_root = get_block_root(state, data.target.epoch)
    target_root_matches = data.target.root == target_root
    is_matching_target = is_matching_source and target_root_matches

    # [New in Gloas:EIP7732]
    if is_attestation_same_slot(state, data):
        assert data.index == 0
        payload_matches = True
    else:
        slot_index = data.slot % SLOTS_PER_HISTORICAL_ROOT
        payload_index = state.execution_payload_availability[slot_index]
        payload_matches = data.index == payload_index

    # Matching head
    head_root = get_block_root_at_slot(state, data.slot)
    head_root_matches = data.beacon_block_root == head_root
    # [Modified in Gloas:EIP7732]
    is_matching_head = is_matching_target and head_root_matches and payload_matches

    assert is_matching_source

    participation_flag_indices = []
    if is_matching_source and inclusion_delay <= integer_squareroot(SLOTS_PER_EPOCH):
        participation_flag_indices.append(TIMELY_SOURCE_FLAG_INDEX)
    if is_matching_target:
        participation_flag_indices.append(TIMELY_TARGET_FLAG_INDEX)
    if is_matching_head and inclusion_delay == MIN_ATTESTATION_INCLUSION_DELAY:
        participation_flag_indices.append(TIMELY_HEAD_FLAG_INDEX)

    return participation_flag_indices

As explained above, an attester that is voting for the current slot’s block, cannot commit to any payload content, if that block is canonical, it should get the timely head flag (assuming their attestation is included in the next block). However, if the attester is attesting for previous slots’ blocks, then they also need to get the payload content right.

Consider the following situations.

Same slot

graph RL G["35"]:::white E["34"]:::white F["33"]:::lightblue C["33"]:::orange B["32"]:::lightblue D["..."]:::lightblue G --> F G ~~~ E E --> C F --> B C --> B B --> D classDef white fill:#FFFFFF,stroke:#000 classDef lightblue fill:#ADD8E6 classDef green fill:#90EE90 classDef orange fill:#FFA500

The attester of slot 33 has voted during its slot for block 33, there are two possible payload contents for that block, either the payload is there (the light blue node) or the payload wasn’t there (the orange node). A consensus block in block 34 was proposed on top of the payload missing block, that is, this proposer at 34 is trying to reorg the payload of 33. The proposer of 35 ignores this beacon block and builds on top of the consensus block of 33 with its payload present. Both branches diverge and are competing now. However, the attestation for 33 supports both branches since the attester could not have any information about the payload content during its attestation.

Previous slot, payload present

In the same diagram as above, imagine an attester in the beacon committee of slot 34. That attester has seen the beacon block for 34, however they had also seen the payload for 33 and it was valid and timely, their head remains on 33 so they want to enforce that the payload remains canonical (thus allowing 35 to reorg 34 as above). These attesters of 34 will vote for 33 but also mark the payload as present. For this, we have repurposed the data.index to be 1 in the case the attester votes for the previous slots block with a payload and 0 otherwise. Attesters for the same slot block are forced to set this index to 0, but it counts for both branches as explained above.

Previous slot, payload absent

In the same diagram as above, if the attester for slot 34 has not yet seen the block for 34 nor the payload for 33, or for example the data for the payload for 33 was not available from their perspective, then these attesters will vote for 33 has head, but set the data.index to 0 to signal that their head is the consensus block of 33 without any payload.

The changes in get_attestation_participation_flag_indices deal with these scenarios. We use execution_payload_availability[slot] to check if the payload was present or not from the point of view of the passed beacon state (which is the head state when processing a beacon block).

PTC selection

get_ptc

def get_ptc(state: BeaconState, slot: Slot) -> Vector[ValidatorIndex, PTC_SIZE]:
    """
    Get the payload timeliness committee for the given ``slot``.
    """
    epoch = compute_epoch_at_slot(slot)
    seed = hash(get_seed(state, epoch, DOMAIN_PTC_ATTESTER) + uint_to_bytes(slot))
    indices: List[ValidatorIndex] = []
    # Concatenate all committees for this slot in order
    committees_per_slot = get_committee_count_per_slot(state, epoch)
    for i in range(committees_per_slot):
        committee = get_beacon_committee(state, slot, CommitteeIndex(i))
        indices.extend(committee)
    return compute_balance_weighted_selection(
        state, indices, seed, size=PTC_SIZE, shuffle_indices=False
    )

This simple helper just gets 512 PTC members from the concatenated beacon committee members. It calls the helper to get the selection weighted by balance, but it doesn’t reshuffle the indices because get_beacon_commitee already returns the shuffled indices.

Remaining state accessors

get_indexed_payload_attestation

def get_indexed_payload_attestation(
    state: BeaconState, payload_attestation: PayloadAttestation
) -> IndexedPayloadAttestation:
    """
    Return the indexed payload attestation corresponding to ``payload_attestation``.
    """
    slot = payload_attestation.data.slot
    ptc = get_ptc(state, slot)
    bits = payload_attestation.aggregation_bits
    attesting_indices = [index for i, index in enumerate(ptc) if bits[i]]

    return IndexedPayloadAttestation(
        attesting_indices=sorted(attesting_indices),
        data=payload_attestation.data,
        signature=payload_attestation.signature,
    )

This helper is just the standard counterpart for the one for usual attestations. Get the list of indices so that it’s easier to validate the signature.

get_builder_payment_quorum_threshold

def get_builder_payment_quorum_threshold(state: BeaconState) -> uint64:
    """
    Calculate the quorum threshold for builder payments.
    """
    per_slot_balance = get_total_active_balance(state) // SLOTS_PER_EPOCH
    quorum = per_slot_balance * BUILDER_PAYMENT_THRESHOLD_NUMERATOR
    return uint64(quorum // BUILDER_PAYMENT_THRESHOLD_DENOMINATOR)

This just computes 60% of the committee size as explained above. This helper is used when processing builder payments to decide whether the builder needs to pay for unincluded payloads.

State transition

initiate_builder_exit

def initiate_builder_exit(state: BeaconState, builder_index: BuilderIndex) -> None:
    """
    Initiate the exit of the builder with index ``index``.
    """
    # Return if builder already initiated exit
    builder = state.builders[builder_index]
    if builder.withdrawable_epoch != FAR_FUTURE_EPOCH:
        return

    # Set builder exit epoch
    builder.withdrawable_epoch = get_current_epoch(state) + MIN_BUILDER_WITHDRAWABILITY_DELAY

Standard. There is no delay in processing builders deposits because their stake is not validating. However there must be a minimum delay in processing exits/withdrawals because otherwise builders could clog the deposit requests and voluntary exits on blocks, preventing honest validators from depositing and exiting. There is an open PR to remove this constant, but most probably we will just lower it to a safe minimum.

Notice that the builder does not have an exit_epoch field or similar. Builders are exited by just setting the withdrawable_epoch to some real number. This is because there is no reason to have builders in an exiting status, since their stake is not validating.

process_slot

def process_slot(state: BeaconState) -> None:
    # Cache state root
    previous_state_root = hash_tree_root(state)
    state.state_roots[state.slot % SLOTS_PER_HISTORICAL_ROOT] = previous_state_root
    # Cache latest block header state root
    if state.latest_block_header.state_root == Bytes32():
        state.latest_block_header.state_root = previous_state_root
    # Cache block root
    previous_block_root = hash_tree_root(state.latest_block_header)
    state.block_roots[state.slot % SLOTS_PER_HISTORICAL_ROOT] = previous_block_root
    # [New in Gloas:EIP7732]
    # Unset the next payload availability
    state.execution_payload_availability[(state.slot + 1) % SLOTS_PER_HISTORICAL_ROOT] = 0b0

The only modification to processing the slot is that the payload availability bit for the current slot has to be set to 0.

process_epoch

def process_epoch(state: BeaconState) -> None:
    process_justification_and_finalization(state)
    process_inactivity_updates(state)
    process_rewards_and_penalties(state)
    process_registry_updates(state)
    process_slashings(state)
    process_eth1_data_reset(state)
    process_pending_deposits(state)
    process_pending_consolidations(state)
    # [New in Gloas:EIP7732]
    process_builder_pending_payments(state)
    process_effective_balance_updates(state)
    process_slashings_reset(state)
    process_randao_mixes_reset(state)
    process_historical_summaries_update(state)
    process_participation_flag_updates(state)
    process_sync_committee_updates(state)
    process_proposer_lookahead(state)

process_builder_pending_payments

def process_builder_pending_payments(state: BeaconState) -> None:
    """
    Processes the builder pending payments from the previous epoch.
    """
    quorum = get_builder_payment_quorum_threshold(state)
    for payment in state.builder_pending_payments[:SLOTS_PER_EPOCH]:
        if payment.weight >= quorum:
            state.builder_pending_withdrawals.append(payment.withdrawal)

    old_payments = state.builder_pending_payments[SLOTS_PER_EPOCH:]
    new_payments = [BuilderPendingPayment() for _ in range(SLOTS_PER_EPOCH)]
    state.builder_pending_payments = old_payments + new_payments

The only modification to epoch processing is on processing builder payments. This helper is called before effective balance updates for historical reasons before we moved the builders to be non-validators. There is an open issue for it. All this helper does is takes those pending payments that have achieved a threshold of 60% of the committee (thus the beacon block was timely and attested) and the payload was not included (included payloads remove the payment from this list) and append the corresponding builder pending withdrawal to the state. Those payments that did not achieve threshold are just removed without forcing the builder to pay.

Withdrawals

get_builder_withdrawals

def get_builder_withdrawals(
    state: BeaconState,
    withdrawal_index: WithdrawalIndex,
    prior_withdrawals: Sequence[Withdrawal],
) -> Tuple[Sequence[Withdrawal], WithdrawalIndex, uint64]:
    withdrawals_limit = MAX_WITHDRAWALS_PER_PAYLOAD - 1
    assert len(prior_withdrawals) <= withdrawals_limit

    processed_count: uint64 = 0
    withdrawals: List[Withdrawal] = []
    for withdrawal in state.builder_pending_withdrawals:
        all_withdrawals = prior_withdrawals + withdrawals
        has_reached_limit = len(all_withdrawals) >= withdrawals_limit
        if has_reached_limit:
            break

        builder_index = withdrawal.builder_index
        withdrawals.append(
            Withdrawal(
                index=withdrawal_index,
                validator_index=convert_builder_index_to_validator_index(builder_index),
                address=withdrawal.fee_recipient,
                amount=withdrawal.amount,
            )
        )
        withdrawal_index += WithdrawalIndex(1)
        processed_count += 1

    return withdrawals, withdrawal_index, processed_count

get_builders_sweep_withdrawals

def get_builders_sweep_withdrawals(
    state: BeaconState,
    withdrawal_index: WithdrawalIndex,
    prior_withdrawals: Sequence[Withdrawal],
) -> Tuple[Sequence[Withdrawal], WithdrawalIndex, uint64]:
    epoch = get_current_epoch(state)
    builders_limit = min(len(state.builders), MAX_BUILDERS_PER_WITHDRAWALS_SWEEP)
    withdrawals_limit = MAX_WITHDRAWALS_PER_PAYLOAD - 1
    assert len(prior_withdrawals) <= withdrawals_limit

    processed_count: uint64 = 0
    withdrawals: List[Withdrawal] = []
    builder_index = state.next_withdrawal_builder_index
    for _ in range(builders_limit):
        all_withdrawals = prior_withdrawals + withdrawals
        has_reached_limit = len(all_withdrawals) >= withdrawals_limit
        if has_reached_limit:
            break

        builder = state.builders[builder_index]
        if builder.withdrawable_epoch <= epoch and builder.balance > 0:
            withdrawals.append(
                Withdrawal(
                    index=withdrawal_index,
                    validator_index=convert_builder_index_to_validator_index(builder_index),
                    address=builder.execution_address,
                    amount=builder.balance,
                )
            )
            withdrawal_index += WithdrawalIndex(1)

        builder_index = BuilderIndex((builder_index + 1) % len(state.builders))
        processed_count += 1

    return withdrawals, withdrawal_index, processed_count

get_expected_withdrawals

def get_expected_withdrawals(state: BeaconState) -> ExpectedWithdrawals:
    withdrawal_index = state.next_withdrawal_index
    withdrawals: List[Withdrawal] = []

    # [New in Gloas:EIP7732]
    # Get builder withdrawals
    builder_withdrawals, withdrawal_index, processed_builder_withdrawals_count = (
        get_builder_withdrawals(state, withdrawal_index, withdrawals)
    )
    withdrawals.extend(builder_withdrawals)

    # Get partial withdrawals
    partial_withdrawals, withdrawal_index, processed_partial_withdrawals_count = (
        get_pending_partial_withdrawals(state, withdrawal_index, withdrawals)
    )
    withdrawals.extend(partial_withdrawals)

    # [New in Gloas:EIP7732]
    # Get builders sweep withdrawals
    builders_sweep_withdrawals, withdrawal_index, processed_builders_sweep_count = (
        get_builders_sweep_withdrawals(state, withdrawal_index, withdrawals)
    )
    withdrawals.extend(builders_sweep_withdrawals)

    # Get validators sweep withdrawals
    validators_sweep_withdrawals, withdrawal_index, processed_validators_sweep_count = (
        get_validators_sweep_withdrawals(state, withdrawal_index, withdrawals)
    )
    withdrawals.extend(validators_sweep_withdrawals)

    return ExpectedWithdrawals(
        withdrawals,
        # [New in Gloas:EIP7732]
        processed_builder_withdrawals_count,
        processed_partial_withdrawals_count,
        # [New in Gloas:EIP7732]
        processed_builders_sweep_count,
        processed_validators_sweep_count,
    )

apply_withdrawals

def apply_withdrawals(state: BeaconState, withdrawals: Sequence[Withdrawal]) -> None:
    for withdrawal in withdrawals:
        # [Modified in Gloas:EIP7732]
        if is_builder_index(withdrawal.validator_index):
            builder_index = convert_validator_index_to_builder_index(withdrawal.validator_index)
            builder_balance = state.builders[builder_index].balance
            state.builders[builder_index].balance -= min(withdrawal.amount, builder_balance)
        else:
            decrease_balance(state, withdrawal.validator_index, withdrawal.amount)

update_payload_expected_withdrawals

def update_payload_expected_withdrawals(
    state: BeaconState, withdrawals: Sequence[Withdrawal]
) -> None:
    state.payload_expected_withdrawals = List[Withdrawal, MAX_WITHDRAWALS_PER_PAYLOAD](withdrawals)

update_builder_pending_withdrawals

def update_builder_pending_withdrawals(
    state: BeaconState, processed_builder_withdrawals_count: uint64
) -> None:
    state.builder_pending_withdrawals = state.builder_pending_withdrawals[
        processed_builder_withdrawals_count:
    ]

update_next_withdrawal_builder_index

def update_next_withdrawal_builder_index(
    state: BeaconState, processed_builders_sweep_count: uint64
) -> None:
    if len(state.builders) > 0:
        # Update the next builder index to start the next withdrawal sweep
        next_index = state.next_withdrawal_builder_index + processed_builders_sweep_count
        next_builder_index = BuilderIndex(next_index % len(state.builders))
        state.next_withdrawal_builder_index = next_builder_index

process_withdrawals

Note: This is modified to only take the state as parameter. Withdrawals are deterministic given the beacon state, any execution payload that has the corresponding block as parent beacon block is required to honor these withdrawals in the execution layer. process_withdrawals must be called before process_execution_payload_bid as the latter function affects validator balances.

def process_withdrawals(
    state: BeaconState,
    # [Modified in Gloas:EIP7732]
    # Removed `payload`
) -> None:
    # [New in Gloas:EIP7732]
    # Return early if the parent block is empty
    if not is_parent_block_full(state):
        return

    # Get expected withdrawals
    expected = get_expected_withdrawals(state)

    # Apply expected withdrawals
    apply_withdrawals(state, expected.withdrawals)

    # Update withdrawals fields in the state
    update_next_withdrawal_index(state, expected.withdrawals)
    # [New in Gloas:EIP7732]
    update_payload_expected_withdrawals(state, expected.withdrawals)
    # [New in Gloas:EIP7732]
    update_builder_pending_withdrawals(state, expected.processed_builder_withdrawals_count)
    update_pending_partial_withdrawals(state, expected.processed_partial_withdrawals_count)
    # [New in Gloas:EIP7732]
    update_next_withdrawal_builder_index(state, expected.processed_builders_sweep_count)
    update_next_withdrawal_validator_index(state, expected.withdrawals)

Most of this is a rewrite of withdrawal processing of previous forks because the helper to get expected withdrawals has gotten out of hand in complexity. Withdrawal processing becomes indeed much more complicated in Gloas, both on the specification and in implementations. The reason is that the fulfilment of withdrawals in the EL is delayed with respect to the deduction from the CL. This explains the first check for is_parent_block_full. If the parent payload has not been present, then we cannot process any withdrawal in the current slot because the previous consensus block’s withdrawals have already been deducted in the CL and haven’t been credited in the EL. Since payloads and blocks perform state transitions, these deducted withdrawals would be lost and it is hard to recover them at block building time. This proved to be complicated to implement on interop thus we opted for explicitly caching these withdrawals in the state in the call to update_payload_expected_withdrawals.

Besides these changes, the new features are that we first get the builder withdrawals, those are payments from builders to proposers. There’s an explicit limit of MAX_WITHDRAWALS_PER_PAYLOAD - 1 so that the last withdrawal has to be for a validator and thus we are able to update the next validator index correctly. Notice also that in get_builder_withdrawals we get the validator index with convert_builder_index_to_validator_index to add the marker 2^40.

We then append partial withdrawals which are just like the previous forks for validators.

We then append builder sweep withdrawals, these are just for already exited builders that have some balance in them (for example if the builder is deposited while it’s exited).

Finally we sweep validators for withdrawals.

Notice that for builder pending withdrawals we do not care about any withdrawable epoch, like for sweeps, these withdrawals are added immediately on processing payloads or builder pending payments and are paid immediately in the next sweep that they fit.

Execution payload bid

verify_execution_payload_bid_signature

def verify_execution_payload_bid_signature(
    state: BeaconState, signed_bid: SignedExecutionPayloadBid
) -> bool:
    builder = state.builders[signed_bid.message.builder_index]
    signing_root = compute_signing_root(
        signed_bid.message, get_domain(state, DOMAIN_BEACON_BUILDER)
    )
    return bls.Verify(builder.pubkey, signing_root, signed_bid.signature)

process_execution_payload_bid

def process_execution_payload_bid(state: BeaconState, block: BeaconBlock) -> None:
    signed_bid = block.body.signed_execution_payload_bid
    bid = signed_bid.message
    builder_index = bid.builder_index
    amount = bid.value

    # For self-builds, amount must be zero regardless of withdrawal credential prefix
    if builder_index == BUILDER_INDEX_SELF_BUILD:
        assert amount == 0
        assert signed_bid.signature == bls.G2_POINT_AT_INFINITY
    else:
        # Verify that the builder is active
        assert is_active_builder(state, builder_index)
        # Verify that the builder has funds to cover the bid
        assert can_builder_cover_bid(state, builder_index, amount)
        # Verify that the bid signature is valid
        assert verify_execution_payload_bid_signature(state, signed_bid)

    # Verify that the bid is for the current slot
    assert bid.slot == block.slot
    # Verify that the bid is for the right parent block
    assert bid.parent_block_hash == state.latest_block_hash
    assert bid.parent_block_root == block.parent_root
    assert bid.prev_randao == get_randao_mix(state, get_current_epoch(state))

    # Record the pending payment if there is some payment
    if amount > 0:
        pending_payment = BuilderPendingPayment(
            weight=0,
            withdrawal=BuilderPendingWithdrawal(
                fee_recipient=bid.fee_recipient,
                amount=amount,
                builder_index=builder_index,
            ),
        )
        state.builder_pending_payments[SLOTS_PER_EPOCH + bid.slot % SLOTS_PER_EPOCH] = (
            pending_payment
        )

    # Cache the signed execution payload bid
    state.latest_execution_payload_bid = bid

Processing the bid is relatively simple: the signature is verified for external builders. Since we also support self-building, we enforce the signature is the point at infinity if the proposer is self-building. We only take bids from external builders if they are active and can cover the amount of the bid in the amount field. We ignore any trusted payment value here. Notice that we currently do not have any way to have active external builders during the first few epochs of the Gloas fork until their deposits are finalized. The remaining verifications are there to make sure that the bid is compatible with the beacon block, in particular it is building on top of the same parent both on the CL and the EL. If there is any payment, a new BuilderPendingPayment is added to the state. The first half of the builder_pending_payments slice contains the payments for the previous epoch and the second half those of the current epoch. This is so that at epoch transition, only the first half is processed, and the second half is moved to the beginning of the slice.

Builder deposits

get_index_for_new_builder

def get_index_for_new_builder(state: BeaconState) -> BuilderIndex:
    for index, builder in enumerate(state.builders):
        if builder.withdrawable_epoch <= get_current_epoch(state) and builder.balance == 0:
            return BuilderIndex(index)
    return BuilderIndex(len(state.builders))

get_builder_from_deposit

def get_builder_from_deposit(
    state: BeaconState, pubkey: BLSPubkey, withdrawal_credentials: Bytes32, amount: uint64
) -> Builder:
    return Builder(
        pubkey=pubkey,
        version=uint8(withdrawal_credentials[0]),
        execution_address=ExecutionAddress(withdrawal_credentials[12:]),
        balance=amount,
        deposit_epoch=get_current_epoch(state),
        withdrawable_epoch=FAR_FUTURE_EPOCH,
    )

add_builder_to_registry

def add_builder_to_registry(
    state: BeaconState, pubkey: BLSPubkey, withdrawal_credentials: Bytes32, amount: uint64
) -> None:
    index = get_index_for_new_builder(state)
    builder = get_builder_from_deposit(state, pubkey, withdrawal_credentials, amount)
    set_or_append_list(state.builders, index, builder)

apply_deposit_for_builder

Note: Builder indices are reusable. When a builder exits, its index may later be reassigned to a different builder with a new public key. Any deposit sent to an exited builder is refunded to the builder’s execution address. Exited builders cannot be reactivated, although a newly registered builder’s public key may have previously appeared in the builder set. Implementations that rely on caching should account for this behavior.

def apply_deposit_for_builder(
    state: BeaconState,
    pubkey: BLSPubkey,
    withdrawal_credentials: Bytes32,
    amount: uint64,
    signature: BLSSignature,
) -> None:
    builder_pubkeys = [b.pubkey for b in state.builders]
    if pubkey not in builder_pubkeys:
        # Verify the deposit signature (proof of possession) which is not checked by the deposit contract
        if is_valid_deposit_signature(pubkey, withdrawal_credentials, amount, signature):
            add_builder_to_registry(state, pubkey, withdrawal_credentials, amount)
    else:
        # Increase balance by deposit amount
        builder_index = builder_pubkeys.index(pubkey)
        state.builders[builder_index].balance += amount

process_deposit_request

def process_deposit_request(state: BeaconState, deposit_request: DepositRequest) -> None:
    # [New in Gloas:EIP7732]
    builder_pubkeys = [b.pubkey for b in state.builders]
    validator_pubkeys = [v.pubkey for v in state.validators]

    # [New in Gloas:EIP7732]
    # Regardless of the withdrawal credentials prefix, if a builder/validator
    # already exists with this pubkey, apply the deposit to their balance
    is_builder = deposit_request.pubkey in builder_pubkeys
    is_validator = deposit_request.pubkey in validator_pubkeys
    is_builder_prefix = is_builder_withdrawal_credential(deposit_request.withdrawal_credentials)
    if is_builder or (is_builder_prefix and not is_validator):
        # Apply builder deposits immediately
        apply_deposit_for_builder(
            state,
            deposit_request.pubkey,
            deposit_request.withdrawal_credentials,
            deposit_request.amount,
            deposit_request.signature,
        )
        return

    # Add validator deposits to the queue
    state.pending_deposits.append(
        PendingDeposit(
            pubkey=deposit_request.pubkey,
            withdrawal_credentials=deposit_request.withdrawal_credentials,
            amount=deposit_request.amount,
            signature=deposit_request.signature,
            slot=state.slot,
        )
    )

The paths for deposits requests is heavily modified in Gloas. Builders can only be onboarded by deposit requests and not deposits in the staking contract.

One invariant that we have and rely on is that no validator and builder can have the same pubkey at any given time.
When receiving a new deposit request, it can be for an existing builder, and exiting validator, a new builder or a new validator. The existing cases are simple to deal with, builders are applied immediately and validators are added to the pending queue. For pubkeys that are not in the exiting list of validators or builders, we distinguish with the withdrawal prefix. If it is for a 0x03 prefix we add a new builder. To enforce the above invariant, we transform any pending deposit for new validators at the Gloas fork to builders if their withdrawal credentials start with 0x03.

Notice that builders reuse the index. This may require careful caching of the pubkey->index map on clients.

Operations

process_operations

Note: process_operations is modified to process PTC attestations and removes calls to process_deposit_request, process_withdrawal_request, and process_consolidation_request.

def process_operations(state: BeaconState, body: BeaconBlockBody) -> None:
    # Disable former deposit mechanism once all prior deposits are processed
    eth1_deposit_index_limit = min(
        state.eth1_data.deposit_count, state.deposit_requests_start_index
    )
    if state.eth1_deposit_index < eth1_deposit_index_limit:
        assert len(body.deposits) == min(
            MAX_DEPOSITS, eth1_deposit_index_limit - state.eth1_deposit_index
        )
    else:
        assert len(body.deposits) == 0

    def for_ops(operations: Sequence[Any], fn: Callable[[BeaconState, Any], None]) -> None:
        for operation in operations:
            fn(state, operation)

    # [Modified in Gloas:EIP7732]
    for_ops(body.proposer_slashings, process_proposer_slashing)
    for_ops(body.attester_slashings, process_attester_slashing)
    # [Modified in Gloas:EIP7732]
    for_ops(body.attestations, process_attestation)
    for_ops(body.deposits, process_deposit)
    # [Modified in Gloas:EIP7732]
    for_ops(body.voluntary_exits, process_voluntary_exit)
    for_ops(body.bls_to_execution_changes, process_bls_to_execution_change)
    # [Modified in Gloas:EIP7732]
    # Removed `process_deposit_request`
    # [Modified in Gloas:EIP7732]
    # Removed `process_withdrawal_request`
    # [Modified in Gloas:EIP7732]
    # Removed `process_consolidation_request`
    # [New in Gloas:EIP7732]
    for_ops(body.payload_attestations, process_payload_attestation)

All requests are no longer processed with the beacon block as explained above, they are processed with the payload envelope in the second state transition in the slot. Additional modifications include proposer slashings (to remove any builder pending payment so as to prevent builder grievances via equivocations). Voluntary exits (to deal with builders exiting) and attestations (to deal with the modified head accounting and also to keep track of blocks’ weight to see if they have achieved quorum to force the builder’s payment). Additionally, we process payload attestations which is a new object in Gloas

Builder exits

process_voluntary_exit

def process_voluntary_exit(state: BeaconState, signed_voluntary_exit: SignedVoluntaryExit) -> None:
    voluntary_exit = signed_voluntary_exit.message
    domain = compute_domain(
        DOMAIN_VOLUNTARY_EXIT, CAPELLA_FORK_VERSION, state.genesis_validators_root
    )
    signing_root = compute_signing_root(voluntary_exit, domain)

    # Exits must specify an epoch when they become valid; they are not valid before then
    assert get_current_epoch(state) >= voluntary_exit.epoch

    # [New in Gloas:EIP7732]
    if is_builder_index(voluntary_exit.validator_index):
        builder_index = convert_validator_index_to_builder_index(voluntary_exit.validator_index)
        # Verify the builder is active
        assert is_active_builder(state, builder_index)
        # Only exit builder if it has no pending withdrawals in the queue
        assert get_pending_balance_to_withdraw_for_builder(state, builder_index) == 0
        # Verify signature
        pubkey = state.builders[builder_index].pubkey
        assert bls.Verify(pubkey, signing_root, signed_voluntary_exit.signature)
        # Initiate exit
        initiate_builder_exit(state, builder_index)
        return

    validator = state.validators[voluntary_exit.validator_index]
    # Verify the validator is active
    assert is_active_validator(validator, get_current_epoch(state))
    # Verify exit has not been initiated
    assert validator.exit_epoch == FAR_FUTURE_EPOCH
    # Verify the validator has been active long enough
    assert get_current_epoch(state) >= validator.activation_epoch + SHARD_COMMITTEE_PERIOD
    # Only exit validator if it has no pending withdrawals in the queue
    assert get_pending_balance_to_withdraw(state, voluntary_exit.validator_index) == 0
    # Verify signature
    assert bls.Verify(validator.pubkey, signing_root, signed_voluntary_exit.signature)
    # Initiate exit
    initiate_validator_exit(state, voluntary_exit.validator_index)

This is a simple modification to withdraw the builder if the signed exit corresponds to a builder. Tooling will be needed to generate these exits since the index needs to be passed as the index in the builder slice plus the marker 2^40. We only honor these exits if the builder does not have pending payments or withdrawals.

Notice that builders sweeps are really an edge case because of index reuse. As long as another builder has deposited on an exited builder, a deposit for the previous builder will generate a new builder with the old pubkey. So clients need to make certain to handle different indices being possible for the same pubkey. And we could probably get rid entirely of the builder sweep if we accept the edge case of an exited builder with some balance and just burn it on index reuse.

process_attestation

Note: The function is modified to track the weight for pending builder payments and to use the index field in the AttestationData to signal the payload availability.

def process_attestation(state: BeaconState, attestation: Attestation) -> 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

    # [Modified in Gloas:EIP7732]
    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 = set(
            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
    )

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

    # [Modified in Gloas:EIP7732]
    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):
        # [New in Gloas:EIP7732]
        # For same-slot attestations, check if we are setting any new flags.
        # If we are, this validator has not contributed to this slot's quorum yet.
        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)
                proposer_reward_numerator += get_base_reward(state, index) * weight
                # [New in Gloas:EIP7732]
                will_set_new_flag = True

        # [New in Gloas:EIP7732]
        # Add weight for same-slot attestations when any new flag is set.
        # This ensures each validator contributes exactly once per slot.
        if (
            will_set_new_flag
            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)

    # [New in Gloas:EIP7732]
    # 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

The only changes are in getting the corresponding builder pending payment for the attestation’s slot and add the weight of the attester to it if the attestation is for the same slot block as explained above. We add this weight only for attestations that get one of the participation flags. This is why the marker will_set_new_flag is used.

Payload attestations

process_payload_attestation

def process_payload_attestation(
    state: BeaconState, payload_attestation: PayloadAttestation
) -> None:
    data = payload_attestation.data

    # Check that the attestation is for the parent beacon block
    assert data.beacon_block_root == state.latest_block_header.parent_root
    # Check that the attestation is for the previous slot
    assert data.slot + 1 == state.slot
    # Verify signature
    indexed_payload_attestation = get_indexed_payload_attestation(state, payload_attestation)
    assert is_valid_indexed_payload_attestation(state, indexed_payload_attestation)

Processing of payload attestations is simple. Only PTC attestations for the previous slot are allowed, this is to help the proposer assert it’s view on reorging or building on top of the parent. The only check is that the beacon block root is for the parent block and that their signatures are valid. There is another helper that is called on forkchoice that we will cover in a separate annotated document. Notice that payload attestations are not rewarded nor subject to penalties if they are missed nor slashing for equivocations. On the one hand the complexity of adding a reward to be incentive aligned was not worth the benefits and on the other hand, any penalty, to be relevant, would have to be very large. We thus opted to not apply any rewards to these attestations. A first draft of the EIP took the PTC from the beacon committee and ignored attestations from these validators, thus they would be rewarded a full attestation if they cast the PTC one on time. But this made attestation processing much more complicated for no good reason.

Grieving the builder with equivocations

process_proposer_slashing

def process_proposer_slashing(state: BeaconState, proposer_slashing: ProposerSlashing) -> None:
    header_1 = proposer_slashing.signed_header_1.message
    header_2 = proposer_slashing.signed_header_2.message

    # Verify header slots match
    assert header_1.slot == header_2.slot
    # Verify header proposer indices match
    assert header_1.proposer_index == header_2.proposer_index
    # Verify the headers are different
    assert header_1 != header_2
    # Verify the proposer is slashable
    proposer = state.validators[header_1.proposer_index]
    assert is_slashable_validator(proposer, get_current_epoch(state))
    # Verify signatures
    for signed_header in (proposer_slashing.signed_header_1, proposer_slashing.signed_header_2):
        domain = get_domain(
            state, DOMAIN_BEACON_PROPOSER, compute_epoch_at_slot(signed_header.message.slot)
        )
        signing_root = compute_signing_root(signed_header.message, domain)
        assert bls.Verify(proposer.pubkey, signing_root, signed_header.signature)

    # [New in Gloas:EIP7732]
    # Remove the BuilderPendingPayment corresponding to
    # this proposal if it is still in the 2-epoch window.
    slot = header_1.slot
    proposal_epoch = compute_epoch_at_slot(slot)
    if proposal_epoch == get_current_epoch(state):
        payment_index = SLOTS_PER_EPOCH + slot % SLOTS_PER_EPOCH
        state.builder_pending_payments[payment_index] = BuilderPendingPayment()
    elif proposal_epoch == get_previous_epoch(state):
        payment_index = slot % SLOTS_PER_EPOCH
        state.builder_pending_payments[payment_index] = BuilderPendingPayment()

    slash_validator(state, header_1.proposer_index)

This modification is to prevent a proposer from forcing a builder to pay for a bid that they didn’t release because there was an equivocation (eg the builder could have one non-canonical block with a committed bid for a different builder, and the canonical block was an equivocation for his own bid). In this case we simply remove any pending payment for the builders associated to the slots in the slashing header.

Execution payload

verify_execution_payload_envelope_signature

def verify_execution_payload_envelope_signature(
    state: BeaconState, signed_envelope: SignedExecutionPayloadEnvelope
) -> bool:
    builder_index = signed_envelope.message.builder_index
    if builder_index == BUILDER_INDEX_SELF_BUILD:
        validator_index = state.latest_block_header.proposer_index
        pubkey = state.validators[validator_index].pubkey
    else:
        pubkey = state.builders[builder_index].pubkey

    signing_root = compute_signing_root(
        signed_envelope.message, get_domain(state, DOMAIN_BEACON_BUILDER)
    )
    return bls.Verify(pubkey, signing_root, signed_envelope.signature)

process_execution_payload

Note: process_execution_payload is now an independent check in state transition. It is called when importing a signed execution payload proposed by the builder of the current slot.

def process_execution_payload(
    state: BeaconState,
    # [Modified in Gloas:EIP7732]
    # Removed `body`
    # [New in Gloas:EIP7732]
    signed_envelope: SignedExecutionPayloadEnvelope,
    execution_engine: ExecutionEngine,
    # [New in Gloas:EIP7732]
    verify: bool = True,
) -> None:
    envelope = signed_envelope.message
    payload = envelope.payload

    # Verify signature
    if verify:
        assert verify_execution_payload_envelope_signature(state, signed_envelope)

    # Cache latest block header state root
    previous_state_root = hash_tree_root(state)
    if state.latest_block_header.state_root == Root():
        state.latest_block_header.state_root = previous_state_root

    # Verify consistency with the beacon block
    assert envelope.beacon_block_root == hash_tree_root(state.latest_block_header)
    assert envelope.slot == state.slot

    # Verify consistency with the committed bid
    committed_bid = state.latest_execution_payload_bid
    assert envelope.builder_index == committed_bid.builder_index
    assert committed_bid.blob_kzg_commitments_root == hash_tree_root(envelope.blob_kzg_commitments)
    assert committed_bid.prev_randao == payload.prev_randao

    # Verify consistency with expected withdrawals
    assert hash_tree_root(payload.withdrawals) == hash_tree_root(state.payload_expected_withdrawals)

    # Verify the gas_limit
    assert committed_bid.gas_limit == payload.gas_limit
    # Verify the block hash
    assert committed_bid.block_hash == payload.block_hash
    # Verify consistency of the parent hash with respect to the previous execution payload
    assert payload.parent_hash == state.latest_block_hash
    # Verify timestamp
    assert payload.timestamp == compute_time_at_slot(state, state.slot)
    # Verify commitments are under limit
    assert (
        len(envelope.blob_kzg_commitments)
        <= get_blob_parameters(get_current_epoch(state)).max_blobs_per_block
    )
    # Verify the execution payload is valid
    versioned_hashes = [
        kzg_commitment_to_versioned_hash(commitment) for commitment in envelope.blob_kzg_commitments
    ]
    requests = envelope.execution_requests
    assert execution_engine.verify_and_notify_new_payload(
        NewPayloadRequest(
            execution_payload=payload,
            versioned_hashes=versioned_hashes,
            parent_beacon_block_root=state.latest_block_header.parent_root,
            execution_requests=requests,
        )
    )

    def for_ops(operations: Sequence[Any], fn: Callable[[BeaconState, Any], None]) -> None:
        for operation in operations:
            fn(state, operation)

    for_ops(requests.deposits, process_deposit_request)
    for_ops(requests.withdrawals, process_withdrawal_request)
    for_ops(requests.consolidations, process_consolidation_request)

    # Queue the builder payment
    payment = state.builder_pending_payments[SLOTS_PER_EPOCH + state.slot % SLOTS_PER_EPOCH]
    amount = payment.withdrawal.amount
    if amount > 0:
        state.builder_pending_withdrawals.append(payment.withdrawal)
    state.builder_pending_payments[SLOTS_PER_EPOCH + state.slot % SLOTS_PER_EPOCH] = (
        BuilderPendingPayment()
    )

    # Cache the execution payload hash
    state.execution_payload_availability[state.slot % SLOTS_PER_HISTORICAL_ROOT] = 0b1
    state.latest_block_hash = payload.block_hash

    # Verify the state root
    if verify:
        assert envelope.state_root == hash_tree_root(state)

Execution payload processing is essentially the same as the previous fork. The only difference is that now this object is broadcast independently from the beacon block and it is broadcast in an ExecutionPayloadEnvelope. The builder index is used to signal if the proposer was self-building (passing BUILDER_INDEX_SELF_BUILD) to grab the proposer’s pubkey, otherwise we use the builder’s pubkey to validate the signature. There’s a chicken and egg on previous forks on when we set the state’s latest block header’s state root, this pattern is now also carried here. So we set the state root on processing the payload when the payload is present, or it will be set later in process_slot when the payload is missing. On processing the payload we remove the builder pending payment and immediately queue a builder pending withdrawal with the payment to the proposer. We cache the latest block hash processed that is useful to check if the parent is full and we set the long term payload availability bit.