mirror of
https://github.com/sigp/lighthouse.git
synced 2026-04-27 01:33:33 +00:00
Ensure payload envelope streamer always serves canonical envelopes after the split slot (#9085)
Co-Authored-By: Eitan Seri- Levi <eserilev@gmail.com> Co-Authored-By: Eitan Seri-Levi <eserilev@ucsc.edu>
This commit is contained in:
@@ -383,11 +383,24 @@ impl<T: BeaconChainTypes> CanonicalHead<T> {
|
||||
Ok((head, execution_status))
|
||||
}
|
||||
|
||||
// TODO(gloas) just a stub for now, implement this once we have fork choice.
|
||||
/// Returns true if the payload for this block is canonical according to fork choice
|
||||
/// Returns an error if the block root doesn't exist in fork choice.
|
||||
pub fn block_has_canonical_payload(&self, _root: &Hash256) -> Result<bool, Error> {
|
||||
Ok(true)
|
||||
/// Returns `true` if the payload for this block is canonical (Full) according to fork choice.
|
||||
pub fn block_has_canonical_payload(
|
||||
&self,
|
||||
root: &Hash256,
|
||||
spec: &ChainSpec,
|
||||
) -> Result<bool, Error> {
|
||||
let cached_head = self.cached_head();
|
||||
let head_root = cached_head.head_block_root();
|
||||
let head_payload_status = cached_head.head_payload_status();
|
||||
|
||||
if *root == head_root {
|
||||
return Ok(head_payload_status == PayloadStatus::Full);
|
||||
}
|
||||
|
||||
self.fork_choice_read_lock()
|
||||
.get_canonical_payload_status(root, spec)
|
||||
.map(|status| status == PayloadStatus::Full)
|
||||
.map_err(Error::ForkChoiceError)
|
||||
}
|
||||
|
||||
/// Returns a clone of `self.cached_head`.
|
||||
|
||||
@@ -37,6 +37,8 @@ impl<T: BeaconChainTypes> EnvelopeStreamerBeaconAdapter<T> {
|
||||
&self,
|
||||
root: &Hash256,
|
||||
) -> Result<bool, BeaconChainError> {
|
||||
self.chain.canonical_head.block_has_canonical_payload(root)
|
||||
self.chain
|
||||
.canonical_head
|
||||
.block_has_canonical_payload(root, &self.chain.spec)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,13 +132,8 @@ impl<T: BeaconChainTypes> PayloadEnvelopeStreamer<T> {
|
||||
results.push((*root, Ok(None)));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
results.push((
|
||||
*root,
|
||||
Err(BeaconChainError::EnvelopeStreamerError(
|
||||
Error::BlockMissingFromForkChoice,
|
||||
)),
|
||||
));
|
||||
Err(e) => {
|
||||
results.push((*root, Err(e)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::beacon_chain::ForkChoiceError;
|
||||
use crate::payload_envelope_streamer::beacon_chain_adapter::MockEnvelopeStreamerBeaconAdapter;
|
||||
use crate::test_utils::EphemeralHarnessType;
|
||||
use bls::{FixedBytesExtended, Signature};
|
||||
@@ -279,15 +280,18 @@ async fn stream_envelopes_by_root() {
|
||||
}
|
||||
|
||||
/// When `block_has_canonical_payload` returns an error, the streamer should
|
||||
/// yield `Err(EnvelopeStreamerError(BlockMissingFromForkChoice))` for those roots.
|
||||
/// propagate that error for those roots.
|
||||
#[tokio::test]
|
||||
async fn stream_envelopes_error() {
|
||||
let chain = build_chain(4, &[], &[], &[]);
|
||||
let (mut mock, _runtime) = mock_adapter();
|
||||
mock.expect_get_split_slot().return_const(Slot::new(0));
|
||||
mock_envelopes(&mut mock, &chain);
|
||||
mock.expect_block_has_canonical_payload()
|
||||
.returning(|_| Err(BeaconChainError::CanonicalHeadLockTimeout));
|
||||
mock.expect_block_has_canonical_payload().returning(|_| {
|
||||
Err(BeaconChainError::ForkChoiceError(
|
||||
ForkChoiceError::DoesNotDescendFromFinalizedCheckpoint,
|
||||
))
|
||||
});
|
||||
|
||||
let streamer = PayloadEnvelopeStreamer::new(mock, EnvelopeRequestSource::ByRange);
|
||||
let mut stream = streamer.launch_stream(roots(&chain));
|
||||
@@ -299,13 +303,8 @@ async fn stream_envelopes_error() {
|
||||
.unwrap_or_else(|| panic!("stream ended early at index {i}"));
|
||||
assert_eq!(root, entry.block_root, "root mismatch at index {i}");
|
||||
assert!(
|
||||
matches!(
|
||||
result.as_ref(),
|
||||
Err(BeaconChainError::EnvelopeStreamerError(
|
||||
Error::BlockMissingFromForkChoice
|
||||
))
|
||||
),
|
||||
"expected BlockMissingFromForkChoice error at index {i}, got {:?}",
|
||||
result.as_ref().is_err(),
|
||||
"expected error at index {i}, got {:?}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ pub enum Error<T> {
|
||||
UnrealizedVoteProcessing(state_processing::EpochProcessingError),
|
||||
ValidatorStatuses(BeaconStateError),
|
||||
ChainSpecError(String),
|
||||
DoesNotDescendFromFinalizedCheckpoint,
|
||||
}
|
||||
|
||||
impl<T> From<InvalidAttestation> for Error<T> {
|
||||
@@ -1523,6 +1524,29 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the canonical payload status of a block. See
|
||||
/// `ProtoArrayForkChoice::get_canonical_payload_status`.
|
||||
pub fn get_canonical_payload_status(
|
||||
&self,
|
||||
block_root: &Hash256,
|
||||
spec: &ChainSpec,
|
||||
) -> Result<PayloadStatus, Error<T::Error>> {
|
||||
if self.is_finalized_checkpoint_or_descendant(*block_root) {
|
||||
let current_slot = self.fc_store.get_current_slot();
|
||||
let proposer_boost_root = self.fc_store.proposer_boost_root();
|
||||
self.proto_array
|
||||
.get_canonical_payload_status::<E>(
|
||||
block_root,
|
||||
current_slot,
|
||||
proposer_boost_root,
|
||||
spec,
|
||||
)
|
||||
.map_err(Error::ProtoArrayError)
|
||||
} else {
|
||||
Err(Error::DoesNotDescendFromFinalizedCheckpoint)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the weight for the given block root.
|
||||
pub fn get_block_weight(&self, block_root: &Hash256) -> Option<u64> {
|
||||
self.proto_array.get_weight(block_root)
|
||||
|
||||
@@ -4,6 +4,7 @@ mod gloas_payload;
|
||||
mod no_votes;
|
||||
mod votes;
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::proto_array_fork_choice::{Block, ExecutionStatus, PayloadStatus, ProtoArrayForkChoice};
|
||||
use crate::{InvalidationOperation, JustifiedBalances};
|
||||
use fixed_bytes::FixedBytesExtended;
|
||||
@@ -30,6 +31,8 @@ pub enum Operation {
|
||||
justified_state_balances: Vec<u64>,
|
||||
expected_head: Hash256,
|
||||
current_slot: Slot,
|
||||
// TODO(gloas): Make this non-optional. `find_head` always returns a `PayloadStatus`
|
||||
// (Empty for pre-GLOAS), so every test should assert on it explicitly.
|
||||
#[serde(default)]
|
||||
expected_payload_status: Option<PayloadStatus>,
|
||||
},
|
||||
@@ -61,6 +64,12 @@ pub enum Operation {
|
||||
block_root: Hash256,
|
||||
attestation_slot: Slot,
|
||||
},
|
||||
ProcessGloasAttestation {
|
||||
validator_index: usize,
|
||||
block_root: Hash256,
|
||||
attestation_slot: Slot,
|
||||
payload_present: bool,
|
||||
},
|
||||
ProcessPayloadAttestation {
|
||||
validator_index: usize,
|
||||
block_root: Hash256,
|
||||
@@ -105,6 +114,16 @@ pub enum Operation {
|
||||
block_root: Hash256,
|
||||
expected: bool,
|
||||
},
|
||||
AssertPayloadStatusByWeight {
|
||||
block_root: Hash256,
|
||||
expected_status: PayloadStatus,
|
||||
/// Override `current_slot`. Defaults to the `current_slot` of the last `FindHead`.
|
||||
#[serde(default)]
|
||||
current_slot: Option<Slot>,
|
||||
/// Override the proposer boost root. Defaults to `Hash256::zero()`.
|
||||
#[serde(default)]
|
||||
proposer_boost_root: Option<Hash256>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -149,6 +168,7 @@ impl ForkChoiceTestDefinition {
|
||||
)
|
||||
.expect("should create fork choice struct");
|
||||
let equivocating_indices = BTreeSet::new();
|
||||
let mut last_current_slot = Slot::new(0);
|
||||
|
||||
for (op_index, op) in self.operations.into_iter().enumerate() {
|
||||
match op.clone() {
|
||||
@@ -189,6 +209,16 @@ impl ForkChoiceTestDefinition {
|
||||
op_index, op
|
||||
);
|
||||
}
|
||||
assert_canonical_payload_status_matches_find_head(
|
||||
&fork_choice,
|
||||
&head,
|
||||
current_slot,
|
||||
Hash256::zero(),
|
||||
&spec,
|
||||
payload_status,
|
||||
op_index,
|
||||
);
|
||||
last_current_slot = current_slot;
|
||||
check_bytes_round_trip(&fork_choice);
|
||||
}
|
||||
Operation::ProposerBoostFindHead {
|
||||
@@ -201,7 +231,7 @@ impl ForkChoiceTestDefinition {
|
||||
let justified_balances =
|
||||
JustifiedBalances::from_effective_balances(justified_state_balances)
|
||||
.unwrap();
|
||||
let (head, _payload_status) = fork_choice
|
||||
let (head, payload_status) = fork_choice
|
||||
.find_head::<MainnetEthSpec>(
|
||||
justified_checkpoint,
|
||||
finalized_checkpoint,
|
||||
@@ -220,6 +250,15 @@ impl ForkChoiceTestDefinition {
|
||||
"Operation at index {} failed head check. Operation: {:?}",
|
||||
op_index, op
|
||||
);
|
||||
assert_canonical_payload_status_matches_find_head(
|
||||
&fork_choice,
|
||||
&head,
|
||||
Slot::new(0),
|
||||
proposer_boost_root,
|
||||
&spec,
|
||||
payload_status,
|
||||
op_index,
|
||||
);
|
||||
check_bytes_round_trip(&fork_choice);
|
||||
}
|
||||
Operation::InvalidFindHead {
|
||||
@@ -308,6 +347,27 @@ impl ForkChoiceTestDefinition {
|
||||
});
|
||||
check_bytes_round_trip(&fork_choice);
|
||||
}
|
||||
Operation::ProcessGloasAttestation {
|
||||
validator_index,
|
||||
block_root,
|
||||
attestation_slot,
|
||||
payload_present,
|
||||
} => {
|
||||
fork_choice
|
||||
.process_attestation(
|
||||
validator_index,
|
||||
block_root,
|
||||
attestation_slot,
|
||||
payload_present,
|
||||
)
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"process_attestation op at index {} returned error",
|
||||
op_index
|
||||
)
|
||||
});
|
||||
check_bytes_round_trip(&fork_choice);
|
||||
}
|
||||
Operation::ProcessPayloadAttestation {
|
||||
validator_index,
|
||||
block_root,
|
||||
@@ -522,6 +582,26 @@ impl ForkChoiceTestDefinition {
|
||||
op_index
|
||||
);
|
||||
}
|
||||
Operation::AssertPayloadStatusByWeight {
|
||||
block_root,
|
||||
expected_status,
|
||||
current_slot,
|
||||
proposer_boost_root,
|
||||
} => {
|
||||
let actual = fork_choice
|
||||
.get_canonical_payload_status::<MainnetEthSpec>(
|
||||
&block_root,
|
||||
current_slot.unwrap_or(last_current_slot),
|
||||
proposer_boost_root.unwrap_or_else(Hash256::zero),
|
||||
&spec,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
actual, expected_status,
|
||||
"canonical payload status mismatch at op index {}",
|
||||
op_index
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -546,6 +626,37 @@ fn get_checkpoint(i: u64) -> Checkpoint {
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks that `get_canonical_payload_status` agrees with the `payload_status`
|
||||
/// returned by `find_head` for the head block.
|
||||
fn assert_canonical_payload_status_matches_find_head(
|
||||
fork_choice: &ProtoArrayForkChoice,
|
||||
head: &Hash256,
|
||||
current_slot: Slot,
|
||||
proposer_boost_root: Hash256,
|
||||
spec: &ChainSpec,
|
||||
expected: PayloadStatus,
|
||||
op_index: usize,
|
||||
) {
|
||||
match fork_choice.get_canonical_payload_status::<MainnetEthSpec>(
|
||||
head,
|
||||
current_slot,
|
||||
proposer_boost_root,
|
||||
spec,
|
||||
) {
|
||||
Ok(actual) => assert_eq!(
|
||||
actual, expected,
|
||||
"get_canonical_payload_status disagreed with find_head for head {:?} at op index {}",
|
||||
head, op_index
|
||||
),
|
||||
// Skip the check for pre-gloas nodes
|
||||
Err(Error::InvalidNodeVariant { .. }) => {}
|
||||
Err(e) => panic!(
|
||||
"get_canonical_payload_status failed at op index {}: {:?}",
|
||||
op_index, e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_bytes_round_trip(original: &ProtoArrayForkChoice) {
|
||||
let bytes = original.as_bytes();
|
||||
let decoded = ProtoArrayForkChoice::from_bytes(&bytes, original.balances.clone())
|
||||
|
||||
@@ -81,20 +81,88 @@ pub fn get_gloas_chain_following_test_definition() -> ForkChoiceTestDefinition {
|
||||
expected_payload_status: None,
|
||||
});
|
||||
|
||||
ops.push(Operation::SetPayloadTiebreak {
|
||||
block_root: get_root(0),
|
||||
is_timely: false,
|
||||
is_data_available: false,
|
||||
// Cross-slot attestation with payload_present=true to Full branch (root 3, slot 2).
|
||||
// vote_slot=3 differs from block_slot=2 and payload_present=true, so it counts as Full weight.
|
||||
ops.push(Operation::ProcessGloasAttestation {
|
||||
validator_index: 0,
|
||||
block_root: get_root(3),
|
||||
attestation_slot: Slot::new(3),
|
||||
payload_present: true,
|
||||
});
|
||||
ops.push(Operation::FindHead {
|
||||
justified_checkpoint: get_checkpoint(0),
|
||||
finalized_checkpoint: get_checkpoint(0),
|
||||
justified_state_balances: vec![1],
|
||||
expected_head: get_root(3),
|
||||
current_slot: Slot::new(0),
|
||||
expected_payload_status: None,
|
||||
});
|
||||
|
||||
// Full weight propagated up: root 0 and root 1 should show Full.
|
||||
ops.push(Operation::AssertPayloadStatusByWeight {
|
||||
block_root: get_root(0),
|
||||
expected_status: PayloadStatus::Full,
|
||||
current_slot: None,
|
||||
proposer_boost_root: None,
|
||||
});
|
||||
ops.push(Operation::AssertPayloadStatusByWeight {
|
||||
block_root: get_root(1),
|
||||
expected_status: PayloadStatus::Full,
|
||||
current_slot: None,
|
||||
proposer_boost_root: None,
|
||||
});
|
||||
// Root 2 has no payload received, so it's always Empty.
|
||||
ops.push(Operation::AssertPayloadStatusByWeight {
|
||||
block_root: get_root(2),
|
||||
expected_status: PayloadStatus::Empty,
|
||||
current_slot: None,
|
||||
proposer_boost_root: None,
|
||||
});
|
||||
|
||||
// Cross-slot attestations with payload_present=false to Empty branch (root 4, slot 2).
|
||||
// Two validators so Empty branch outweighs Full branch.
|
||||
ops.push(Operation::ProcessGloasAttestation {
|
||||
validator_index: 1,
|
||||
block_root: get_root(4),
|
||||
attestation_slot: Slot::new(3),
|
||||
payload_present: false,
|
||||
});
|
||||
ops.push(Operation::ProcessGloasAttestation {
|
||||
validator_index: 2,
|
||||
block_root: get_root(4),
|
||||
attestation_slot: Slot::new(3),
|
||||
payload_present: false,
|
||||
});
|
||||
ops.push(Operation::FindHead {
|
||||
justified_checkpoint: get_checkpoint(0),
|
||||
finalized_checkpoint: get_checkpoint(0),
|
||||
justified_state_balances: vec![1, 1, 1],
|
||||
expected_head: get_root(4),
|
||||
current_slot: Slot::new(0),
|
||||
expected_payload_status: None,
|
||||
});
|
||||
|
||||
// Empty weight now dominates, so root 0 flips to Empty.
|
||||
ops.push(Operation::AssertPayloadStatusByWeight {
|
||||
block_root: get_root(0),
|
||||
expected_status: PayloadStatus::Empty,
|
||||
current_slot: None,
|
||||
proposer_boost_root: None,
|
||||
});
|
||||
ops.push(Operation::AssertPayloadStatusByWeight {
|
||||
block_root: get_root(2),
|
||||
expected_status: PayloadStatus::Empty,
|
||||
current_slot: None,
|
||||
proposer_boost_root: None,
|
||||
});
|
||||
// Root 1 (Full branch) still has 1 Full vote and 0 Empty, so it stays Full.
|
||||
ops.push(Operation::AssertPayloadStatusByWeight {
|
||||
block_root: get_root(1),
|
||||
expected_status: PayloadStatus::Full,
|
||||
current_slot: None,
|
||||
proposer_boost_root: None,
|
||||
});
|
||||
|
||||
ForkChoiceTestDefinition {
|
||||
finalized_block_slot: Slot::new(0),
|
||||
justified_checkpoint: get_checkpoint(0),
|
||||
@@ -143,7 +211,7 @@ pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition {
|
||||
justified_state_balances: vec![1, 1],
|
||||
expected_head: get_root(1),
|
||||
current_slot: Slot::new(0),
|
||||
// With MainnetEthSpec PTC_SIZE=512, 1 bit set out of 256 threshold → not timely → Empty.
|
||||
// With MainnetEthSpec PTC_SIZE=512 and a 256-bit threshold, 1 bit set is not timely, so Empty.
|
||||
expected_payload_status: Some(PayloadStatus::Empty),
|
||||
});
|
||||
// PTC votes write to bitfields only, not to full/empty weight.
|
||||
@@ -286,7 +354,7 @@ pub fn get_gloas_find_head_vote_transition_test_definition() -> ForkChoiceTestDe
|
||||
expected_payload_status: None,
|
||||
});
|
||||
|
||||
// CL attestation to Empty branch (root 4) from validator 0 → head flips to 4.
|
||||
// CL attestation to Empty branch (root 4) from validator 0 flips the head to 4.
|
||||
ops.push(Operation::ProcessAttestation {
|
||||
validator_index: 0,
|
||||
block_root: get_root(4),
|
||||
@@ -301,7 +369,7 @@ pub fn get_gloas_find_head_vote_transition_test_definition() -> ForkChoiceTestDe
|
||||
expected_payload_status: None,
|
||||
});
|
||||
|
||||
// CL attestation back to Full branch (root 3) → head returns to 3.
|
||||
// CL attestation back to Full branch (root 3) returns the head to 3.
|
||||
ops.push(Operation::ProcessAttestation {
|
||||
validator_index: 0,
|
||||
block_root: get_root(3),
|
||||
@@ -546,7 +614,7 @@ pub fn get_gloas_interleaved_attestations_test_definition() -> ForkChoiceTestDef
|
||||
block_root: get_root(1),
|
||||
});
|
||||
|
||||
// Step 4: Set tiebreaker to Empty on genesis → Empty branch wins.
|
||||
// Step 4: Set tiebreaker to Empty on genesis so the Empty branch wins.
|
||||
ops.push(Operation::SetPayloadTiebreak {
|
||||
block_root: get_root(0),
|
||||
is_timely: false,
|
||||
@@ -560,8 +628,15 @@ pub fn get_gloas_interleaved_attestations_test_definition() -> ForkChoiceTestDef
|
||||
current_slot: Slot::new(1),
|
||||
expected_payload_status: None,
|
||||
});
|
||||
// Weights are tied (1 vote each branch), tiebreaker is Empty.
|
||||
ops.push(Operation::AssertPayloadStatusByWeight {
|
||||
block_root: get_root(0),
|
||||
expected_status: PayloadStatus::Empty,
|
||||
current_slot: None,
|
||||
proposer_boost_root: None,
|
||||
});
|
||||
|
||||
// Step 5: Flip tiebreaker to Full → Full branch wins.
|
||||
// Step 5: Flip tiebreaker to Full so the Full branch wins.
|
||||
ops.push(Operation::SetPayloadTiebreak {
|
||||
block_root: get_root(0),
|
||||
is_timely: true,
|
||||
@@ -575,8 +650,15 @@ pub fn get_gloas_interleaved_attestations_test_definition() -> ForkChoiceTestDef
|
||||
current_slot: Slot::new(100),
|
||||
expected_payload_status: None,
|
||||
});
|
||||
// Weights still tied, tiebreaker flipped to Full.
|
||||
ops.push(Operation::AssertPayloadStatusByWeight {
|
||||
block_root: get_root(0),
|
||||
expected_status: PayloadStatus::Full,
|
||||
current_slot: None,
|
||||
proposer_boost_root: None,
|
||||
});
|
||||
|
||||
// Step 6: Add extra CL weight to Empty branch → overrides Full tiebreaker.
|
||||
// Step 6: Add extra CL weight to the Empty branch; this overrides the Full tiebreaker.
|
||||
ops.push(Operation::ProcessAttestation {
|
||||
validator_index: 2,
|
||||
block_root: get_root(4),
|
||||
@@ -732,6 +814,163 @@ pub fn get_gloas_payload_received_interleaving_test_definition() -> ForkChoiceTe
|
||||
}
|
||||
}
|
||||
|
||||
/// When `current_slot == node.slot + 1`, spec `get_weight` zeroes out Full and Empty
|
||||
/// weights so the tiebreaker decides. Tests that the zero-out is applied and
|
||||
/// doesn't just compare raw payload weights.
|
||||
pub fn get_gloas_previous_slot_tiebreaker_test_definition() -> ForkChoiceTestDefinition {
|
||||
let mut ops = vec![];
|
||||
|
||||
// Block 1 at slot 1 with its payload received.
|
||||
// Genesis has zero block hash so all its children are Empty (genesis never has
|
||||
// payload_received). Block 1's parent_hash doesn't match zero → Empty child.
|
||||
ops.push(Operation::ProcessBlock {
|
||||
slot: Slot::new(1),
|
||||
root: get_root(1),
|
||||
parent_root: get_root(0),
|
||||
justified_checkpoint: get_checkpoint(0),
|
||||
finalized_checkpoint: get_checkpoint(0),
|
||||
execution_payload_parent_hash: Some(get_hash(0)),
|
||||
execution_payload_block_hash: Some(get_hash(1)),
|
||||
});
|
||||
ops.push(Operation::ProcessExecutionPayloadEnvelope {
|
||||
block_root: get_root(1),
|
||||
});
|
||||
|
||||
// Block 2 at slot 2 with a mismatched EL parent hash, giving it an Empty parent payload status.
|
||||
ops.push(Operation::ProcessBlock {
|
||||
slot: Slot::new(2),
|
||||
root: get_root(2),
|
||||
parent_root: get_root(1),
|
||||
justified_checkpoint: get_checkpoint(0),
|
||||
finalized_checkpoint: get_checkpoint(0),
|
||||
execution_payload_parent_hash: Some(get_hash(99)),
|
||||
execution_payload_block_hash: Some(get_hash(2)),
|
||||
});
|
||||
|
||||
// More Full weight than Empty on block 1.
|
||||
ops.push(Operation::ProcessGloasAttestation {
|
||||
validator_index: 0,
|
||||
block_root: get_root(1),
|
||||
attestation_slot: Slot::new(2),
|
||||
payload_present: true,
|
||||
});
|
||||
|
||||
// Materialize the attestation into `full_payload_weight`.
|
||||
ops.push(Operation::FindHead {
|
||||
justified_checkpoint: get_checkpoint(0),
|
||||
finalized_checkpoint: get_checkpoint(0),
|
||||
justified_state_balances: vec![1],
|
||||
expected_head: get_root(1),
|
||||
current_slot: Slot::new(1),
|
||||
expected_payload_status: Some(PayloadStatus::Full),
|
||||
});
|
||||
|
||||
// Before zero-out (current_slot == block 1's slot), raw weights decide payload status (Full)
|
||||
ops.push(Operation::AssertPayloadStatusByWeight {
|
||||
block_root: get_root(1),
|
||||
expected_status: PayloadStatus::Full,
|
||||
current_slot: Some(Slot::new(1)),
|
||||
proposer_boost_root: None,
|
||||
});
|
||||
|
||||
// At current_slot == block 1's slot + 1, both weights zero out and the
|
||||
// tiebreaker picks Empty (block 2 extends block 1 with an Empty parent
|
||||
// payload status).
|
||||
ops.push(Operation::AssertPayloadStatusByWeight {
|
||||
block_root: get_root(1),
|
||||
expected_status: PayloadStatus::Empty,
|
||||
current_slot: Some(Slot::new(2)),
|
||||
proposer_boost_root: Some(get_root(2)),
|
||||
});
|
||||
|
||||
ForkChoiceTestDefinition {
|
||||
finalized_block_slot: Slot::new(0),
|
||||
justified_checkpoint: get_checkpoint(0),
|
||||
finalized_checkpoint: get_checkpoint(0),
|
||||
operations: ops,
|
||||
execution_payload_parent_hash: Some(ExecutionBlockHash::zero()),
|
||||
execution_payload_block_hash: Some(ExecutionBlockHash::zero()),
|
||||
spec: Some(gloas_spec()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Proposer boost on a descendant can flip an ancestor's canonical payload status.
|
||||
/// Boost supports the ancestor's Full variant (via the descendant's Full parent
|
||||
/// payload status) but not Empty, so a large enough boost overrides raw Empty weight.
|
||||
pub fn get_gloas_proposer_boost_flips_ancestor_test_definition() -> ForkChoiceTestDefinition {
|
||||
let mut ops = vec![];
|
||||
|
||||
// Block 1 at slot 1 with payload received.
|
||||
ops.push(Operation::ProcessBlock {
|
||||
slot: Slot::new(1),
|
||||
root: get_root(1),
|
||||
parent_root: get_root(0),
|
||||
justified_checkpoint: get_checkpoint(0),
|
||||
finalized_checkpoint: get_checkpoint(0),
|
||||
execution_payload_parent_hash: Some(get_hash(0)),
|
||||
execution_payload_block_hash: Some(get_hash(1)),
|
||||
});
|
||||
ops.push(Operation::ProcessExecutionPayloadEnvelope {
|
||||
block_root: get_root(1),
|
||||
});
|
||||
|
||||
// Block 2 at slot 3 with a Full parent payload status (skip slot 2 so
|
||||
// block 1's previous-slot zero-out doesn't fire at current_slot 3).
|
||||
ops.push(Operation::ProcessBlock {
|
||||
slot: Slot::new(3),
|
||||
root: get_root(2),
|
||||
parent_root: get_root(1),
|
||||
justified_checkpoint: get_checkpoint(0),
|
||||
finalized_checkpoint: get_checkpoint(0),
|
||||
execution_payload_parent_hash: Some(get_hash(1)),
|
||||
execution_payload_block_hash: Some(get_hash(2)),
|
||||
});
|
||||
|
||||
// One Empty vote on block 1. Balance totals are chosen so the proposer
|
||||
// boost score exceeds the single Empty voter's balance.
|
||||
ops.push(Operation::ProcessGloasAttestation {
|
||||
validator_index: 0,
|
||||
block_root: get_root(1),
|
||||
attestation_slot: Slot::new(2),
|
||||
payload_present: false,
|
||||
});
|
||||
|
||||
ops.push(Operation::FindHead {
|
||||
justified_checkpoint: get_checkpoint(0),
|
||||
finalized_checkpoint: get_checkpoint(0),
|
||||
justified_state_balances: vec![100, 10000],
|
||||
expected_head: get_root(1),
|
||||
current_slot: Slot::new(3),
|
||||
expected_payload_status: Some(PayloadStatus::Empty),
|
||||
});
|
||||
|
||||
// Without boost the raw weights decide and Empty wins.
|
||||
ops.push(Operation::AssertPayloadStatusByWeight {
|
||||
block_root: get_root(1),
|
||||
expected_status: PayloadStatus::Empty,
|
||||
current_slot: Some(Slot::new(3)),
|
||||
proposer_boost_root: None,
|
||||
});
|
||||
|
||||
// With boost on block 2 the boost supports block 1's Full variant, so Full wins.
|
||||
ops.push(Operation::AssertPayloadStatusByWeight {
|
||||
block_root: get_root(1),
|
||||
expected_status: PayloadStatus::Full,
|
||||
current_slot: Some(Slot::new(3)),
|
||||
proposer_boost_root: Some(get_root(2)),
|
||||
});
|
||||
|
||||
ForkChoiceTestDefinition {
|
||||
finalized_block_slot: Slot::new(0),
|
||||
justified_checkpoint: get_checkpoint(0),
|
||||
finalized_checkpoint: get_checkpoint(0),
|
||||
operations: ops,
|
||||
execution_payload_parent_hash: Some(ExecutionBlockHash::zero()),
|
||||
execution_payload_block_hash: Some(ExecutionBlockHash::zero()),
|
||||
spec: Some(gloas_spec()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -758,7 +997,7 @@ mod tests {
|
||||
let mut ops = vec![];
|
||||
|
||||
// Block at slot 31 — last pre-Gloas slot. Created as a V17 node because
|
||||
// gloas_fork_epoch = 1 → Gloas starts at slot 32.
|
||||
// gloas_fork_epoch = 1 means Gloas starts at slot 32.
|
||||
//
|
||||
// The test harness sets execution_status = Optimistic(ExecutionBlockHash::from_root(root)),
|
||||
// so this V17 node's EL block hash = ExecutionBlockHash::from_root(get_root(1)).
|
||||
@@ -909,6 +1148,18 @@ mod tests {
|
||||
test.run();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previous_slot_tiebreaker() {
|
||||
let test = get_gloas_previous_slot_tiebreaker_test_definition();
|
||||
test.run();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proposer_boost_flips_ancestor() {
|
||||
let test = get_gloas_proposer_boost_flips_ancestor_test_definition();
|
||||
test.run();
|
||||
}
|
||||
|
||||
/// Test that execution payload invalidation propagates across the V17→V29 fork
|
||||
/// boundary: after invalidating a V17 parent, head must not select any descendant.
|
||||
///
|
||||
|
||||
@@ -1262,6 +1262,90 @@ impl ProtoArray {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the canonical payload status of a block, matching the decision
|
||||
/// `get_head` would make between `(root, FULL)` and `(root, EMPTY)`.
|
||||
pub(crate) fn get_canonical_payload_status<E: EthSpec>(
|
||||
&self,
|
||||
root: Hash256,
|
||||
current_slot: Slot,
|
||||
proposer_boost_root: Hash256,
|
||||
justified_balances: &JustifiedBalances,
|
||||
spec: &ChainSpec,
|
||||
) -> Result<PayloadStatus, Error> {
|
||||
let proto_node_index = *self.indices.get(&root).ok_or(Error::NodeUnknown(root))?;
|
||||
let proto_node = self
|
||||
.nodes
|
||||
.get(proto_node_index)
|
||||
.ok_or(Error::InvalidNodeIndex(proto_node_index))?;
|
||||
|
||||
if !proto_node
|
||||
.payload_received()
|
||||
.map_err(|_| Error::InvalidNodeVariant { block_root: root })?
|
||||
{
|
||||
return Ok(PayloadStatus::Empty);
|
||||
}
|
||||
|
||||
let full_fc = IndexedForkChoiceNode {
|
||||
root,
|
||||
proto_node_index,
|
||||
payload_status: PayloadStatus::Full,
|
||||
};
|
||||
let empty_fc = IndexedForkChoiceNode {
|
||||
root,
|
||||
proto_node_index,
|
||||
payload_status: PayloadStatus::Empty,
|
||||
};
|
||||
|
||||
// Matches the hoisting optimization in `find_head`: `get_weight`'s spec-level
|
||||
// `should_apply_proposer_boost` check is precomputed once.
|
||||
let apply_proposer_boost =
|
||||
self.should_apply_proposer_boost::<E>(proposer_boost_root, justified_balances, spec)?;
|
||||
|
||||
let full_weight = self.get_weight::<E>(
|
||||
&full_fc,
|
||||
proto_node,
|
||||
apply_proposer_boost,
|
||||
proposer_boost_root,
|
||||
current_slot,
|
||||
justified_balances,
|
||||
spec,
|
||||
)?;
|
||||
|
||||
let empty_weight = self.get_weight::<E>(
|
||||
&empty_fc,
|
||||
proto_node,
|
||||
apply_proposer_boost,
|
||||
proposer_boost_root,
|
||||
current_slot,
|
||||
justified_balances,
|
||||
spec,
|
||||
)?;
|
||||
|
||||
match full_weight.cmp(&empty_weight) {
|
||||
std::cmp::Ordering::Greater => Ok(PayloadStatus::Full),
|
||||
std::cmp::Ordering::Less => Ok(PayloadStatus::Empty),
|
||||
std::cmp::Ordering::Equal => {
|
||||
let full_tb = self.get_payload_status_tiebreaker::<E>(
|
||||
&full_fc,
|
||||
proto_node,
|
||||
current_slot,
|
||||
proposer_boost_root,
|
||||
)?;
|
||||
let empty_tb = self.get_payload_status_tiebreaker::<E>(
|
||||
&empty_fc,
|
||||
proto_node,
|
||||
current_slot,
|
||||
proposer_boost_root,
|
||||
)?;
|
||||
if full_tb >= empty_tb {
|
||||
Ok(PayloadStatus::Full)
|
||||
} else {
|
||||
Ok(PayloadStatus::Empty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spec: `get_weight`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn get_weight<E: EthSpec>(
|
||||
@@ -1417,7 +1501,7 @@ impl ProtoArray {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_payload_status_tiebreaker<E: EthSpec>(
|
||||
pub(crate) fn get_payload_status_tiebreaker<E: EthSpec>(
|
||||
&self,
|
||||
fc_node: &IndexedForkChoiceNode,
|
||||
proto_node: &ProtoNode,
|
||||
|
||||
@@ -1053,6 +1053,24 @@ impl ProtoArrayForkChoice {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Returns the canonical payload status of a block, matching the decision
|
||||
/// `get_head` would make between `(root, FULL)` and `(root, EMPTY)`.
|
||||
pub fn get_canonical_payload_status<E: EthSpec>(
|
||||
&self,
|
||||
block_root: &Hash256,
|
||||
current_slot: Slot,
|
||||
proposer_boost_root: Hash256,
|
||||
spec: &ChainSpec,
|
||||
) -> Result<PayloadStatus, Error> {
|
||||
self.proto_array.get_canonical_payload_status::<E>(
|
||||
*block_root,
|
||||
current_slot,
|
||||
proposer_boost_root,
|
||||
&self.balances,
|
||||
spec,
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the weight of a given block.
|
||||
pub fn get_weight(&self, block_root: &Hash256) -> Option<u64> {
|
||||
let block_index = self.proto_array.indices.get(block_root)?;
|
||||
|
||||
Reference in New Issue
Block a user