Skip to main content
· 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

· 3 min read

Mitigating Supply Chain Attacks

Author:
Protocol Engineer, Technical Lead

I've heard people say that they would never trust software written in JavaScript because of the growing number of npm supply chain attacks. Kinda based, but also overly absolutist in an unproductive way.

Since we have a JS product, we take these concerns seriously and have steps in place to mitigate the risks (detailed below). With proper handling, JS products can be just as safe as those in other ecosystems.

What are Supply Chain Attacks

Basically, an attacker gains access to the publishing credentials for a highly-used package, and publishes a maliciously crafted version of the package. Any packages or products that transitively depend on this package may then unwittingly install the malware when they install their product or upgrade their dependencies. So it may be a dependency of a dependency of a dependency of a package which can poison the product.

These malware packages are often quickly noticed within a few days, but anyone caught installing these packages during that time will be affected.

These malware packages cause extreme damage, often targeting exfiltration of additional credentials, like cryptocurrency wallets or AWS access tokens. No bueno.

The vast majority of supply chain attacks occur via the npm package registry. Why? Probably because JavaScript remains one of the largest, most popular language ecosystems and package reuse is highly encouraged. There's billions of downloads from npm per week. So it's a juicy target.

What We Do

Use a Lockfile

So if npm supply chain attacks occur when a user upgrades a project dependencies, we have to make sure that all upgrades are audited and intentional. This starts with a project lockfile.

A lockfile is a snapshot of exact versions of all dependencies and transitive dependencies for a project. It ensures that only those exact versions specified in the lockfile will be installed.

Our code includes a lockfile, so checking out the code and installing dependencies will always install the pre-selected dependencies, and can never expose us to a new supply chain attack.

Audit Lockfile Changes

It's very important that every update to the lockfile is treated carefully, since updating the lockfile to a malicious version of a dependency can open up our developers or users to these supply chain attacks. We conservatively update the lockfile and don't update dependencies to newly-published versions proactively. We review diffs manually and use automated tooling to flag suspicious changes.

Enforce a Lockfile in Production

The problem with lockfiles is that they are limited to only apply within a local project. There's no way to fully enforce a lockfile from a published package. For this reason we emphatically warn against installing Lodestar via npm.

We only support and promote deployments which respect the lockfile.

  1. Build from source
  2. Prebuilt binary
  3. Prebuilt docker image

Switch to PNPM

We're switching to the pnpm package manager, which has been championing features to mitigate supply chain attacks. This should help us more robustly mitigate these attacks.

Minimize External Dependency Attack Surface

We intentionally are very selective about which dependencies we rely on, choosing to copy or vendor simple code rather than fully embracing the entire npm package ecosystem. Additionally, implementing our zig roadmap will further reduce the external dependencies we rely on.

· One min read

hello world

Author:
Protocol Engineer, Technical Lead

We're Lodestar, we've built an Ethereum Consensus client entirely in Typescript, and we've begun reinventing ourselves by rewriting the beacon node in Zig, for higher performance and broader usefulness.

This blog is an experiment in sharing our informal notes as we navigate the high seas of the Ethereum R&D process and share our engineering progress.

Enjoy.