Gloas Forkchoice#8906
Conversation
| justified_checkpoint, | ||
| finalized_checkpoint, | ||
| execution_payload_parent_hash, | ||
| execution_payload_block_hash, |
There was a problem hiding this comment.
The payload should maybe have its own variant in the Operation enum so we can test interleaving of blocks, payloads, and other events, e.g.
- Process block 1
- Process attestations
- Process block 2 building on 1
- Process payload for block 1
etc
eserilev
left a comment
There was a problem hiding this comment.
I've reviewed proto_array.rs. Looks pretty good so far. Will continue reviewing the rest of this PR
| pub struct NodeDelta { | ||
| pub delta: i64, | ||
| pub empty_delta: i64, | ||
| pub full_delta: i64, | ||
| pub payload_tiebreaker: Option<PayloadTiebreak>, |
There was a problem hiding this comment.
can we add doc comments about each field
| } else { | ||
| PayloadStatus::Full | ||
| }; |
There was a problem hiding this comment.
I can find two conditions where this could occur
- starting a network w/ gloas at genesis.
- the parent was pruned due to finalization
for (1) not sure how we're supposed to handle gloas at genesis. for (2) this situation could only happen if we've called on_block for a block that violates finality. Should never make it into this code path in that case.
So I guess we just need to understand what a gloas genesis block should do, either build on empty or full. Full probably makes sense? might be worth a comment here
| // `self`. | ||
| if let Some(parent_index) = node.parent { | ||
| // If the parent has an invalid execution status, return an error before adding the | ||
| // block to `self`. This applies when the parent is a V17 node with execution tracking. |
There was a problem hiding this comment.
| // block to `self`. This applies when the parent is a V17 node with execution tracking. | |
| // block to `self`. This applies only when the parent is a V17 node with execution tracking. |
| let node_deltas = deltas | ||
| .get(node_index) | ||
| .copied() | ||
| .ok_or(Error::InvalidNodeDelta(node_index))?; | ||
|
|
||
| let mut node_delta = if execution_status_is_invalid { |
There was a problem hiding this comment.
Nit: we have a node_deltas and a node_delta variable which is a bit confusing from a readability standpoint.
I think node_deltas should be renamed to node_delta (to match the struct name)
and node_delta should be renamed to delta (to match the field name its referencing)
| if let Ok(execution_status) = justified_node.execution_status() | ||
| && execution_status.is_invalid() | ||
| { |
There was a problem hiding this comment.
Nit: this would help make it clearer that this check is only relevant for v17 nodes
| if let Ok(execution_status) = justified_node.execution_status() | |
| && execution_status.is_invalid() | |
| { | |
| if let Ok(node) = justified_node.as_v17() | |
| && node.execution_status.is_invalid() | |
| { |
| impl PartialEq<i64> for NodeDelta { | ||
| fn eq(&self, other: &i64) -> bool { | ||
| self.delta == *other | ||
| && self.empty_delta == 0 | ||
| && self.full_delta == 0 | ||
| && self.payload_tiebreaker.is_none() | ||
| } | ||
| } |
There was a problem hiding this comment.
is this dead code? are we using this anywhere?
| } else { | ||
| // Equal weights: for V29 parents, prefer the child whose | ||
| // parent_payload_status matches the parent's payload preference. | ||
| let child_matches = child_matches_parent_payload_preference(parent, child); | ||
| let best_child_matches = | ||
| child_matches_parent_payload_preference(parent, best_child); | ||
|
|
||
| if child_matches && !best_child_matches { | ||
| change_to_child | ||
| } else { | ||
| } else if !child_matches && best_child_matches { | ||
| no_change | ||
| } | ||
| } else { | ||
| // Choose the winner by weight. | ||
| if child.weight > best_child.weight { | ||
| } else if *child.root() >= *best_child.root() { | ||
| // Final tie-breaker of equal weights by root. |
There was a problem hiding this comment.
I think we should add comments under each if statement to clarify what each condition means.
also should this code path have additional gloas related tie breaker logic from should_extend_payload?
| /// When equal, the tiebreaker uses the parent's `payload_tiebreak`: prefer Full if the block | ||
| /// was timely and data is available; otherwise prefer Empty. |
There was a problem hiding this comment.
isn't the full tie breaker this? :
(is_payload_timely(store, root) and is_payload_data_available(store, root))
or proposer_root == Root()
or store.blocks[proposer_root].parent_root != root
or is_parent_node_full(store, store.blocks[proposer_root])
eserilev
left a comment
There was a problem hiding this comment.
Made some more progress here. I'm about halfway through.
|
|
||
| /// Used for queuing payload attestations (PTC votes) from the current slot. | ||
| /// Payload attestations have different dequeue timing than regular attestations: | ||
| /// non-block payload attestations need an extra slot of delay (slot + 1 < current_slot). |
There was a problem hiding this comment.
| /// non-block payload attestations need an extra slot of delay (slot + 1 < current_slot). | |
| /// Gossiped payload attestations need an extra slot of delay (slot + 1 < current_slot). |
| } else if let Ok(signed_bid) = | ||
| anchor_block.message().body().signed_execution_payload_bid() | ||
| { | ||
| // Gloas: hashes come from the execution payload bid. |
There was a problem hiding this comment.
ExecutionStatus is always irrelevant post gloas right?
| } | ||
|
|
||
| // Post-GLOAS: same-slot attestations with index != 0 indicate a payload-present vote. | ||
| // These must go through `on_payload_attestation`, not `on_attestation`. |
There was a problem hiding this comment.
I'm not sure what you mean by these must go through on_payload_attestation. aren't these just invalid attestations? a PayloadAttestation seems to be a different concept
I think you can just reword the comment to say that same slot attestations with index !=0 are invalid
| && indexed_attestation.data().slot == block.slot | ||
| && indexed_attestation.data().index != 0 | ||
| { | ||
| return Err(InvalidAttestation::PayloadAttestationDuringSameSlot { slot: block.slot }); |
There was a problem hiding this comment.
this error variant is misleading as well, since these are not PayloadAttestations, these are just regular attestations
| })?; | ||
|
|
||
| if block.slot > indexed_payload_attestation.data.slot { | ||
| return Err(InvalidAttestation::AttestsToFutureBlock { |
There was a problem hiding this comment.
I wonder if instead of re-using InvalidAttestation error variants we should look at introducing InvalidPayloadAttestation error variants
| && indexed_payload_attestation.data.slot != self.fc_store.get_current_slot() | ||
| { | ||
| return Err(InvalidAttestation::PayloadAttestationNotCurrentSlot { | ||
| attestation_slot: indexed_payload_attestation.data.slot, |
There was a problem hiding this comment.
if we introduced a separate InvalidPayloadAttestation error enum, we won't have to mix payload attestation specific error variants with attestation specific error variants
| }); | ||
| } | ||
|
|
||
| if self.fc_store.get_current_slot() == block.slot |
There was a problem hiding this comment.
should this also check is_from_block == true? I think we might be inadvertently rejecting valid gossip payload attestations?
|
|
||
| let processing_slot = self.fc_store.get_current_slot(); | ||
| // Payload attestations from blocks can be applied in the next slot (S+1 for data.slot=S), | ||
| // while non-block payload attestations are delayed one extra slot. |
There was a problem hiding this comment.
| // while non-block payload attestations are delayed one extra slot. | |
| // while gossiped payload attestations are delayed one extra slot. |
| }, | ||
| InvalidEpochOffset(u64), | ||
| Arith(ArithError), | ||
| GloasNotImplemented, |
| variants(V17), | ||
| variant_attributes(derive(Clone, PartialEq, Debug, Encode, Decode, Serialize, Deserialize)), | ||
| no_enum | ||
| variants(V17, V29), |
There was a problem hiding this comment.
Do we really need to keep both versions implemented? Why not migrate all V17 nodes to V29 nodes once and not superstruct this? Then the API of the ProtoNode remains unchanged
There was a problem hiding this comment.
The V17 variants will be gone once the Gloas fork epoch is finalized anyway. So they can be removed in a future schema migration.
| } | ||
|
|
||
| #[derive(Clone, PartialEq, Debug, Copy)] | ||
| pub struct NodeDelta { |
There was a problem hiding this comment.
Also docs for the struct itself
…to gloas-fc-proto
Co-authored-by: Co-author hopinheimer <knmanas7@gmail.com> Co-authored-by: Co-author brech1 <11075677+brech1@users.noreply.github.com>
| pub fn head_payload_status<E: EthSpec>( | ||
| &self, | ||
| head_root: &Hash256, | ||
| _current_slot: Slot, | ||
| ) -> Option<PayloadStatus> { | ||
| let node = self.get_proto_node(head_root)?; | ||
| let v29 = node.as_v29().ok()?; | ||
| if v29.full_payload_weight > v29.empty_payload_weight { | ||
| Some(PayloadStatus::Full) | ||
| } else if v29.empty_payload_weight > v29.full_payload_weight { | ||
| Some(PayloadStatus::Empty) | ||
| } else if is_payload_timely( | ||
| &v29.payload_timeliness_votes, | ||
| E::ptc_size(), | ||
| v29.payload_received, | ||
| ) && is_payload_data_available( | ||
| &v29.payload_data_availability_votes, | ||
| E::ptc_size(), | ||
| v29.payload_received, | ||
| ) { | ||
| Some(PayloadStatus::Full) | ||
| } else { | ||
| Some(PayloadStatus::Empty) | ||
| } |
There was a problem hiding this comment.
I think were missing the payload tiebreaker logic here when node.slot + 1 == current_slot. I added this locally and got the on_execution_payload ef test to pass
There was a problem hiding this comment.
oh it seems like this function is only being used in tests
| #[superstruct(only(V29), partial_getter(copy))] | ||
| pub full_payload_weight: u64, | ||
| #[superstruct(only(V29), partial_getter(copy))] | ||
| pub execution_payload_block_hash: ExecutionBlockHash, |
There was a problem hiding this comment.
We should add a test that this equals the expected value from EthSpec.
| let mut bv = BitVector::new(); | ||
| for i in 0..bv.len() { | ||
| let _ = bv.set(i, true); | ||
| } | ||
| bv |
There was a problem hiding this comment.
nit: looks like we generate a full bit vector in the genesis case for both payload_timeliness_votes and payload_data_availability_votes. Would be nice to extract this out into separate helper method. Maybe even within the BitVector impl inside the ssz crate (e.g.BitVector::full())
| // Check if the parent is "weak" (low attestation weight). | ||
| // Parent weight currently includes the back-propagated boost, so subtract it. | ||
| let reorg_threshold = calculate_committee_fraction::<E>( | ||
| justified_balances, | ||
| spec.reorg_head_weight_threshold.unwrap_or(20), | ||
| ) | ||
| .unwrap_or(0); | ||
|
|
||
| let parent_weight_without_boost = parent.weight().saturating_sub(proposer_score); | ||
| if parent_weight_without_boost >= reorg_threshold { | ||
| return Ok(true); // Parent is not weak — apply. | ||
| } |
There was a problem hiding this comment.
I believe this isn't consistent with the spec. is_head_weak counts attestation weight from equivocating validators. parent.weight() reflects post-equivocation and post-delta weight calculations.
I think this makes us consider the parent weaker than the spec intended, which can cause us to skip proposer boosts in certain edge cases. Don't think we need to fix it in this PR but worth a TODO
|
closing this for now in favor of #9025 |
Issue Addressed
#8675
This PR implements fork choice change for Gloas. note that the forkchoice tests for post-Gloas are ignored for the testing harness lacks support for new block production flow.