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
BuilderPendingWithdrawalis immediately added to the beacon state. - If no payload has been processed until the end of the next epoch, at epoch transition the committed
BuilderPendingPaymentis processed, theweightparameter 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_BPSdeadline 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.
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
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.
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.
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.
