Part 3: Annotated Specification
Bellatrix Fork Choice
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.
||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
notify_forkchoice_updatedfunction is added to the
ExecutionEngineprotocol 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.
- It validates execution payloads.
- It executes execution payloads in order to maintain Ethereum's state (accounts, contracts, balances, receipts, etc.).
- It provides data to applications via its RPC API.
- 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.
- 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.
- 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.
- The function below,
- This is used to retrieve an execution payload previously requested via
engine_forkchoiceUpdatedV1, using a
PayloadIdas a reference.
- This is used to retrieve an execution payload previously requested via
This function performs three actions atomically:
- Re-organizes the execution payload chain and corresponding state to make
- Updates safe block hash with the value provided by
- Applies finality to the execution state: it irreversibly persists the chain of all execution payloads and corresponding state, up to and including
payload_attributesis provided, this function sets in motion a payload build process on top of
head_block_hashand 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.
(head_block_hash, finalized_block_hash)values of the
notify_forkchoice_updatedfunction call maps on the
POS_FORKCHOICE_UPDATEDevent defined in the EIP-3675. As per EIP-3675, before a post-transition block is finalized,
notify_forkchoice_updatedMUST 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
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_hashparameter 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_hashparameter MUST be set to return value of
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.
Used to signal to initiate the payload build process via
@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.
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
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.
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
ExecutionAddress field. The
ExecutionAddress is derived from the validator's withdrawal credentials.
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
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
Noneif the requested block is not yet available.
eth_getBlockByHashJSON-RPC method may be used to pull this information from an execution client.
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
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.
Used by fork-choice handler,
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
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.
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
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
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
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
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)
is_merge_transition_block() function will return
True when the given block is the first beacon block that contains an execution payload, and
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.
- This name comes from Mikhail Kalinin's original article on Ethresear.ch.↩
- And triggered an issue with some client implementations.↩
- For the record, the first merged beacon block on mainnet was at slot 4700013.↩