Skip to main content

One post tagged with "Gloas"

View All Tags
· 7 min read

Overview of Fork Choice in Gloas

Author:
Protocol Engineer

Fork choice in Gloas is modified to account for scenarios where a builder does not reveal the execution payload. In a beacon node, we must determine the effective payload status of each block. As a result, fork choice adopts voting over (root, payload_status) rather than over block roots alone.

At a high level, we collect attestation votes for block N. Based on the contents of block N, we can derive how many supporting votes apply to (parent_root, FULL) and (parent_root, EMPTY), where parent_root = block.parent_root. By combining supporting votes, local payload availability, and PTC votes, the fork choice weight calculation is modified, which in turn affects head selection.

Specification

Useful Resources

Container

In pre-Gloas fork choice, the block tree is conceptualized as a tree of blocks, where each node is represented solely by its block root. In post-Gloas fork choice, the tree consists of ForkChoiceNodes, each of which contains both a beacon block root and a PayloadStatus. As a result, function signatures such as get_head, get_ancestor, and get_weight are updated accordingly.

The fork choice store Store introduces two additional fields: • execution_payload_states: a dictionary mapping beacon block roots to post-states after process_execution_payload • ptc_vote: a dictionary mapping beacon block roots to a boolean vector of length PTC_SIZE, where true indicates payload present and false indicates payload absent

execution_payload_states serves several purposes. First, it provides the post-state of the previous slot when importing a new block in on_block, allowing state transition to proceed correctly. Second, it indicates local payload availability: if an execution payload state exists for a given root, the node has locally observed the execution payload. Otherwise it has not. A more subtle role is that it participates in payload status tie-breaking, which is discussed later.

ptc_vote represents votes from the Payload Timeliness Committee. It comes from block.body.payload_attestations of importing blocks. It influences the head block decision to see whether we should prefer extending payload or not through the tie-breaking logic.

Note that Store.block_states now stores the intermediate state after the beacon block state transition and before process_execution_payload. If the execution payload is missing, this intermediate state is used as the post-state when importing subsequent blocks.

Decision Making

In the spec, we have a new type named PayloadStatus which can be FULL, EMPTY or PENDING. But notice that our fork choice store doesn't store any payload status or ForkChoiceNode. This is because the payload statuses (inside ForkChoiceNodes) are dynamically generated during node tree construction when determining head block. PENDING means the status of the payload is not yet determined which means the associated node is the leaf of the tree. One confusing part is PENDING is also used as a stub or dummy value when determining head block.

If we look at get_head(), we see we start with our justified checkpoint node and we assign PENDING to the node's status.

def get_head(store: Store) -> ForkChoiceNode:
# Get filtered block tree that only includes viable branches
blocks = get_filtered_block_tree(store)
# Execute the LMD-GHOST fork-choice
head = ForkChoiceNode(
root=store.justified_checkpoint.root,
payload_status=PAYLOAD_STATUS_PENDING,
)

while True:
children = get_node_children(store, blocks, head)
if len(children) == 0:
return head
# Sort by latest attesting balance with ties broken lexicographically
head = max(
children,
key=lambda child: (
get_weight(store, child),
child.root,
get_payload_status_tiebreaker(store, child),
),
)

In get_node_children, there are two branches. If node is PENDING, add a node with the same block root and EMPTY as node's child, and add a node with the same block root and FULL as node's child if we have observed the execution payload. If node is not PENDING, then we search for child blocks that build on this block root like the status quo, but we also add PENDING as the stub status. So this get_head() algorithm is modified to oscillate between PENDING -> EMPTY/FULL -> PENDING -> EMPTY/FULL until we reach the leaves of the tree.

def get_node_children(
store: Store, blocks: Dict[Root, BeaconBlock], node: ForkChoiceNode
) -> Sequence[ForkChoiceNode]:
if node.payload_status == PAYLOAD_STATUS_PENDING:
children = [ForkChoiceNode(root=node.root, payload_status=PAYLOAD_STATUS_EMPTY)]
if node.root in store.execution_payload_states:
children.append(ForkChoiceNode(root=node.root, payload_status=PAYLOAD_STATUS_FULL))
return children
else:
return [
ForkChoiceNode(root=root, payload_status=PAYLOAD_STATUS_PENDING)
for root in blocks.keys()
if (
blocks[root].parent_root == node.root
and node.payload_status == get_parent_payload_status(store, blocks[root])
)
]

Below is an illustration of the Gloas node tree, adapted in a style similar to eth2book:

gloas-fork-choice-tree

Here, ps denotes payload status and pps denotes parent payload status. The winning path in this example is A → C → E, and get_weight(store, (A, PENDING)) evaluates to 215.

Observations

Observation 1: Each non-leaf PENDING node can only point to a parent node that is EMPTY or FULL. This is because each PENDING node's block has parent_root, to know which root it should point to, and get_parent_payload_status() determines which payload status it should point to.

Observation 2: Each non-leaf PENDING node inherits sum of the weights of its two (sometimes one) children ie. (root, EMPTY) and (root, FULL).

Observation 3: FULL node is constructed only if payload is locally available. In other words, all descendants of a FULL node will not be considered when it comes to beacon node's validator casting attestation or proposing a block if the payload is not locally available.

The get_weight() function is modified to count votes supporting a given (root, payload_status) via the helper is_supporting_vote().

Miscellaneous

Skipped Slot

The LatestMessage container has a new field named payload_present to indicate whether the message supports FULL or EMPTY node of the given root. Although this field is always present, it is not used when the message is voting for a PENDING node in the current slot. Rather, it is used when the message is voting for FULL/EMPTY node in previous slot. In beacon node's term, this field does not matter when an attestation is voting for a head block that is in the current slot, because we do not know whether the payload will be revealed yet. PTC will fulfilled the role of indicate the payload status. But if an attestation is voting for a head block in previous slot, it indicates there is a skipped slot. Since PTC votes reside in the beacon block, missed slot means we cannot retrieve these PTC votes, and we now rely on attestation.data.index which is converted into LatestMessage.payload_present to indicate the payload status.

def is_supporting_vote(store: Store, node: ForkChoiceNode, message: LatestMessage) -> bool:
"""
Returns whether a vote for ``message.root`` supports the chain containing the beacon block ``node.root`` with the
payload contents indicated by ``node.payload_status`` as head during slot ``node.slot``.
"""
block = store.blocks[node.root]
if node.root == message.root:
if node.payload_status == PAYLOAD_STATUS_PENDING:
return True
if message.slot <= block.slot:
return False
if message.payload_present:
return node.payload_status == PAYLOAD_STATUS_FULL
else:
return node.payload_status == PAYLOAD_STATUS_EMPTY
else:
ancestor = get_ancestor(store, message.root, block.slot)
return node.root == ancestor.root and (
node.payload_status == PAYLOAD_STATUS_PENDING
or node.payload_status == ancestor.payload_status
)

Notice that if message.slot > block.slot, the message is a supporting vote for FULL or EMPTY. If message.slot == block.slot then it will only support PENDING.

Usually votes for FULL or EMPTY are not as significant as PENDING votes because we can rely on the following PENDING node's block hash to see if it is building on FULL or EMPTY to derive that information.

Tiebreaker

In the event there are two nodes in node tree that have the same amount of weight and same root, think of two nodes (root, EMPTY) and (root, FULL) having the same weight and root, a new tiebreaker is introduced.

def get_payload_status_tiebreaker(store: Store, node: ForkChoiceNode) -> uint8:
if node.payload_status == PAYLOAD_STATUS_PENDING or store.blocks[
node.root
].slot + 1 != get_current_slot(store):
return node.payload_status
else:
# To decide on a payload from the previous slot, choose
# between FULL and EMPTY based on `should_extend_payload`
if node.payload_status == PAYLOAD_STATUS_EMPTY:
return 1
else:
return 2 if should_extend_payload(store, node.root) else 0
def should_extend_payload(store: Store, root: Root) -> bool:
proposer_root = store.proposer_boost_root
return (
is_payload_timely(store, root)
or proposer_root == Root()
or store.blocks[proposer_root].parent_root != root
or is_parent_node_full(store, store.blocks[proposer_root])
)

The high level concept is we prefer the node that is FULL with sufficiently evidence (see should_extend_payload, involves ptc_vote and execution_payload_states) > EMPTY node > Others