Skip to content

Commit

Permalink
Allow honest validators to reorg late blocks
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelsproul committed Nov 7, 2022
1 parent 851b9a5 commit e92cf07
Show file tree
Hide file tree
Showing 3 changed files with 197 additions and 20 deletions.
99 changes: 99 additions & 0 deletions specs/bellatrix/fork-choice.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- [`ExecutionEngine`](#executionengine)
- [`notify_forkchoice_updated`](#notify_forkchoice_updated)
- [`safe_block_hash`](#safe_block_hash)
- [`should_override_forkchoice_update`](#should_override_forkchoice_update)
- [Helpers](#helpers)
- [`PayloadAttributes`](#payloadattributes)
- [`PowBlock`](#powblock)
Expand Down Expand Up @@ -76,6 +77,104 @@ As per EIP-3675, before a post-transition block is finalized, `notify_forkchoice
The `safe_block_hash` parameter MUST be set to return value of
[`get_safe_execution_payload_hash(store: Store)`](../../fork_choice/safe-block.md#get_safe_execution_payload_hash) function.

##### `should_override_forkchoice_update`

If proposer boost re-orgs are implemented and enabled (see `get_proposer_head`) then additional care
must be taken to ensure that the proposer is able to build an execution payload.

If a beacon node knows it will propose the next block then it SHOULD NOT call
`notify_forkchoice_updated` if it detects the current head to be weak and potentially capable of
being re-orged. Complete information for evaluating `get_proposer_head` _will not_ be available
immediately after the receipt of a new block, so an approximation of those conditions should be
used when deciding whether to send or suppress a fork choice notification. The exact conditions
used may be implementation-specific, a suggested implementation is below.

Let `validator_is_connected` be a function that indicates whether the validator with
`validator_index` is connected to the node (e.g. has sent an unexpired proposer preparation
message).

```python
def validator_is_connected(validator_index: ValidatorIndex) -> boolean:
...
```

```python
def should_override_forkchoice_update(
store: Store,
head_root: Root,
) -> boolean:
justified_state = store.checkpoint_states[store.justified_checkpoint]
head_block = store.blocks[head_root]
parent_root = head_block.parent_root
parent_block = store.blocks[parent_root]
current_slot = get_current_slot(store)

# Only re-org the head_block block if it arrived later than the attestation deadline.
head_late = store.block_timeliness.get(head_root) is False

# Only suppress the fork choice update if we are confident that we will propose the next block.
parent_state_advanced = store.block_states[parent_root]
process_slots(parent_state_advanced, head_block.slot + 1)
proposer_index = get_beacon_proposer_index(parent_state_advanced)
proposing_reorg_slot = validator_is_connected(proposer_index)

# Single slot re-org.
parent_slot_ok = parent_block.slot + 1 == head_block.slot
time_into_slot = (store.time - store.genesis_time) % SECONDS_PER_SLOT
current_time_ok = (head_block.slot == current_slot or
(head_block.slot + 1 == current_slot and
time_into_slot < SECONDS_PER_SLOT // INTERVALS_PER_SLOT))
single_slot_reorg = parent_slot_ok and current_time_ok

# Shuffling stable.
shuffling_stable = (head_block.slot + 1) % SLOTS_PER_EPOCH != 0

# FFG information of the new head_block will be competitive with the current head.
# FIXME: needs more justification/finalization information in the store
# ffg_competitive =
# store.realized_justifications[parent_root] ==
# store.realized_justifications[head_root] and
# store.realized_finalizations[parent_root] ==
# store.realized_finalization[head_root]
ffg_competitive = True

# Ensure that total participation meets a minimum threshold.
# The participation multiplier is set to the number of slots for which the parent_block block has
# been known.
participation_multiplier = current_slot - parent_block.slot
parent_weight = get_latest_attesting_balance(store, parent_root)
participation_threshold = calculate_committee_fraction(justified_state, PARTICIPATION_THRESHOLD)
participation_ok = parent_weight >= participation_multiplier * participation_threshold

# Check the head weight only if the attestations from the head slot have already been applied.
# Implementations may want to do this in different ways, e.g. by advancing
# `store.time` early, or by counting queued attestations during the head block's slot.
if current_slot > head_block.slot:
assert store.proposer_boost_root == Root()
head_weight = get_latest_attesting_balance(store, head_root)
reorg_threshold = calculate_committee_fraction(justified_state, REORG_WEIGHT_THRESHOLD)
head_weak = head_weight < reorg_threshold
else:
head_weak = True

return all([head_late, proposing_reorg_slot, single_slot_reorg, shuffling_stable,
ffg_competitive, participation_ok, head_weak])
```

> Note that the ordering of conditions is a suggestion only. Implementations are free to
optimize by re-ordering the conditions from least to most expensive and by returning early if
any of the early conditions are `False`.

In case `should_override_forkchoice_update` returns `True`, a node SHOULD instead call
`notify_forkchoice_updated` with the parameters of the parent block. Payload attributes should
also be sent with this request so that the execution layer may begin constructing a payload atop
the parent.

If `should_override_forkchoice_update` returns `True` but `get_proposer_head` later chooses the
canonical head rather that its parent, then this is a misprediction that will cause the node
to construct a payload with less notice. The result of `get_proposer_head` MUST be honoured in
preference to the heuristic method.

## Helpers

### `PayloadAttributes`
Expand Down
97 changes: 84 additions & 13 deletions specs/phase0/fork-choice.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@
- [`get_current_slot`](#get_current_slot)
- [`compute_slots_since_epoch_start`](#compute_slots_since_epoch_start)
- [`get_ancestor`](#get_ancestor)
- [`calculate_committee_fraction`](#calculate_committee_fraction)
- [`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)
- [`get_proposer_head`](#get_proposer_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)
Expand Down Expand Up @@ -74,11 +76,15 @@ Any of the above handlers that trigger an unhandled exception (e.g. a failed ass

### Configuration

| Name | Value |
| ---------------------- | ------------ |
| `PROPOSER_SCORE_BOOST` | `uint64(40)` |
| Name | Value |
| ------------------------------- | ------------ |
| `PROPOSER_SCORE_BOOST` | `uint64(40)` |
| `REORG_WEIGHT_THRESHOLD` | `uint64(20)` |
| `PARTICIPATION_THRESHOLD` | `uint64(70)` |

- The proposer score boost is worth `PROPOSER_SCORE_BOOST` percentage of the committee's weight, i.e., for slot with committee weight `committee_weight` the boost weight is equal to `(committee_weight * PROPOSER_SCORE_BOOST) // 100`.
- The proposer score boost, re-org weight threshold and participation threshold are percentage
values that are measured with respect to the weight of a single committee. See
`calculate_committee_fraction`.

### Helpers

Expand All @@ -105,6 +111,7 @@ class Store(object):
equivocating_indices: Set[ValidatorIndex]
blocks: Dict[Root, BeaconBlock] = field(default_factory=dict)
block_states: Dict[Root, BeaconState] = field(default_factory=dict)
block_timeliness: Dict[Root, boolean] = field(default_factory=dict)
checkpoint_states: Dict[Checkpoint, BeaconState] = field(default_factory=dict)
latest_messages: Dict[ValidatorIndex, LatestMessage] = field(default_factory=dict)
```
Expand Down Expand Up @@ -173,6 +180,17 @@ def get_ancestor(store: Store, root: Root, slot: Slot) -> Root:
return root
```

#### `calculate_committee_fraction`

```python
def calculate_committee_fraction(state: BeaconState, committee_percent: uint64) -> Gwei:
num_validators = len(get_active_validator_indices(state, get_current_epoch(state)))
avg_balance = get_total_active_balance(state) // num_validators
committee_size = num_validators // SLOTS_PER_EPOCH
committee_weight = committee_size * avg_balance
return (committee_weight * committee_percent) // 100
```

#### `get_latest_attesting_balance`

```python
Expand All @@ -193,15 +211,11 @@ def get_latest_attesting_balance(store: Store, root: Root) -> Gwei:
proposer_score = Gwei(0)
# Boost is applied if ``root`` is an ancestor of ``proposer_boost_root``
if get_ancestor(store, store.proposer_boost_root, store.blocks[root].slot) == root:
num_validators = len(get_active_validator_indices(state, get_current_epoch(state)))
avg_balance = get_total_active_balance(state) // num_validators
committee_size = num_validators // SLOTS_PER_EPOCH
committee_weight = committee_size * avg_balance
proposer_score = (committee_weight * PROPOSER_SCORE_BOOST) // 100
proposer_score = calculate_committee_fraction(state, PROPOSER_SCORE_BOOST)
return attestation_score + proposer_score

```


#### `filter_block_tree`

```python
Expand Down Expand Up @@ -275,6 +289,59 @@ def get_head(store: Store) -> Root:
head = max(children, key=lambda root: (get_latest_attesting_balance(store, root), root))
```

#### `get_proposer_head`

```python
def get_proposer_head(store: Store, head_root: Root, slot: Slot) -> Root:
justified_state = store.checkpoint_states[store.justified_checkpoint]
head_block = store.blocks[head_root]
parent_root = head_block.parent_root
parent_block = store.blocks[parent_root]

# Only re-org the head block if it arrived later than the attestation deadline.
head_late = store.block_timeliness.get(head_root) is False

# Only re-org a single slot at most.
single_slot_reorg = parent_block.slot + 1 == head_block.slot and head_block.slot + 1 == slot

# Do not re-org on an epoch boundary where the proposer shuffling could change.
shuffling_stable = slot % SLOTS_PER_EPOCH != 0

# Ensure that the FFG information of the new head will be competitive with the current head.
# FIXME: needs more justification/finalization information in the store
# ffg_competitive =
# store.realized_justifications[parent_root] ==
# store.realized_justifications[head_root] and
# store.realized_finalizations[parent_root] ==
# store.realized_finalization[head_root]
ffg_competitive = True

# Ensure that total participation meets a minimum threshold. The factor of 2 accounts for votes
# from the head slot which should flow to the parent either via the head block or votes for
# emptiness.
participation_multiplier = 2
parent_weight = get_latest_attesting_balance(store, parent_root)
participation_threshold = calculate_committee_fraction(justified_state, PARTICIPATION_THRESHOLD)
participation_ok = parent_weight >= participation_multiplier * participation_threshold

# Check that the head has few enough votes to be overpowered by our proposer boost.
assert store.proposer_boost_root == Root()
head_weight = get_latest_attesting_balance(store, head_root)
reorg_threshold = calculate_committee_fraction(justified_state, REORG_WEIGHT_THRESHOLD)
head_weak = head_weight < reorg_threshold

if all([head_late, single_slot_reorg, shuffling_stable, ffg_competitive, participation_ok,
head_weak]):
# We can re-org the current head by building upon its parent block.
return parent_root
else:
return head_root
```

> Note that the ordering of conditions is a suggestion only. Implementations are free to
optimize by re-ordering the conditions from least to most expensive and by returning early if
any of the early conditions are `False`.

#### `should_update_justified_checkpoint`

```python
Expand Down Expand Up @@ -423,10 +490,14 @@ def on_block(store: Store, signed_block: SignedBeaconBlock) -> None:
# 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
# Add block timeliness to the store
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:
is_timely = get_current_slot(store) and is_before_attesting_interval
store.block_timeliness[hash_tree_root(block)] = is_timely

# Add proposer score boost if the block is timely
if is_timely:
store.proposer_boost_root = hash_tree_root(block)

# Update justified checkpoint
Expand Down Expand Up @@ -485,4 +556,4 @@ def on_attester_slashing(store: Store, attester_slashing: AttesterSlashing) -> N
indices = set(attestation_1.attesting_indices).intersection(attestation_2.attesting_indices)
for index in indices:
store.equivocating_indices.add(index)
```
```
21 changes: 14 additions & 7 deletions specs/phase0/validator.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,15 +278,22 @@ A validator has two primary responsibilities to the beacon chain: [proposing blo
A validator is expected to propose a [`SignedBeaconBlock`](./beacon-chain.md#signedbeaconblock) at
the beginning of any `slot` during which `is_proposer(state, validator_index)` returns `True`.

To propose, the validator selects the `BeaconBlock`, `parent` which:

1. In their view of fork choice is the head of the chain at the start of
`slot`, after running `on_tick` and applying any queued attestations from `slot - 1`.
2. Is from a slot strictly less than the slot of the block about to be proposed,
i.e. `parent.slot < slot`.
To propose, the validator selects a `BeaconBlock`, `parent` using this process:

1. Compute fork choice's view of the head at the start of `slot`, after running
`on_tick` and applying any queued attestations from `slot - 1`.
Set `head_root = get_head(store)`.
2. Compute the _proposer head_, which is the head upon which the proposer SHOULD build in order to
incentivise timely block propagation by other validators.
Set `parent_root = get_proposer_head(store, head_root, slot)`.
A proposer may set `parent_root == head_root` if proposer re-orgs are not implemented or have
been disabled.
3. Let `parent` be the block with `parent_root`.

The validator creates, signs, and broadcasts a `block` that is a child of `parent`
that satisfies a valid [beacon chain state transition](./beacon-chain.md#beacon-chain-state-transition-function).
and satisfies a valid [beacon chain state transition](./beacon-chain.md#beacon-chain-state-transition-function).
Note that the parent's slot must be strictly less than the slot of the block about to be proposed,
i.e. `parent.slot < slot`.

There is one proposer per slot, so if there are N active validators any individual validator
will on average be assigned to propose once per N slots (e.g. at 312,500 validators = 10 million ETH, that's once per ~6 weeks).
Expand Down

0 comments on commit e92cf07

Please sign in to comment.