From 3772323a847aadfb619f63ff5d9f786820c21ad9 Mon Sep 17 00:00:00 2001 From: Gabriel Fukushima Date: Thu, 27 Aug 2026 15:42:56 +1000 Subject: [PATCH 1/4] remove is shuffle stable from fulu onwards Signed-off-by: Gabriel Fukushima --- .../teku/spec/logic/common/util/ForkChoiceUtil.java | 13 ++++++++----- .../versions/fulu/util/ForkChoiceUtilFulu.java | 6 ++++++ specrefs/functions.yml | 2 ++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/common/util/ForkChoiceUtil.java b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/common/util/ForkChoiceUtil.java index 7ff8d617448..c76ae5a3b9e 100644 --- a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/common/util/ForkChoiceUtil.java +++ b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/common/util/ForkChoiceUtil.java @@ -213,20 +213,19 @@ 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 isProposerHeadReorgAllowed = isProposerHeadReorgAllowed(store, slot); final boolean isProposingOnTime = isProposingOnTime(store, slot); final boolean isHeadLate = isHeadLate(context.getBlockTimeliness(headNode.blockRoot())); final Optional maybeHead = store.getBlockIfAvailable(headNode.blockRoot()); if (!isHeadLate - || !isShufflingStableAndForkChoiceOk + || !isProposerHeadReorgAllowed || !isProposingOnTime || isProposerBoostActive || maybeHead.isEmpty()) { LOG.debug( - "getProposerHead - return headRoot - isHeadLate {}, isForkChoiceStableAndFinalizationOk {}, isProposingOnTime {}, isProposerBoostActive {}, head.isEmpty {}", + "getProposerHead - return headRoot - isHeadLate {}, isProposerHeadReorgAllowed {}, isProposingOnTime {}, isProposerBoostActive {}, head.isEmpty {}", isHeadLate, - isShufflingStableAndForkChoiceOk, + isProposerHeadReorgAllowed, isProposingOnTime, isProposerBoostActive, maybeHead.isEmpty()); @@ -378,6 +377,10 @@ boolean isForkChoiceStableAndFinalizationOk(final ReadOnlyStore store, final UIn return isShufflingStable(slot) && isFinalizationOk(store, slot); } + protected boolean isProposerHeadReorgAllowed(final ReadOnlyStore store, final UInt64 slot) { + return isForkChoiceStableAndFinalizationOk(store, slot); + } + boolean isProposerBoostActive(final ReadOnlyStore store, final Bytes32 headRoot) { return store.getProposerBoostRoot().map(root -> !root.equals(headRoot)).orElse(false); } diff --git a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/versions/fulu/util/ForkChoiceUtilFulu.java b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/versions/fulu/util/ForkChoiceUtilFulu.java index b46e8dd2ac5..434daf52097 100644 --- a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/versions/fulu/util/ForkChoiceUtilFulu.java +++ b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/versions/fulu/util/ForkChoiceUtilFulu.java @@ -17,6 +17,7 @@ import tech.pegasys.teku.infrastructure.unsigned.UInt64; import tech.pegasys.teku.spec.config.SpecConfig; import tech.pegasys.teku.spec.datastructures.blocks.SignedBeaconBlock; +import tech.pegasys.teku.spec.datastructures.forkchoice.ReadOnlyStore; import tech.pegasys.teku.spec.logic.common.helpers.BeaconStateAccessors; import tech.pegasys.teku.spec.logic.common.helpers.MiscHelpers; import tech.pegasys.teku.spec.logic.common.statetransition.availability.AvailabilityChecker; @@ -59,6 +60,11 @@ public boolean isDataAvailabilityRequiredForTimeliness() { return true; } + @Override + protected boolean isProposerHeadReorgAllowed(final ReadOnlyStore store, final UInt64 slot) { + return isFinalizationOk(store, slot); + } + @Override public Optional toVersionFulu() { return Optional.of(this); diff --git a/specrefs/functions.yml b/specrefs/functions.yml index 074d53c8fe2..1cd29139776 100644 --- a/specrefs/functions.yml +++ b/specrefs/functions.yml @@ -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 isProposerHeadReorgAllowed( spec: | def get_proposer_head(store: Store, head_node: ForkChoiceNode, slot: Slot) -> ForkChoiceNode: From 8722ce218e586c8c72d8d1e55b7fdaf24a02239c Mon Sep 17 00:00:00 2001 From: Gabriel Fukushima Date: Thu, 27 Aug 2026 15:43:29 +1000 Subject: [PATCH 2/4] add test Signed-off-by: Gabriel Fukushima --- .../util/ForkChoiceUtilProposerHeadTest.java | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 ethereum/spec/src/test/java/tech/pegasys/teku/spec/logic/common/util/ForkChoiceUtilProposerHeadTest.java diff --git a/ethereum/spec/src/test/java/tech/pegasys/teku/spec/logic/common/util/ForkChoiceUtilProposerHeadTest.java b/ethereum/spec/src/test/java/tech/pegasys/teku/spec/logic/common/util/ForkChoiceUtilProposerHeadTest.java new file mode 100644 index 00000000000..27ee1076d6e --- /dev/null +++ b/ethereum/spec/src/test/java/tech/pegasys/teku/spec/logic/common/util/ForkChoiceUtilProposerHeadTest.java @@ -0,0 +1,112 @@ +/* + * 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; + +@TestSpecContext(milestone = {BELLATRIX, FULU}) +class ForkChoiceUtilProposerHeadTest { + + @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 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; + } + } +} From 3c53068ab17ec8054bea8b64a50e0352691cedf9 Mon Sep 17 00:00:00 2001 From: Gabriel Fukushima Date: Mon, 31 Aug 2026 14:28:36 +1000 Subject: [PATCH 3/4] fix is_past_slot reference Signed-off-by: Gabriel Fukushima --- specrefs/functions.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specrefs/functions.yml b/specrefs/functions.yml index 1cd29139776..8dea95ff9a6 100644 --- a/specrefs/functions.yml +++ b/specrefs/functions.yml @@ -7508,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: | def is_past_slot( From e52c6ad25360de7f1bcf13a50e7ce8fdad8de5e0 Mon Sep 17 00:00:00 2001 From: Gabriel Fukushima Date: Tue, 1 Sep 2026 13:01:53 +1000 Subject: [PATCH 4/4] address review comment Signed-off-by: Gabriel Fukushima --- .../logic/common/util/ForkChoiceUtil.java | 40 ++++++++++++------- .../fulu/util/ForkChoiceUtilFulu.java | 10 +++-- ... => ForkChoiceUtilReorgMilestoneTest.java} | 6 ++- .../common/util/ForkChoiceUtilReorgTest.java | 6 +-- specrefs/functions.yml | 2 +- 5 files changed, 41 insertions(+), 23 deletions(-) rename ethereum/spec/src/test/java/tech/pegasys/teku/spec/logic/common/util/{ForkChoiceUtilProposerHeadTest.java => ForkChoiceUtilReorgMilestoneTest.java} (96%) diff --git a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/common/util/ForkChoiceUtil.java b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/common/util/ForkChoiceUtil.java index c76ae5a3b9e..3551088be27 100644 --- a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/common/util/ForkChoiceUtil.java +++ b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/common/util/ForkChoiceUtil.java @@ -213,19 +213,22 @@ public ForkChoiceNode getProposerHead( LOG.debug("start getProposerHead"); final ReadOnlyStore store = context.getStore(); final boolean isProposerBoostActive = isProposerBoostActive(store, headNode.blockRoot()); - final boolean isProposerHeadReorgAllowed = isProposerHeadReorgAllowed(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 maybeHead = store.getBlockIfAvailable(headNode.blockRoot()); if (!isHeadLate - || !isProposerHeadReorgAllowed + || !isProposerStable + || !isFinalizationOk || !isProposingOnTime || isProposerBoostActive || maybeHead.isEmpty()) { LOG.debug( - "getProposerHead - return headRoot - isHeadLate {}, isProposerHeadReorgAllowed {}, isProposingOnTime {}, isProposerBoostActive {}, head.isEmpty {}", + "getProposerHead - return headRoot - isHeadLate {}, isProposerStable {}, isFinalizationOk {}, isProposingOnTime {}, isProposerBoostActive {}, head.isEmpty {}", isHeadLate, - isProposerHeadReorgAllowed, + isProposerStable, + isFinalizationOk, isProposingOnTime, isProposerBoostActive, maybeHead.isEmpty()); @@ -281,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 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; @@ -373,12 +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); - } - - protected boolean isProposerHeadReorgAllowed(final ReadOnlyStore store, final UInt64 slot) { - return isForkChoiceStableAndFinalizationOk(store, slot); + /** + * Whether the proposer for {@code slot} is already fixed, so that re-orging the previous block + * cannot change it. + * + *

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) { diff --git a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/versions/fulu/util/ForkChoiceUtilFulu.java b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/versions/fulu/util/ForkChoiceUtilFulu.java index 434daf52097..e111468e943 100644 --- a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/versions/fulu/util/ForkChoiceUtilFulu.java +++ b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/versions/fulu/util/ForkChoiceUtilFulu.java @@ -17,7 +17,6 @@ import tech.pegasys.teku.infrastructure.unsigned.UInt64; import tech.pegasys.teku.spec.config.SpecConfig; import tech.pegasys.teku.spec.datastructures.blocks.SignedBeaconBlock; -import tech.pegasys.teku.spec.datastructures.forkchoice.ReadOnlyStore; import tech.pegasys.teku.spec.logic.common.helpers.BeaconStateAccessors; import tech.pegasys.teku.spec.logic.common.helpers.MiscHelpers; import tech.pegasys.teku.spec.logic.common.statetransition.availability.AvailabilityChecker; @@ -60,9 +59,14 @@ 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 isProposerHeadReorgAllowed(final ReadOnlyStore store, final UInt64 slot) { - return isFinalizationOk(store, slot); + protected boolean isProposerStable(final UInt64 slot) { + return true; } @Override diff --git a/ethereum/spec/src/test/java/tech/pegasys/teku/spec/logic/common/util/ForkChoiceUtilProposerHeadTest.java b/ethereum/spec/src/test/java/tech/pegasys/teku/spec/logic/common/util/ForkChoiceUtilReorgMilestoneTest.java similarity index 96% rename from ethereum/spec/src/test/java/tech/pegasys/teku/spec/logic/common/util/ForkChoiceUtilProposerHeadTest.java rename to ethereum/spec/src/test/java/tech/pegasys/teku/spec/logic/common/util/ForkChoiceUtilReorgMilestoneTest.java index 27ee1076d6e..a56930c5570 100644 --- a/ethereum/spec/src/test/java/tech/pegasys/teku/spec/logic/common/util/ForkChoiceUtilProposerHeadTest.java +++ b/ethereum/spec/src/test/java/tech/pegasys/teku/spec/logic/common/util/ForkChoiceUtilReorgMilestoneTest.java @@ -37,8 +37,12 @@ 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 ForkChoiceUtilProposerHeadTest { +class ForkChoiceUtilReorgMilestoneTest { @TestTemplate void getProposerHeadHandlesShufflingStabilityAtEpochBoundary(final SpecContext specContext) { diff --git a/ethereum/spec/src/test/java/tech/pegasys/teku/spec/logic/common/util/ForkChoiceUtilReorgTest.java b/ethereum/spec/src/test/java/tech/pegasys/teku/spec/logic/common/util/ForkChoiceUtilReorgTest.java index 6f43f2c146a..450ebcb0c8d 100644 --- a/ethereum/spec/src/test/java/tech/pegasys/teku/spec/logic/common/util/ForkChoiceUtilReorgTest.java +++ b/ethereum/spec/src/test/java/tech/pegasys/teku/spec/logic/common/util/ForkChoiceUtilReorgTest.java @@ -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); } diff --git a/specrefs/functions.yml b/specrefs/functions.yml index 8dea95ff9a6..892ea733bfe 100644 --- a/specrefs/functions.yml +++ b/specrefs/functions.yml @@ -5269,7 +5269,7 @@ - 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 isProposerHeadReorgAllowed( + search: protected boolean isProposerStable( spec: | def get_proposer_head(store: Store, head_node: ForkChoiceNode, slot: Slot) -> ForkChoiceNode: