450 lines
19 KiB
Markdown
450 lines
19 KiB
Markdown
# Phase 0 -- Beacon Chain Fork Choice
|
|
|
|
## Table of contents
|
|
<!-- TOC -->
|
|
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
|
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
|
|
|
- [Introduction](#introduction)
|
|
- [Fork choice](#fork-choice)
|
|
- [Preset](#preset)
|
|
- [Configuration](#configuration)
|
|
- [Helpers](#helpers)
|
|
- [`LatestMessage`](#latestmessage)
|
|
- [`Store`](#store)
|
|
- [`get_forkchoice_store`](#get_forkchoice_store)
|
|
- [`get_slots_since_genesis`](#get_slots_since_genesis)
|
|
- [`get_current_slot`](#get_current_slot)
|
|
- [`compute_slots_since_epoch_start`](#compute_slots_since_epoch_start)
|
|
- [`get_ancestor`](#get_ancestor)
|
|
- [`get_latest_attesting_balance`](#get_latest_attesting_balance)
|
|
- [`filter_block_tree`](#filter_block_tree)
|
|
- [`get_filtered_block_tree`](#get_filtered_block_tree)
|
|
- [`get_head`](#get_head)
|
|
- [`should_update_justified_checkpoint`](#should_update_justified_checkpoint)
|
|
- [`on_attestation` helpers](#on_attestation-helpers)
|
|
- [`validate_target_epoch_against_current_time`](#validate_target_epoch_against_current_time)
|
|
- [`validate_on_attestation`](#validate_on_attestation)
|
|
- [`store_target_checkpoint_state`](#store_target_checkpoint_state)
|
|
- [`update_latest_messages`](#update_latest_messages)
|
|
- [Handlers](#handlers)
|
|
- [`on_tick`](#on_tick)
|
|
- [`on_block`](#on_block)
|
|
- [`on_attestation`](#on_attestation)
|
|
|
|
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
|
<!-- /TOC -->
|
|
|
|
## Introduction
|
|
|
|
This document is the beacon chain fork choice spec, part of Phase 0. It assumes the [beacon chain state transition function spec](./beacon-chain.md).
|
|
|
|
## Fork choice
|
|
|
|
The head block root associated with a `store` is defined as `get_head(store)`. At genesis, let `store = get_forkchoice_store(genesis_state)` and update `store` by running:
|
|
|
|
- `on_tick(store, time)` whenever `time > store.time` where `time` is the current Unix time
|
|
- `on_block(store, block)` whenever a block `block: SignedBeaconBlock` is received
|
|
- `on_attestation(store, attestation)` whenever an attestation `attestation` is received
|
|
|
|
Any of the above handlers that trigger an unhandled exception (e.g. a failed assert or an out-of-range list access) are considered invalid. Invalid calls to handlers must not modify `store`.
|
|
|
|
*Notes*:
|
|
|
|
1) **Leap seconds**: Slots will last `SECONDS_PER_SLOT + 1` or `SECONDS_PER_SLOT - 1` seconds around leap seconds. This is automatically handled by [UNIX time](https://en.wikipedia.org/wiki/Unix_time).
|
|
2) **Honest clocks**: Honest nodes are assumed to have clocks synchronized within `SECONDS_PER_SLOT` seconds of each other.
|
|
3) **Eth1 data**: The large `ETH1_FOLLOW_DISTANCE` specified in the [honest validator document](./validator.md) should ensure that `state.latest_eth1_data` of the canonical beacon chain remains consistent with the canonical Ethereum proof-of-work chain. If not, emergency manual intervention will be required.
|
|
4) **Manual forks**: Manual forks may arbitrarily change the fork choice rule but are expected to be enacted at epoch transitions, with the fork details reflected in `state.fork`.
|
|
5) **Implementation**: The implementation found in this specification is constructed for ease of understanding rather than for optimization in computation, space, or any other resource. A number of optimized alternatives can be found [here](https://github.com/protolambda/lmd-ghost).
|
|
|
|
### Preset
|
|
|
|
| Name | Value | Unit | Duration |
|
|
| - | - | :-: | :-: |
|
|
| `SAFE_SLOTS_TO_UPDATE_JUSTIFIED` | `2**3` (= 8) | slots | 96 seconds |
|
|
| `ATTESTATION_OFFSET_QUOTIENT` | `3` | - | - |
|
|
|
|
### Configuration
|
|
|
|
| Name | Value |
|
|
| - | - |
|
|
| `PROPOSER_SCORE_BOOST_QUOTIENT` | `4` |
|
|
|
|
### Helpers
|
|
|
|
#### `LatestMessage`
|
|
|
|
```python
|
|
@dataclass(eq=True, frozen=True)
|
|
class LatestMessage(object):
|
|
epoch: Epoch
|
|
root: Root
|
|
```
|
|
|
|
#### `Store`
|
|
|
|
```python
|
|
@dataclass
|
|
class Store(object):
|
|
time: uint64
|
|
genesis_time: uint64
|
|
justified_checkpoint: Checkpoint
|
|
finalized_checkpoint: Checkpoint
|
|
best_justified_checkpoint: Checkpoint
|
|
proposer_score_boost: LatestMessage
|
|
blocks: Dict[Root, BeaconBlock] = field(default_factory=dict)
|
|
block_states: Dict[Root, BeaconState] = field(default_factory=dict)
|
|
checkpoint_states: Dict[Checkpoint, BeaconState] = field(default_factory=dict)
|
|
latest_messages: Dict[ValidatorIndex, LatestMessage] = field(default_factory=dict)
|
|
```
|
|
|
|
#### `get_forkchoice_store`
|
|
|
|
The provided anchor-state will be regarded as a trusted state, to not roll back beyond.
|
|
This should be the genesis state for a full client.
|
|
|
|
*Note* With regards to fork choice, block headers are interchangeable with blocks. The spec is likely to move to headers for reduced overhead in test vectors and better encapsulation. Full implementations store blocks as part of their database and will often use full blocks when dealing with production fork choice.
|
|
|
|
```python
|
|
def get_forkchoice_store(anchor_state: BeaconState, anchor_block: BeaconBlock) -> Store:
|
|
assert anchor_block.state_root == hash_tree_root(anchor_state)
|
|
anchor_root = hash_tree_root(anchor_block)
|
|
anchor_epoch = get_current_epoch(anchor_state)
|
|
justified_checkpoint = Checkpoint(epoch=anchor_epoch, root=anchor_root)
|
|
finalized_checkpoint = Checkpoint(epoch=anchor_epoch, root=anchor_root)
|
|
proposer_score_boost = LatestMessage(root=Root(), epoch=Epoch(0))
|
|
return Store(
|
|
time=uint64(anchor_state.genesis_time + SECONDS_PER_SLOT * anchor_state.slot),
|
|
genesis_time=anchor_state.genesis_time,
|
|
justified_checkpoint=justified_checkpoint,
|
|
finalized_checkpoint=finalized_checkpoint,
|
|
best_justified_checkpoint=justified_checkpoint,
|
|
proposer_score_boost=proposer_score_boost,
|
|
blocks={anchor_root: copy(anchor_block)},
|
|
block_states={anchor_root: copy(anchor_state)},
|
|
checkpoint_states={justified_checkpoint: copy(anchor_state)},
|
|
)
|
|
```
|
|
|
|
#### `get_slots_since_genesis`
|
|
|
|
```python
|
|
def get_slots_since_genesis(store: Store) -> int:
|
|
return (store.time - store.genesis_time) // SECONDS_PER_SLOT
|
|
```
|
|
|
|
#### `get_current_slot`
|
|
|
|
```python
|
|
def get_current_slot(store: Store) -> Slot:
|
|
return Slot(GENESIS_SLOT + get_slots_since_genesis(store))
|
|
```
|
|
|
|
#### `compute_slots_since_epoch_start`
|
|
|
|
```python
|
|
def compute_slots_since_epoch_start(slot: Slot) -> int:
|
|
return slot - compute_start_slot_at_epoch(compute_epoch_at_slot(slot))
|
|
```
|
|
|
|
#### `get_ancestor`
|
|
|
|
```python
|
|
def get_ancestor(store: Store, root: Root, slot: Slot) -> Root:
|
|
block = store.blocks[root]
|
|
if block.slot > slot:
|
|
return get_ancestor(store, block.parent_root, slot)
|
|
elif block.slot == slot:
|
|
return root
|
|
else:
|
|
# root is older than queried slot, thus a skip slot. Return most recent root prior to slot
|
|
return root
|
|
```
|
|
|
|
#### `get_latest_attesting_balance`
|
|
|
|
```python
|
|
def get_latest_attesting_balance(store: Store, root: Root) -> Gwei:
|
|
state = store.checkpoint_states[store.justified_checkpoint]
|
|
active_indices = get_active_validator_indices(state, get_current_epoch(state))
|
|
attestation_score = Gwei(sum(
|
|
state.validators[i].effective_balance for i in active_indices
|
|
if (i in store.latest_messages
|
|
and get_ancestor(store, store.latest_messages[i].root, store.blocks[root].slot) == root)
|
|
))
|
|
proposer_score = Gwei(0)
|
|
if store.proposer_score_boost.root != Root():
|
|
block_slot = store.blocks[root].slot
|
|
if get_ancestor(store, root, block_slot) == store.proposer_score_boost.root:
|
|
num_validators = len(get_active_validator_indices(state, get_current_epoch(state)))
|
|
avg_balance = get_total_active_balance(state) // num_validators
|
|
block_epoch = compute_epoch_at_slot(block_slot)
|
|
committee_size = get_committee_count_per_slot(state, block_epoch) * TARGET_COMMITTEE_SIZE
|
|
committee_weight = committee_size * avg_balance
|
|
proposer_score = committee_weight // PROPOSER_SCORE_BOOST_QUOTIENT
|
|
return attestation_score + proposer_score
|
|
|
|
```
|
|
|
|
#### `filter_block_tree`
|
|
|
|
```python
|
|
def filter_block_tree(store: Store, block_root: Root, blocks: Dict[Root, BeaconBlock]) -> bool:
|
|
block = store.blocks[block_root]
|
|
children = [
|
|
root for root in store.blocks.keys()
|
|
if store.blocks[root].parent_root == block_root
|
|
]
|
|
|
|
# If any children branches contain expected finalized/justified checkpoints,
|
|
# add to filtered block-tree and signal viability to parent.
|
|
if any(children):
|
|
filter_block_tree_result = [filter_block_tree(store, child, blocks) for child in children]
|
|
if any(filter_block_tree_result):
|
|
blocks[block_root] = block
|
|
return True
|
|
return False
|
|
|
|
# If leaf block, check finalized/justified checkpoints as matching latest.
|
|
head_state = store.block_states[block_root]
|
|
|
|
correct_justified = (
|
|
store.justified_checkpoint.epoch == GENESIS_EPOCH
|
|
or head_state.current_justified_checkpoint == store.justified_checkpoint
|
|
)
|
|
correct_finalized = (
|
|
store.finalized_checkpoint.epoch == GENESIS_EPOCH
|
|
or head_state.finalized_checkpoint == store.finalized_checkpoint
|
|
)
|
|
# If expected finalized/justified, add to viable block-tree and signal viability to parent.
|
|
if correct_justified and correct_finalized:
|
|
blocks[block_root] = block
|
|
return True
|
|
|
|
# Otherwise, branch not viable
|
|
return False
|
|
```
|
|
|
|
#### `get_filtered_block_tree`
|
|
|
|
```python
|
|
def get_filtered_block_tree(store: Store) -> Dict[Root, BeaconBlock]:
|
|
"""
|
|
Retrieve a filtered block tree from ``store``, only returning branches
|
|
whose leaf state's justified/finalized info agrees with that in ``store``.
|
|
"""
|
|
base = store.justified_checkpoint.root
|
|
blocks: Dict[Root, BeaconBlock] = {}
|
|
filter_block_tree(store, base, blocks)
|
|
return blocks
|
|
```
|
|
|
|
#### `get_head`
|
|
|
|
```python
|
|
def get_head(store: Store) -> Root:
|
|
# Get filtered block tree that only includes viable branches
|
|
blocks = get_filtered_block_tree(store)
|
|
# Execute the LMD-GHOST fork choice
|
|
head = store.justified_checkpoint.root
|
|
while True:
|
|
children = [
|
|
root for root in blocks.keys()
|
|
if blocks[root].parent_root == head
|
|
]
|
|
if len(children) == 0:
|
|
return head
|
|
# Sort by latest attesting balance with ties broken lexicographically
|
|
head = max(children, key=lambda root: (get_latest_attesting_balance(store, root), root))
|
|
```
|
|
|
|
#### `should_update_justified_checkpoint`
|
|
|
|
```python
|
|
def should_update_justified_checkpoint(store: Store, new_justified_checkpoint: Checkpoint) -> bool:
|
|
"""
|
|
To address the bouncing attack, only update conflicting justified
|
|
checkpoints in the fork choice if in the early slots of the epoch.
|
|
Otherwise, delay incorporation of new justified checkpoint until next epoch boundary.
|
|
|
|
See https://ethresear.ch/t/prevention-of-bouncing-attack-on-ffg/6114 for more detailed analysis and discussion.
|
|
"""
|
|
if compute_slots_since_epoch_start(get_current_slot(store)) < SAFE_SLOTS_TO_UPDATE_JUSTIFIED:
|
|
return True
|
|
|
|
justified_slot = compute_start_slot_at_epoch(store.justified_checkpoint.epoch)
|
|
if not get_ancestor(store, new_justified_checkpoint.root, justified_slot) == store.justified_checkpoint.root:
|
|
return False
|
|
|
|
return True
|
|
```
|
|
|
|
#### `on_attestation` helpers
|
|
|
|
|
|
##### `validate_target_epoch_against_current_time`
|
|
|
|
```python
|
|
def validate_target_epoch_against_current_time(store: Store, attestation: Attestation) -> None:
|
|
target = attestation.data.target
|
|
|
|
# Attestations must be from the current or previous epoch
|
|
current_epoch = compute_epoch_at_slot(get_current_slot(store))
|
|
# Use GENESIS_EPOCH for previous when genesis to avoid underflow
|
|
previous_epoch = current_epoch - 1 if current_epoch > GENESIS_EPOCH else GENESIS_EPOCH
|
|
# If attestation target is from a future epoch, delay consideration until the epoch arrives
|
|
assert target.epoch in [current_epoch, previous_epoch]
|
|
```
|
|
|
|
##### `validate_on_attestation`
|
|
|
|
```python
|
|
def validate_on_attestation(store: Store, attestation: Attestation, is_from_block: bool) -> None:
|
|
target = attestation.data.target
|
|
|
|
# If the given attestation is not from a beacon block message, we have to check the target epoch scope.
|
|
if not is_from_block:
|
|
validate_target_epoch_against_current_time(store, attestation)
|
|
|
|
# Check that the epoch number and slot number are matching
|
|
assert target.epoch == compute_epoch_at_slot(attestation.data.slot)
|
|
|
|
# Attestations target be for a known block. If target block is unknown, delay consideration until the block is found
|
|
assert target.root in store.blocks
|
|
|
|
# Attestations must be for a known block. If block is unknown, delay consideration until the block is found
|
|
assert attestation.data.beacon_block_root in store.blocks
|
|
# Attestations must not be for blocks in the future. If not, the attestation should not be considered
|
|
assert store.blocks[attestation.data.beacon_block_root].slot <= attestation.data.slot
|
|
|
|
# LMD vote must be consistent with FFG vote target
|
|
target_slot = compute_start_slot_at_epoch(target.epoch)
|
|
assert target.root == get_ancestor(store, attestation.data.beacon_block_root, target_slot)
|
|
|
|
# Attestations can only affect the fork choice of subsequent slots.
|
|
# Delay consideration in the fork choice until their slot is in the past.
|
|
assert get_current_slot(store) >= attestation.data.slot + 1
|
|
```
|
|
|
|
##### `store_target_checkpoint_state`
|
|
|
|
```python
|
|
def store_target_checkpoint_state(store: Store, target: Checkpoint) -> None:
|
|
# Store target checkpoint state if not yet seen
|
|
if target not in store.checkpoint_states:
|
|
base_state = copy(store.block_states[target.root])
|
|
if base_state.slot < compute_start_slot_at_epoch(target.epoch):
|
|
process_slots(base_state, compute_start_slot_at_epoch(target.epoch))
|
|
store.checkpoint_states[target] = base_state
|
|
```
|
|
|
|
##### `update_latest_messages`
|
|
|
|
```python
|
|
def update_latest_messages(store: Store, attesting_indices: Sequence[ValidatorIndex], attestation: Attestation) -> None:
|
|
target = attestation.data.target
|
|
beacon_block_root = attestation.data.beacon_block_root
|
|
for i in attesting_indices:
|
|
if i not in store.latest_messages or target.epoch > store.latest_messages[i].epoch:
|
|
store.latest_messages[i] = LatestMessage(epoch=target.epoch, root=beacon_block_root)
|
|
```
|
|
|
|
|
|
### Handlers
|
|
|
|
#### `on_tick`
|
|
|
|
```python
|
|
def on_tick(store: Store, time: uint64) -> None:
|
|
previous_slot = get_current_slot(store)
|
|
|
|
# update store time
|
|
store.time = time
|
|
|
|
current_slot = get_current_slot(store)
|
|
# Reset store.proposer_score_boost if this is a new slot
|
|
if store.proposer_score_boost.root != Root():
|
|
if current_slot != store.blocks[store.proposer_score_boost.root].slot:
|
|
store.proposer_score_boost = LatestMessage(root=Root(), epoch=Epoch(0))
|
|
# Not a new epoch, return
|
|
if not (current_slot > previous_slot and compute_slots_since_epoch_start(current_slot) == 0):
|
|
return
|
|
|
|
# Update store.justified_checkpoint if a better checkpoint on the store.finalized_checkpoint chain
|
|
if store.best_justified_checkpoint.epoch > store.justified_checkpoint.epoch:
|
|
finalized_slot = compute_start_slot_at_epoch(store.finalized_checkpoint.epoch)
|
|
ancestor_at_finalized_slot = get_ancestor(store, store.best_justified_checkpoint.root, finalized_slot)
|
|
if ancestor_at_finalized_slot == store.finalized_checkpoint.root:
|
|
store.justified_checkpoint = store.best_justified_checkpoint
|
|
```
|
|
|
|
#### `on_block`
|
|
|
|
```python
|
|
def on_block(store: Store, signed_block: SignedBeaconBlock) -> None:
|
|
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 the 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()
|
|
state_transition(state, signed_block, True)
|
|
# Add new block to the store
|
|
store.blocks[hash_tree_root(block)] = block
|
|
# Add new state for this block to the store
|
|
store.block_states[hash_tree_root(block)] = state
|
|
|
|
# Add proposer score boost if the block is timely
|
|
if (get_current_slot(store) == block.slot and
|
|
store.time % SECONDS_PER_SLOT < SECONDS_PER_SLOT // ATTESTATION_OFFSET_QUOTIENT):
|
|
store.proposer_score_boost = LatestMessage(
|
|
root=hash_tree_root(block),
|
|
epoch=compute_epoch_at_slot(block.slot)
|
|
)
|
|
|
|
# Update justified checkpoint
|
|
if state.current_justified_checkpoint.epoch > store.justified_checkpoint.epoch:
|
|
if state.current_justified_checkpoint.epoch > store.best_justified_checkpoint.epoch:
|
|
store.best_justified_checkpoint = state.current_justified_checkpoint
|
|
if should_update_justified_checkpoint(store, state.current_justified_checkpoint):
|
|
store.justified_checkpoint = state.current_justified_checkpoint
|
|
|
|
# Update finalized checkpoint
|
|
if state.finalized_checkpoint.epoch > store.finalized_checkpoint.epoch:
|
|
store.finalized_checkpoint = state.finalized_checkpoint
|
|
store.justified_checkpoint = state.current_justified_checkpoint
|
|
```
|
|
|
|
#### `on_attestation`
|
|
|
|
```python
|
|
def on_attestation(store: Store, attestation: Attestation, is_from_block: bool=False) -> None:
|
|
"""
|
|
Run ``on_attestation`` upon receiving a new ``attestation`` from either within a block or directly on the wire.
|
|
|
|
An ``attestation`` that is asserted as invalid may be valid at a later time,
|
|
consider scheduling it for later processing in such case.
|
|
"""
|
|
validate_on_attestation(store, attestation, is_from_block)
|
|
|
|
store_target_checkpoint_state(store, attestation.data.target)
|
|
|
|
# Get state at the `target` to fully validate attestation
|
|
target_state = store.checkpoint_states[attestation.data.target]
|
|
indexed_attestation = get_indexed_attestation(target_state, attestation)
|
|
assert is_valid_indexed_attestation(target_state, indexed_attestation)
|
|
|
|
# Update latest messages for attesting indices
|
|
update_latest_messages(store, indexed_attestation.attesting_indices, attestation)
|
|
```
|