Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -213,20 +213,22 @@ public ForkChoiceNode getProposerHead(
LOG.debug("start getProposerHead");
final ReadOnlyStore store = context.getStore();
final boolean isProposerBoostActive = isProposerBoostActive(store, headNode.blockRoot());
final boolean isShufflingStableAndForkChoiceOk =
isForkChoiceStableAndFinalizationOk(store, slot);
final boolean isProposerStable = isProposerStable(slot);
final boolean isFinalizationOk = isFinalizationOk(store, slot);
final boolean isProposingOnTime = isProposingOnTime(store, slot);
final boolean isHeadLate = isHeadLate(context.getBlockTimeliness(headNode.blockRoot()));
final Optional<SignedBeaconBlock> maybeHead = store.getBlockIfAvailable(headNode.blockRoot());
if (!isHeadLate
|| !isShufflingStableAndForkChoiceOk
|| !isProposerStable
|| !isFinalizationOk
|| !isProposingOnTime
|| isProposerBoostActive
|| maybeHead.isEmpty()) {
LOG.debug(
"getProposerHead - return headRoot - isHeadLate {}, isForkChoiceStableAndFinalizationOk {}, isProposingOnTime {}, isProposerBoostActive {}, head.isEmpty {}",
"getProposerHead - return headRoot - isHeadLate {}, isProposerStable {}, isFinalizationOk {}, isProposingOnTime {}, isProposerBoostActive {}, head.isEmpty {}",
isHeadLate,
isShufflingStableAndForkChoiceOk,
isProposerStable,
isFinalizationOk,
isProposingOnTime,
isProposerBoostActive,
maybeHead.isEmpty());
Expand Down Expand Up @@ -282,15 +284,19 @@ public boolean shouldOverrideForkChoiceUpdate(
final SignedBeaconBlock head = maybeHead.orElseThrow();
final UInt64 currentSlot = getCurrentSlot(store);
final UInt64 proposalSlot = headSlot.increment();
final boolean isShufflingStableAndForkChoiceOk =
isForkChoiceStableAndFinalizationOk(store, proposalSlot);
// Unlike getProposerHead, this Teku-only optimization keeps the shuffling stability check at
// every milestone: preparing a payload on the parent is only a latency win, so it stays
// conservative at epoch boundaries.
final boolean isShufflingStable = isShufflingStable(proposalSlot);
final boolean isFinalizationOk = isFinalizationOk(store, proposalSlot);
final boolean isFfgCompetitive = isFfgCompetitive(store, headRoot, head.getParentRoot());
final Optional<UInt64> maybeParentSlot =
store.getForkChoiceStrategy().blockSlot(head.getParentRoot());
if (!isShufflingStableAndForkChoiceOk || !isFfgCompetitive || maybeParentSlot.isEmpty()) {
if (!isShufflingStable || !isFinalizationOk || !isFfgCompetitive || maybeParentSlot.isEmpty()) {
LOG.debug(
"shouldOverrideForkChoiceUpdate isShufflingStableAndForkChoiceOk {}, isFfgCompetitive {}, maybeParentSlot {}",
isShufflingStableAndForkChoiceOk,
"shouldOverrideForkChoiceUpdate isShufflingStable {}, isFinalizationOk {}, isFfgCompetitive {}, maybeParentSlot {}",
isShufflingStable,
isFinalizationOk,
isFfgCompetitive,
maybeParentSlot);
return false;
Expand Down Expand Up @@ -374,8 +380,15 @@ boolean isCurrentSlotOk(final SignedBeaconBlock head, final UInt64 proposalSlot)
return proposalSlot.equals(head.getSlot().increment());
}

boolean isForkChoiceStableAndFinalizationOk(final ReadOnlyStore store, final UInt64 slot) {
return isShufflingStable(slot) && isFinalizationOk(store, slot);
/**
* Whether the proposer for {@code slot} is already fixed, so that re-orging the previous block
* cannot change it.
*
* <p>Before Fulu the proposer is only known to be stable away from an epoch boundary, so this
* falls back to {@code is_shuffling_stable}.
*/
protected boolean isProposerStable(final UInt64 slot) {
return isShufflingStable(slot);
}

boolean isProposerBoostActive(final ReadOnlyStore store, final Bytes32 headRoot) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ public boolean isDataAvailabilityRequiredForTimeliness() {
return true;
}

/**
* From Fulu (EIP-7917) the proposer lookahead fixes proposer assignments before the epoch
* boundary, so re-orging a late block at the end of the previous epoch cannot change the current
* slot's proposer. The proposer is therefore always stable.
*/
@Override
protected boolean isProposerStable(final UInt64 slot) {
return true;
}

@Override
public Optional<ForkChoiceUtilFulu> toVersionFulu() {
return Optional.of(this);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
* Copyright Consensys Software Inc., 2026
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/

package tech.pegasys.teku.spec.logic.common.util;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static tech.pegasys.teku.spec.SpecMilestone.BELLATRIX;
import static tech.pegasys.teku.spec.SpecMilestone.FULU;

import java.util.Optional;
import org.apache.tuweni.bytes.Bytes32;
import org.junit.jupiter.api.TestTemplate;
import tech.pegasys.teku.infrastructure.unsigned.UInt64;
import tech.pegasys.teku.spec.Spec;
import tech.pegasys.teku.spec.SpecMilestone;
import tech.pegasys.teku.spec.TestSpecContext;
import tech.pegasys.teku.spec.TestSpecInvocationContextProvider.SpecContext;
import tech.pegasys.teku.spec.datastructures.blocks.SignedBlockAndState;
import tech.pegasys.teku.spec.datastructures.forkchoice.ForkChoiceNode;
import tech.pegasys.teku.spec.datastructures.forkchoice.ForkChoiceReorgContext;
import tech.pegasys.teku.spec.datastructures.forkchoice.ProtoNodeData;
import tech.pegasys.teku.spec.datastructures.forkchoice.ReadOnlyForkChoiceStrategy;
import tech.pegasys.teku.spec.datastructures.forkchoice.ReadOnlyStore;
import tech.pegasys.teku.spec.datastructures.state.beaconstate.BeaconState;
import tech.pegasys.teku.spec.logic.common.statetransition.exceptions.EpochProcessingException;
import tech.pegasys.teku.spec.logic.common.statetransition.exceptions.SlotProcessingException;

/**
* Milestone-parameterized counterpart to {@link ForkChoiceUtilReorgTest}, covering proposer re-org
* behaviour that differs across forks.
*/
@TestSpecContext(milestone = {BELLATRIX, FULU})
class ForkChoiceUtilReorgMilestoneTest {

@TestTemplate
void getProposerHeadHandlesShufflingStabilityAtEpochBoundary(final SpecContext specContext) {
final Spec spec = specContext.getSpec();
final SpecMilestone milestone = specContext.getSpecMilestone();
final UInt64 proposalSlot = spec.computeStartSlotAtEpoch(UInt64.ONE);
final UInt64 headSlot = proposalSlot.minus(UInt64.ONE);
final SignedBlockAndState headBlockAndState =
specContext.getDataStructureUtil().randomSignedBlockAndState(headSlot);
final ForkChoiceNode head = ForkChoiceNode.createBase(headBlockAndState.getRoot());
final ForkChoiceNode parent = ForkChoiceNode.createBase(headBlockAndState.getParentRoot());

final ReadOnlyStore store = mock(ReadOnlyStore.class);
final ReadOnlyForkChoiceStrategy forkChoiceStrategy = mock(ReadOnlyForkChoiceStrategy.class);
final ProtoNodeData headNodeData = mock(ProtoNodeData.class);
final UInt64 genesisTimeMillis = headBlockAndState.getState().getGenesisTime().times(1000);

when(store.getForkChoiceStrategy()).thenReturn(forkChoiceStrategy);
when(store.getGenesisTimeMillis()).thenReturn(genesisTimeMillis);
when(store.getTimeInMillis())
.thenReturn(
genesisTimeMillis.plus(
proposalSlot.times(spec.getGenesisSpecConfig().getSlotDurationMillis())));
when(store.getFinalizedCheckpoint())
.thenReturn(specContext.getDataStructureUtil().randomCheckpoint(UInt64.ZERO));
when(store.getProposerBoostRoot()).thenReturn(Optional.empty());
when(store.getBlockIfAvailable(head.blockRoot()))
.thenReturn(headBlockAndState.getSignedBeaconBlock());
when(store.isFfgCompetitive(head.blockRoot(), parent.blockRoot()))
.thenReturn(Optional.of(true));
when(store.getReorgThreshold()).thenReturn(UInt64.ONE);
when(store.getParentThreshold()).thenReturn(UInt64.ONE);
when(forkChoiceStrategy.blockSlot(parent.blockRoot()))
.thenReturn(Optional.of(headSlot.minus(UInt64.ONE)));
when(forkChoiceStrategy.getBlockData(head.blockRoot())).thenReturn(Optional.of(headNodeData));
when(headNodeData.getWeight()).thenReturn(UInt64.ZERO);
when(forkChoiceStrategy.getParentBeaconBlockNode(head)).thenReturn(Optional.of(parent));

final ForkChoiceReorgContext context = new LateBlockReorgContext(store);
final ForkChoiceNode proposerHead =
spec.forMilestone(milestone)
.getForkChoiceUtil()
.getProposerHead(context, head, proposalSlot);

assertThat(proposerHead).isEqualTo(milestone.isGreaterThanOrEqualTo(FULU) ? parent : head);
}

private record LateBlockReorgContext(ReadOnlyStore store) implements ForkChoiceReorgContext {

@Override
public ReadOnlyStore getStore() {
return store;
}

@Override
public Optional<ForkChoiceUtil.BlockTimeliness> getBlockTimeliness(final Bytes32 root) {
return Optional.of(new ForkChoiceUtil.BlockTimeliness(false, false));
}

@Override
public boolean isValidatorConnected(final int validatorIndex, final UInt64 slot) {
return false;
}

@Override
public BeaconState processSlots(final BeaconState state, final UInt64 slot)
throws SlotProcessingException, EpochProcessingException {
return state;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,13 @@ void isProposingOnTimeHandlesBoundaryConditions(

@ParameterizedTest
@MethodSource("forkChoiceStabilityCases")
void isForkChoiceStableAndFinalizationOkHandlesBoundaryConditions(
void isProposerStableAndFinalizationOkHandlesBoundaryConditions(
final int slot, final boolean expectedResult) {
final ReorgTestSetup setup = new ReorgTestSetup();

assertThat(
setup.baseForkChoiceUtil.isForkChoiceStableAndFinalizationOk(
setup.store, UInt64.valueOf(slot)))
setup.baseForkChoiceUtil.isProposerStable(UInt64.valueOf(slot))
&& setup.baseForkChoiceUtil.isFinalizationOk(setup.store, UInt64.valueOf(slot)))
.isEqualTo(expectedResult);
}

Expand Down
6 changes: 4 additions & 2 deletions specrefs/functions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5268,6 +5268,8 @@
sources:
- file: ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/common/util/ForkChoiceUtil.java
search: public ForkChoiceNode getProposerHead(
- file: ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/versions/fulu/util/ForkChoiceUtilFulu.java
search: protected boolean isProposerStable(
spec: |
<spec fn="get_proposer_head" fork="fulu" hash="b8612966">
def get_proposer_head(store: Store, head_node: ForkChoiceNode, slot: Slot) -> ForkChoiceNode:
Expand Down Expand Up @@ -7506,8 +7508,8 @@

- name: is_past_slot#gloas
sources:
- file: ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/validation/ProposerPreferencesGossipValidator.java
search: proposal slot has already passed
- file: ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/validation/GossipValidationHelper.java
search: public boolean hasSlotStarted(
spec: |
<spec fn="is_past_slot" fork="gloas" hash="4bd9aba5">
def is_past_slot(
Expand Down
Loading