Part 3: Annotated Specification

Fork Choice

Bellatrix Fork Choice

Introduction

This section covers the additional Bellatrix fork choice document, v1.3.0. For a complementary take, see Vitalik's annotated Bellatrix fork choice (based on a slightly older version).

As usual, text with a side bar is quoted directly from the specification.

This is the modification of the fork choice according to the executable beacon chain proposal.

Note: It introduces the process of transition from the last PoW block to the first PoS block.

The "executable beacon chain proposal"1 is what became known as The Merge, and is specified by EIP-3675 together with the Bellatrix upgrade on the beacon chain.

Upgrades to Ethereum's protocol are normally planned to take place at pre-determined block heights. For security reasons, the Merge upgrade used a different trigger, specifically a terminal total difficulty of proof of work mining. The first proof of work block to reach that amount of accumulated difficulty became the last proof of work block: all subsequent execution blocks are now merged into the proof of stake beacon chain as execution payloads.

The only functional change to the fork choice that the Bellatrix upgrade introduced was about ensuring that a valid terminal proof of work block was picked up by the beacon chain at the point of the Merge. As such, this section is largely of only historical interest now.

The remainder of the material in this section (mostly Engine API related) isn't really relevant to the fork choice rule at all. It mainly describes one-way communication of fork choice decisions to the execution layer. Altogether, it's a bit of a weird collection of stuff, for want of a better place to put it I suppose.

Custom types

Name SSZ equivalent Description
PayloadId Bytes8 Identifier of a payload building process

PayloadId is used to keep track of stateful requests from the consensus client to the execution client. Specifically, the consensus client can ask the execution client to start creating a new execution payload via the notify_forkchoice_updated() command (which maps to the engine_forkchoiceUpdatedV1 RPC method in the Engine API docs). The execution client will return a PayloadId reference and continue to build the payload asynchronously. Later, the consensus client can obtain the payload with a call to the engine API's engine_getPayloadV1 method by passing it the same PayloadId.

Protocols

ExecutionEngine

Note: The notify_forkchoice_updated function is added to the ExecutionEngine protocol to signal the fork choice updates.

The body of this function is implementation dependent. The Engine API may be used to implement it with an external execution engine.

Post-Merge, every consensus client (beacon chain client) must be paired up with an execution client (ExecutionEngine; formerly, Eth1 client). The execution client has several roles.

  1. It validates execution payloads.
  2. It executes execution payloads in order to maintain Ethereum's state (accounts, contracts, balances, receipts, etc.).
  3. It provides data to applications via its RPC API.
  4. It maintains a mempool of transactions from which it builds execution payloads and provides them to the consensus layer for distribution.

The first and the last of these are the ones that interest us on the consensus side. The first role is important because beacon blocks are valid only if they contain valid execution payloads. The last is important because the consensus side does not directly handle ordinary Ethereum transactions and cannot build its own execution payloads.

The interface between the two sides is called the Engine API. The Engine API is the RPC (remote procedure call) interface that the execution client provides to its companion consensus client. It is one-way in the sense that the consensus client can call methods on the Engine API, but the execution client does not call any methods on the consensus client.

The most interesting methods that the Engine API provides are these three.

  • engine_newPayloadV1
    • When the consensus client receives a new beacon block, it extracts the block's execution payload and uses this method to send it to the execution client. The execution client will validate the payload and execute the transactions it contains. The method's return value indicates whether the payload was valid or not.
  • engine_forkchoiceUpdatedV1
    • The function below, notify_forkchoice_updated(), uses this method for two purposes. First, it is used routinely to update the execution client with the latest consensus information: head block, safe head block, and finalised block. Second, it can be used to prompt the execution client to begin building an execution payload from its mempool. The consensus client will do this when it is about to propose a beacon block.
  • engine_getPayloadV1
    • This is used to retrieve an execution payload previously requested via engine_forkchoiceUpdatedV1, using a PayloadId as a reference.
notify_forkchoice_updated

This function performs three actions atomically:

  • Re-organizes the execution payload chain and corresponding state to make head_block_hash the head.
  • Updates safe block hash with the value provided by safe_block_hash parameter.
  • Applies finality to the execution state: it irreversibly persists the chain of all execution payloads and corresponding state, up to and including finalized_block_hash.

Additionally, if payload_attributes is provided, this function sets in motion a payload build process on top of head_block_hash and returns an identifier of initiated process.

def notify_forkchoice_updated(self: ExecutionEngine,
                              head_block_hash: Hash32,
                              safe_block_hash: Hash32,
                              finalized_block_hash: Hash32,
                              payload_attributes: Optional[PayloadAttributes]) -> Optional[PayloadId]:
    ...

This is a wrapper around the Engine API's engine_forkchoiceUpdatedV1 RPC method as described above. We use it to keep the execution client up to date with the latest fork choice information, and (optionally) from time to time to request it to build a new execution payload for us.

Note: The (head_block_hash, finalized_block_hash) values of the notify_forkchoice_updated function call maps on the POS_FORKCHOICE_UPDATED event defined in the EIP-3675. As per EIP-3675, before a post-transition block is finalized, notify_forkchoice_updated MUST be called with finalized_block_hash = Hash32().

EIP-3675 is the specification of the Merge on the execution layer side (Eth1 side) of things. The POS_FORKCHOICE_UPDATED event described there is triggered by the consensus layer calling the Engine API's engine_forkchoiceUpdatedV1 method, which is in turn triggered by the consensus client calling notify_forkchoice_updated(). The consensus client will do this periodically, in particular whenever a reorg occurs on the beacon chain so that applications built on the execution layer can know which state is current.

Between the Merge and the first finalised epoch after the Merge there was no guarantee of finality on the execution chain, therefore we could not sent it a finalised block hash and had to use the placeholder default value instead.

Note: Client software MUST NOT call this function until the transition conditions are met on the PoW network, i.e. there exists a block for which is_valid_terminal_pow_block function returns True.

The proof of work chain was not interested in the proof of stake chain's view of the world until after the Merge.

Note: Client software MUST call this function to initiate the payload build process to produce the merge transition block; the head_block_hash parameter MUST be set to the hash of a terminal PoW block in this case.

The first beacon chain proposer after the terminal proof of work block had been detected would call notify_forkchoice_updated() with the payload_attributes parameter in order to request an execution payload to be build for the first merged block.

If there had been multiple candidate terminal PoW blocks (as there were for the Goerli testnet Merge), the beacon block proposer would have been free to choose which of them to ask its execution client to build on.

safe_block_hash

The safe_block_hash parameter MUST be set to return value of get_safe_execution_payload_hash(store: Store) function.

The "safe block" feature is a way for the consensus protocol to signal to the execution layer that a block is very unlikely ever to be reverted. Application developers could use the safe block information to provide better user experience to their users in the form of a pseudo fast-finality. See the later Safe Block section for more on this.

Helpers

PayloadAttributes

Used to signal to initiate the payload build process via notify_forkchoice_updated.

@dataclass
class PayloadAttributes(object):
    timestamp: uint64
    prev_randao: Bytes32
    suggested_fee_recipient: ExecutionAddress
    withdrawals: Sequence[Withdrawal]  # [New in Capella]

This class maps onto the Engine API's PayloadAttributesV2 class and is used when asking the execution client to start building an execution payload.

The prev_randao field is the beacon state's current RANDAO value, having been updated by the RANDAO reveal in the previous beacon block. It is made available to execution layer applications via the EVM's new PREVRANDAO opcode.

suggested_fee_recipient is the Ethereum account that any fee income from transaction tips should be sent to when the payload is executed (formerly known as the COINBASE). The execution client may override this if it has its own setting for fee recipient, hence "suggested". But allowing it to be set via the Engine API makes it possible for a beacon node hosting multiple validators to use a different fee recipient address for each validator, whereas setting it on the execution side would force them all to use the same fee recipient address.

The withdrawals field was added in the Capella upgrade. It allows the consensus layer to pass a list of withdrawals to the execution layer to include in an execution payload. There will be at most MAX_WITHDRAWALS_PER_PAYLOAD of them. When the block containing the payload is processed, for each withdrawal, the amount will be deducted from validator's balance on the beacon chain, and will be added to the balance of the Ethereum account in the Withdrawal object's ExecutionAddress field. The ExecutionAddress is derived from the validator's withdrawal credentials.

PowBlock

class PowBlock(Container):
    block_hash: Hash32
    parent_hash: Hash32
    total_difficulty: uint256

This class is just a succinct way to wrap up the information we need for checking proof of work blocks around the Merge. It is returned by get_pow_block() and consumed by is_valid_terminal_pow_block().

get_pow_block

Let get_pow_block(block_hash: Hash32) -> Optional[PowBlock] be the function that given the hash of the PoW block returns its data. It may result in None if the requested block is not yet available.

Note: The eth_getBlockByHash JSON-RPC method may be used to pull this information from an execution client.

As noted, get_pow_block() is a wrapper around Ethereum's eth_getBlockByHash JSON-RPC method. Given a block hash (not its hash tree root! - Eth1 blocks are encoded with RLP rather than SSZ), it returns the information in the PowBlock structure.

eth_getBlockByHash is a standard Eth1 client RPC method rather than a specific Engine API method. For convenience, execution clients often provide access to this method via the Engine API port in addition to the standard RPC API port so that consensus clients can be configured to connect to only one port on the execution client.

is_valid_terminal_pow_block

Used by fork-choice handler, on_block.

def is_valid_terminal_pow_block(block: PowBlock, parent: PowBlock) -> bool:
    is_total_difficulty_reached = block.total_difficulty >= TERMINAL_TOTAL_DIFFICULTY
    is_parent_total_difficulty_valid = parent.total_difficulty < TERMINAL_TOTAL_DIFFICULTY
    return is_total_difficulty_reached and is_parent_total_difficulty_valid

Given two PowBlock objects (corresponding to a proof of work block and its parent proof of work block respectively), this function checks whether the block meets the criteria for being the terminal proof of work block. That is, that its total difficulty exceeds the terminal total difficulty and that its parent's total difficulty does not.

validate_merge_block

def validate_merge_block(block: BeaconBlock) -> None:
    """
    Check the parent PoW block of execution payload is a valid terminal PoW block.

    Note: Unavailable PoW block(s) may later become available,
    and a client software MAY delay a call to ``validate_merge_block``
    until the PoW block(s) become available.
    """
    if TERMINAL_BLOCK_HASH != Hash32():
        # If `TERMINAL_BLOCK_HASH` is used as an override, the activation epoch must be reached.
        assert compute_epoch_at_slot(block.slot) >= TERMINAL_BLOCK_HASH_ACTIVATION_EPOCH
        assert block.body.execution_payload.parent_hash == TERMINAL_BLOCK_HASH
        return

    pow_block = get_pow_block(block.body.execution_payload.parent_hash)
    # Check if `pow_block` is available
    assert pow_block is not None
    pow_parent = get_pow_block(pow_block.parent_hash)
    # Check if `pow_parent` is available
    assert pow_parent is not None
    # Check if `pow_block` is a valid terminal PoW block
    assert is_valid_terminal_pow_block(pow_block, pow_parent)

This is used by the Bellatrix on_block() handler. The block parameter is a beacon block that claims to be the first merged block. That is, it is the first beacon block (on the current branch) to contain a non-default ExecutionPayload.

The TERMINAL_BLOCK_HASH is a parameter that client operators could have agreed to use to override the terminal total difficulty mechanism if necessary. For example, if the Merge had resulted in beacon chain forks they could have been resolved by manually agreeing an Eth1 Merge block and setting TERMINAL_BLOCK_HASH to its value via client command line parameters. In the event, this was not needed and TERMINAL_BLOCK_HASH remains at its default value of Hash32().

The remainder of the function checks, (a) that the PoW block that's the parent of the execution payload exists, and has total difficulty greater than the TERMINAL_TOTAL_DIFFICULTY, and (b) that the parent of that block exists and has a total difficulty less than the TERMINAL_TOTAL_DIFFICULTY. (The difficulty checks are performed in is_valid_terminal_pow_block().)

A diagram showing the relationship between the merge block and the terminal proof of work block.

The first beacon chain merged block contains the execution payload whose parent PoW block was the terminal PoW block. The terminal PoW block is the first PoW block to have a total difficulty exceeding the TERMINAL_TOTAL_DIFFICULTY.

The parent and grandparent PoW blocks are retrieved via the get_pow_block() function, which in practice involves making RPC calls to the attached Eth1/execution client. If either of these calls fails, an assert will be triggered, and the on_block() handler will bail out without making any changes.

Updated fork-choice handlers

on_block

Note: The only modification is the addition of the verification of transition block conditions.

def on_block(store: Store, signed_block: SignedBeaconBlock) -> None:
    """
    Run ``on_block`` upon receiving a new block.

    A block that is asserted as invalid due to unavailable PoW block may be valid at a later time,
    consider scheduling it for later processing in such case.
    """
    block = signed_block.message
    # Parent block must be known
    assert block.parent_root in store.block_states
    # Make a copy of the state to avoid mutability issues
    pre_state = copy(store.block_states[block.parent_root])
    # Blocks cannot be in the future. If they are, their consideration must be delayed until they are in the past.
    assert get_current_slot(store) >= block.slot

    # Check that block is later than the finalized epoch slot (optimization to reduce calls to get_ancestor)
    finalized_slot = compute_start_slot_at_epoch(store.finalized_checkpoint.epoch)
    assert block.slot > finalized_slot
    # Check block is a descendant of the finalized block at the checkpoint finalized slot
    assert get_ancestor(store, block.parent_root, finalized_slot) == store.finalized_checkpoint.root

    # Check the block is valid and compute the post-state
    state = pre_state.copy()
    block_root = hash_tree_root(block)
    state_transition(state, signed_block, True)

    # [New in Bellatrix]
    if is_merge_transition_block(pre_state, block.body):
        validate_merge_block(block)

    # Add new block to the store
    store.blocks[block_root] = block
    # Add new state for this block to the store
    store.block_states[block_root] = state

    # Add proposer score boost if the block is timely
    time_into_slot = (store.time - store.genesis_time) % SECONDS_PER_SLOT
    is_before_attesting_interval = time_into_slot < SECONDS_PER_SLOT // INTERVALS_PER_SLOT
    if get_current_slot(store) == block.slot and is_before_attesting_interval:
        store.proposer_boost_root = hash_tree_root(block)

    # Update checkpoints in store if necessary
    update_checkpoints(store, state.current_justified_checkpoint, state.finalized_checkpoint)

    # Eagerly compute unrealized justification and finality.
    compute_pulled_up_tip(store, block_root)

As noted, the only addition here to the normal on_block() handler is the lines,

    # [New in Bellatrix]
    if is_merge_transition_block(pre_state, block.body):
        validate_merge_block(block)

The is_merge_transition_block() function will return True when the given block is the first beacon block that contains an execution payload, and False otherwise.

To ensure consistency between the execution chain and the beacon chain at the Merge, this first merged beacon block requires some extra processing. We must check that the PoW block its execution payload is derived from has indeed met the criteria for the merge. Essentially, its total difficulty must exceed the terminal total difficulty and its parent's total difficulty must not. If this test fails then something has gone wrong and the beacon block must be excluded from the fork choice.

There might be several candidate execution blocks that meet this criterion in the event of PoW forks at the point of the Merge – this occurred when merging one of the testnets2 – but that's fine. The proposer of the first merged beacon block3 that becomes canonical gets to decide which terminal execution block wins.


  1. This name comes from Mikhail Kalinin's original article on Ethresear.ch.
  2. And triggered an issue with some client implementations.
  3. For the record, the first merged beacon block on mainnet was at slot 4700013.

Created by Ben Edgington. Licensed under CC BY-SA 4.0. Published 2023-09-29 14:16 UTC. Commit ebfcf50.