This is the second post on the series annotating the Gloas fork. In this post we will go over validation of objects over gossip. These paths are typically not executable on the spec and leave more slack for clients to design their validation pipeline. For example, changing the order of validation can lead a client to ignore messages that other client would reject.

The structure of this post is different than the beacon chain one. We will break the post in sections for each object. Special care will be taken with respect to DataColumnSidecar because this validation is very likely to change.

My gratitude goes to Manu Nalepa for continuing to send corrections.

Attestations

The only change to attestations in Gloas is that the committee index is overloaded to indicate the payload content when attesting to an old block. We thus added the following two validations

  • [REJECT] aggregate.data.index < 2.
  • [REJECT] aggregate.data.index == 0 if block.slot == aggregate.data.slot.

enforcing that the index can be either 0 or 1 and it must be zero for same slot attestations.

The exact same changes are needed both in the aggregation and proof topics and the single attestations topics. Notice that we removed the Electra condition forcing this index to be zero.

Beacon Blocks

Beacon blocks in Gloas are much lighter because the payload is missing. Instead, they only commit to a future payload via a signed bid. Thus, all validations about the execution payload are removed. Some of the information of the execution payload is still available in the signed bid, so those validations are now on the bid.

  • If execution_payload verification of block’s execution payload parent by an execution node is complete:
    • [REJECT] The block’s execution payload parent (defined by bid.parent_block_hash) passes all validation.
  • [REJECT] The bid’s parent (defined by bid.parent_block_root) equals the block’s parent (defined by block.parent_root).

If we move full KZG commitments to the signed bid, then the following rule needs to be added to beacon block gossip validation:

  • [REJECT] The length of KZG commitments is less than or equal to the limitation defined in the consensus layer – i.e. validate that len(bid.blob_kzg_commitments) <= get_blob_parameters(get_current_epoch(state)).max_blobs_per_block

Execution Payload

Execution payload validation is now a gossip validation because the paylaod travels independently as a SignedExecutionPayloadEnvelope. The checks are as follows:

- _[IGNORE]_ The envelope's block root `envelope.block_root` has been seen (via
  gossip or non-gossip sources) (a client MAY queue payload for processing once
  the block is retrieved).

That is, we ignore payloads for blocks that we haven’t seen yet. In implementations this cache for pending payloads was actually hard to deal with, this is the reason why the slot was added to the payload envelope to make it simpler to identify which blocks we may be missing. The situation of the payload arriving earlier than the beacon block could happen in local environments for self-built blocks, that are broadcast jointly, if the payload is essentially empty.

- _[IGNORE]_ The node has not seen another valid
  `SignedExecutionPayloadEnvelope` for this block root from this builder.
- _[IGNORE]_ The envelope is from a slot greater than or equal to the latest
  finalized slot -- i.e. validate that
  `envelope.slot >= compute_start_slot_at_epoch(store.finalized_checkpoint.epoch)`

These are standard.

When we have seen the block with the given beacon block root, the following are validity conditions

- _[REJECT]_ `block` passes validation.
- _[REJECT]_ `block.slot` equals `envelope.slot`.
- _[REJECT]_ `envelope.builder_index == bid.builder_index`
- _[REJECT]_ `payload.block_hash == bid.block_hash`
- _[REJECT]_ `signed_execution_payload_envelope.signature` is valid with respect
  to the builder's public key.

The first one is to never accept payloads for invalid blocks and penalize peers that send those. The other three are there to make sure that the payload agrees with the committed bid, there are no checks with respect of parent blocks because those checks are done on processing the payload.

Notice that there could be equivocations on the builder side, execution requests for example are included in the envelope and are not checked. Nor even for length. Thus the condition on only forwarding one payload is necessary.

Proposer preferences

This topic is added so that proposers can broadcast information about their upcoming proposal slot to builders.

Proposers broadcast one epoch in advance (the lookahead is for two epochs), the following objects

class ProposerPreferences(Container):
    proposal_slot: Slot
    validator_index: ValidatorIndex
    fee_recipient: ExecutionAddress
    gas_limit: uint64
class SignedProposerPreferences(Container):
    message: ProposerPreferences
    signature: BLSSignature

These simply send the fee recipient and the gas limit as tweakable preferences, and the proposing slot expected for this proposer and the validator index that is used for signature verification. We could add other preferences like the min bid that the proposer would accept from an external builder and the such, we these were deemed premature, specially since they could break the p2p builder set from being a good fallback mechanism in case self-building is no longer possible.

The gossip validation in this topic is specified as

- _[IGNORE]_ `preferences.proposal_slot` is in the next epoch -- i.e.
  `compute_epoch_at_slot(preferences.proposal_slot) == get_current_epoch(state) + 1`.
- _[REJECT]_ `preferences.validator_index` is present at the correct slot in the
  next epoch's portion of `state.proposer_lookahead` -- i.e.
  `is_valid_proposal_slot(state, preferences)` returns `True`.
- _[IGNORE]_ The `signed_proposer_preferences` is the first valid message
  received from the validator with index `preferences.validator_index` and the given slot. 
- _[REJECT]_ `signed_proposer_preferences.signature` is valid with respect to
  the validator's public key.

We simply check that the proposer is indeed proposing in the given slot and that the proposal is in the next epoch, so that at the start of epoch N, proposers already find out that they will be proposing in N+1, they can start submitting these messages.

This topic is expected to have very few messages given that only 32 of them can possibly be valid.

I am assuming this PR will merge.

Payload bids

This topic is still not completely specified and may suffer many changes. Mostly to try to throttle and avoid DOS on a global topic in which any builder can send messages.

This topic should not be viewed as an auction. This topic serves as a fallback mechanism in case the centralized builder market is not working, is censoring (or being censored), etc. Thus, validations should err in protecting the network from DOS rather than adding rules to try to make it fair in the sense of allowing builders to effectively bid to win blocks in this p2p topic.

The rules at the time of writing this post are divided in two groups:

- _[IGNORE]_ `bid.slot` is the current slot or the next slot.
- _[IGNORE]_ the `SignedProposerPreferences` where `preferences.proposal_slot`
  is equal to `bid.slot` has been seen.
- _[REJECT]_ `bid.builder_index` is a valid/active builder index -- i.e.
  `is_active_builder(state, bid.builder_index)` returns `True`.
- _[REJECT]_ `bid.execution_payment` is zero.
- _[REJECT]_ `bid.fee_recipient` matches the `fee_recipient` from the proposer's
  `SignedProposerPreferences` associated with `bid.slot`.
- _[REJECT]_ `bid.gas_limit` matches the `gas_limit` from the proposer's
  `SignedProposerPreferences` associated with `bid.slot`.
- _[IGNORE]_ this is the first signed bid seen with a valid signature from the
  given builder for this slot.
- _[IGNORE]_ this bid is the highest value bid seen for the corresponding slot
  and the given parent block hash.
- _[IGNORE]_ `bid.value` is less or equal than the builder's excess balance --
  i.e. `can_builder_cover_bid(state, builder_index, amount)` returns `True`.
- _[IGNORE]_ `bid.parent_block_hash` is the block hash of a known execution
  payload in fork choice.
- _[IGNORE]_ `bid.parent_block_root` is the hash tree root of a known beacon
  block in fork choice.
- _[REJECT]_ `signed_execution_payload_bid.signature` is valid with respect to
  the `bid.builder_index`.

These are validation rules, while the DOS prevention is left as an implementation detail to clients:

*Note*: Implementations SHOULD include DoS prevention measures to mitigate spam
from malicious builders submitting numerous bids with minimal value increments.
Possible strategies include: (1) only forwarding bids that exceed the current
highest bid by a minimum threshold, or (2) forwarding only the highest observed
bid at regular time intervals.

The idea behind leaving this up to clients, is that as long as they implement reasonable defaults, and are different enough, these rules will be hard to game due to client diversity.

On the validation list we see that we only accept bids for the current and next slot. Vanilla clients are expected to submit bids as soon as they have executed the previous payload and built the next block, this could be very early in the previous slot. Some more sophisticated builders that still don’t want to open servers for direct connections may want to wait as much as possile and submit bids during the slot, after having seen previous bids in the p2p network.

Other validations are standard, they are mostly to check that the bid is compatible with the proposer’s preferences that must have been seen before. One of the problems is builder activation. Since builders will be onboarded from pending deposits only at the fork, it is very unlikely that they will be active right after the fork (their deposit needs to have been already finalized, this could happen if there was a large pending queue at the fork and the builder deposited two epochs in advance).

We check that the builder actually can afford the bid, in particular, we only allow trustless payments in this topic, including trusted ones would be useless as anyone could promise the maximum value. We only allow bids that build on top of known block. Notice that we do not make any checks with respect of the EL block hash that the bid is referring to. We could in principle enforce that the parent hash is known or corresponds to the parent block’s bid or the parent hash of the parent block. But these checks are done when processing and would be inherently slower to be carried out in gossip.

Notice that we are allowing a single message per builder in this topic. This rule is likely to change as some people believe it leads to builders being hurt by not being able to update their bid in an auction-style.

Blob data

The topic of data column sidecar validation is in flux. I’ll discuss here the design decisions both in the current status and how it may change due to this open issue.

The current validation mechanism

Currently, the signed bid includes a hiding commitment to the blob KZG commitments by including their hash instead of the full list. This commitment is currently useless and it is there mostly to avoid equivocations from the builder when sending the sidecars. The idea is that a data column sidecar is always expected to be received after the beacon block, but not necessarily after the payload envelope. Both data column sidecars and the payload envelope include the full list of KZG commitments. The reason for the former is that they can be validated regardless of having seen the payload or not. In fact, a single data column sidecar may be much smaller than the payload, and the builder may choose to start broadcasting them earlier than the payload. So it is highly expected that we will see them before the payload often. The payload envelope also includes them so that it can be verified independently of having seen a valid data column sidecar: the KZG commitments are needed to send the versioned hashes to the engine on payload validation. So the current approach requires that the beacon block has been seen to validate both payload envelope and data column sidecars. But these objects sent by the builder can be verified independently of seeing any of them first.

A proposed variation

It has been proposed to instead move the full list of KZG commitments instead of their hash to the signed bid. This allows this list to be removed from the payload and from every data column sidecar. So it would be included only once in the block, and it can be removed up to 129 times from the broadcast data in the slot.

What this mechanism would allow is for attesters of N+1, to start requesting blobs from the engine as soon as they see the beacon block for N, even before the builder for that payload has even started assembling the data column sidecars! This could potentially lower bandwith consumption considerably. Another big gain for clients is on simplicity of implementation since currently both for Fulu and since Deneb, these commitments are assumed to be in the beacon block body.

The cost of making this change is that signed bids go from 316 bytes to 3772 bytes with 72 blobs. This could potentially make the DOS prevention tactics on the p2p bidding mechanism more strict and could play a role by adding latency to the centralized builder auction with direct connections to proposers.

Notice that this also doesn’t change the need for the beacon block to have been seen when validating the data column sidecar. We can remove the KZG commitments from the sidecar because the sidecar has the beacon block root, and thus we can take the commitments from the included bid. Something similar happens with the payload envelope.

Merkle Inclusion proof

In principle the builder could include the same Merkle system on the data column sidecars that shows that the sidecars are committed in the beacon block tied with a proposer’s signature. This would make it so that the data column sidecars could be validated without having seen the beacon block. This could actually be a case that happens if the builder gets the block from the proposer in a direct connection and immediately broadcast data columns. Attesters for N+1 can receive these columns before getting the beacon block for N. However, since these attesters have an entire slot to validate data, it doesn’t make much sense to go with this inclusion mechanism and bloat bandwidth for no reason. Data column validation is no longer in the hot path to validation, only attesters for the next slot are interested and not the current slot’s attesters.

The actual specification

The structure is currently modified to be

class DataColumnSidecar(Container):
    index: ColumnIndex
    column: List[Cell, MAX_BLOB_COMMITMENTS_PER_BLOCK]
    kzg_commitments: List[KZGCommitment, MAX_BLOB_COMMITMENTS_PER_BLOCK]
    kzg_proofs: List[KZGProof, MAX_BLOB_COMMITMENTS_PER_BLOCK]
    # [Modified in Gloas:EIP7732]
    # Removed `signed_block_header`
    # [Modified in Gloas:EIP7732]
    # Removed `kzg_commitments_inclusion_proof`
    # [New in Gloas:EIP7732]
    slot: Slot
    # [New in Gloas:EIP7732]
    beacon_block_root: Root

If we do move KZG commitments to the bid, we would remove them from this structure. Notice that we have already removed the Merkle proofs for the reasons mentioned above.

The gossip validation is as follows

- _[IGNORE]_ The sidecar's `beacon_block_root` has been seen via a valid signed
  execution payload bid. A client MAY queue the sidecar for processing once the
  block is retrieved.
- _[REJECT]_ The sidecars's `slot` matches the slot of the block with root
  `beacon_block_root`.
- _[REJECT]_ The hash of the sidecar's `kzg_commitments` matches the
  `blob_kzg_commitments_root` in the corresponding builder's bid for
  `sidecar.beacon_block_root`.
- _[REJECT]_ The sidecar is valid as verified by
  `verify_data_column_sidecar(sidecar)`.
- _[REJECT]_ The sidecar is for the correct subnet -- i.e.
  `compute_subnet_for_data_column_sidecar(sidecar.index) == subnet_id`.
- _[REJECT]_ The sidecar's column data is valid as verified by
  `verify_data_column_sidecar_kzg_proofs(sidecar)`.
- _[IGNORE]_ The sidecar is the first sidecar for the tuple
  `(sidecar.beacon_block_root, sidecar.index)` with valid kzg proof.

So we explicitly require the beacon block to be known and that the kzg commiment hash matches the committed one in the bid. verify_data_column_sidecar is modified as follows

def verify_data_column_sidecar(sidecar: DataColumnSidecar) -> bool:
    """
    Verify if the data column sidecar is valid.
    """
    # The sidecar index must be within the valid range
    if sidecar.index >= NUMBER_OF_COLUMNS:
        return False

    # A sidecar for zero blobs is invalid
    if len(sidecar.kzg_commitments) == 0:
        return False

    # [Modified in Gloas:EIP7732]
    # Check that the sidecar respects the blob limit
    epoch = compute_epoch_at_slot(sidecar.slot)
    if len(sidecar.kzg_commitments) > get_blob_parameters(epoch).max_blobs_per_block:
        return False

    # The column length must be equal to the number of commitments/proofs
    if len(sidecar.column) != len(sidecar.kzg_commitments) or len(sidecar.column) != len(
        sidecar.kzg_proofs
    ):
        return False

    return True

We now need to check that the sidecar’s KZG commitments respect the blob limit using the sidecar’s slot and not the block header that is no longer present.

If we move to remove the KZG commitments from the sidecars as explained above, this helper will take them as an input, passed from the beacon block’s committed bid.