diff --git a/data/beaconrestapi/src/integration-test/resources/tech/pegasys/teku/beaconrestapi/beacon/paths/_eth_v1_events.json b/data/beaconrestapi/src/integration-test/resources/tech/pegasys/teku/beaconrestapi/beacon/paths/_eth_v1_events.json index 665cd1b0c19..d51a9c0c5fd 100644 --- a/data/beaconrestapi/src/integration-test/resources/tech/pegasys/teku/beaconrestapi/beacon/paths/_eth_v1_events.json +++ b/data/beaconrestapi/src/integration-test/resources/tech/pegasys/teku/beaconrestapi/beacon/paths/_eth_v1_events.json @@ -9,7 +9,7 @@ "in" : "query", "schema" : { "type" : "string", - "description" : "Event types to subscribe to. Supported event types: [`attestation`, `attester_slashing`, `blob_sidecar`, `block_gossip`, `block`, `bls_to_execution_change`, `chain_reorg`, `contribution_and_proof`, `data_column_sidecar`, `execution_payload_available`, `execution_payload_bid`, `execution_payload_gossip`, `execution_payload`, `finalized_checkpoint`, `head_v2`, `head`, `payload_attestation_message`, `payload_attributes`, `proposer_preferences`, `proposer_slashing`, `single_attestation`, `sync_state`, `voluntary_exit`]", + "description" : "Event types to subscribe to. Supported event types: [`attestation`, `attester_slashing`, `blob_sidecar`, `block_gossip`, `block`, `bls_to_execution_change`, `chain_reorg`, `contribution_and_proof`, `data_column_sidecar`, `execution_payload_available`, `execution_payload_bid`, `execution_payload_gossip`, `execution_payload`, `fast_confirmation`, `finalized_checkpoint`, `head_v2`, `head`, `payload_attestation_message`, `payload_attributes`, `proposer_preferences`, `proposer_slashing`, `single_attestation`, `sync_state`, `voluntary_exit`]", "example" : "head" } } ], diff --git a/data/beaconrestapi/src/main/java/tech/pegasys/teku/beaconrestapi/handlers/v1/events/EventSubscriptionManager.java b/data/beaconrestapi/src/main/java/tech/pegasys/teku/beaconrestapi/handlers/v1/events/EventSubscriptionManager.java index 9e8b5900628..197a78ccbf2 100644 --- a/data/beaconrestapi/src/main/java/tech/pegasys/teku/beaconrestapi/handlers/v1/events/EventSubscriptionManager.java +++ b/data/beaconrestapi/src/main/java/tech/pegasys/teku/beaconrestapi/handlers/v1/events/EventSubscriptionManager.java @@ -60,6 +60,7 @@ import tech.pegasys.teku.statetransition.execution.ReceivedExecutionPayloadBidEventsChannel; import tech.pegasys.teku.statetransition.execution.ReceivedExecutionPayloadEventsChannel; import tech.pegasys.teku.statetransition.forkchoice.ForkChoiceUpdatedResultSubscriber.ForkChoiceUpdatedResultNotification; +import tech.pegasys.teku.statetransition.forkchoice.fastconfirmation.FastConfirmationEventChannel; import tech.pegasys.teku.statetransition.validation.InternalValidationResult; import tech.pegasys.teku.storage.api.ChainHeadChannel; import tech.pegasys.teku.storage.api.FinalizedCheckpointChannel; @@ -70,7 +71,8 @@ public class EventSubscriptionManager FinalizedCheckpointChannel, ReceivedBlockEventsChannel, ReceivedExecutionPayloadEventsChannel, - ReceivedExecutionPayloadBidEventsChannel { + ReceivedExecutionPayloadBidEventsChannel, + FastConfirmationEventChannel { private static final Logger LOG = LogManager.getLogger(); private final Spec spec; @@ -104,6 +106,7 @@ public EventSubscriptionManager( eventChannels.subscribe(ReceivedBlockEventsChannel.class, this); eventChannels.subscribe(ReceivedExecutionPayloadEventsChannel.class, this); eventChannels.subscribe(ReceivedExecutionPayloadBidEventsChannel.class, this); + eventChannels.subscribe(FastConfirmationEventChannel.class, this); syncDataProvider.subscribeToSyncStateChanges(this::onSyncStateChange); nodeDataProvider.subscribeToReceivedBlobSidecar(this::onNewBlobSidecar); nodeDataProvider.subscribeToAttesterSlashing(this::onNewAttesterSlashing); @@ -202,6 +205,14 @@ public void onNewFinalizedCheckpoint( notifySubscribersOfEvent(EventType.finalized_checkpoint, event); } + @Override + public void onFastConfirmation( + final Bytes32 confirmedRoot, final UInt64 confirmedSlot, final UInt64 currentSlot) { + notifySubscribersOfEvent( + EventType.fast_confirmation, + new FastConfirmationEvent(confirmedRoot, confirmedSlot, currentSlot)); + } + @Override public void onBlockValidated(final SignedBeaconBlock block) { onNewBlockGossip(block); diff --git a/data/beaconrestapi/src/main/java/tech/pegasys/teku/beaconrestapi/handlers/v1/events/FastConfirmationEvent.java b/data/beaconrestapi/src/main/java/tech/pegasys/teku/beaconrestapi/handlers/v1/events/FastConfirmationEvent.java new file mode 100644 index 00000000000..d60dcafdf6a --- /dev/null +++ b/data/beaconrestapi/src/main/java/tech/pegasys/teku/beaconrestapi/handlers/v1/events/FastConfirmationEvent.java @@ -0,0 +1,60 @@ +/* + * 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.beaconrestapi.handlers.v1.events; + +import static tech.pegasys.teku.infrastructure.json.types.CoreTypes.BYTES32_TYPE; +import static tech.pegasys.teku.infrastructure.json.types.CoreTypes.UINT64_TYPE; + +import org.apache.tuweni.bytes.Bytes32; +import tech.pegasys.teku.infrastructure.json.types.SerializableTypeDefinition; +import tech.pegasys.teku.infrastructure.unsigned.UInt64; + +public class FastConfirmationEvent extends Event { + + static final SerializableTypeDefinition FAST_CONFIRMATION_EVENT_TYPE = + SerializableTypeDefinition.object(FastConfirmationData.class) + .name("FastConfirmationEvent") + .withField("block", BYTES32_TYPE, FastConfirmationData::getBlock) + .withField("slot", UINT64_TYPE, FastConfirmationData::getSlot) + .withField("current_slot", UINT64_TYPE, FastConfirmationData::getCurrentSlot) + .build(); + + FastConfirmationEvent(final Bytes32 block, final UInt64 slot, final UInt64 currentSlot) { + super(FAST_CONFIRMATION_EVENT_TYPE, new FastConfirmationData(block, slot, currentSlot)); + } + + public static class FastConfirmationData { + public final Bytes32 block; + public final UInt64 slot; + public final UInt64 currentSlot; + + FastConfirmationData(final Bytes32 block, final UInt64 slot, final UInt64 currentSlot) { + this.block = block; + this.slot = slot; + this.currentSlot = currentSlot; + } + + private Bytes32 getBlock() { + return block; + } + + private UInt64 getSlot() { + return slot; + } + + private UInt64 getCurrentSlot() { + return currentSlot; + } + } +} diff --git a/data/beaconrestapi/src/test/java/tech/pegasys/teku/beaconrestapi/handlers/v1/events/EventSubscriptionManagerTest.java b/data/beaconrestapi/src/test/java/tech/pegasys/teku/beaconrestapi/handlers/v1/events/EventSubscriptionManagerTest.java index 52076d869b9..eb01806025a 100644 --- a/data/beaconrestapi/src/test/java/tech/pegasys/teku/beaconrestapi/handlers/v1/events/EventSubscriptionManagerTest.java +++ b/data/beaconrestapi/src/test/java/tech/pegasys/teku/beaconrestapi/handlers/v1/events/EventSubscriptionManagerTest.java @@ -121,6 +121,9 @@ public class EventSubscriptionManagerTest { private final FinalizedCheckpointEvent sampleCheckpointEvent = new FinalizedCheckpointEvent(data.randomBytes32(), data.randomBytes32(), epoch, false); + private final FastConfirmationEvent sampleFastConfirmationEvent = + new FastConfirmationEvent(data.randomBytes32(), data.randomUInt64(), data.randomUInt64()); + private final SyncState sampleSyncState = SyncState.IN_SYNC; private final SignedBeaconBlock sampleBlock = data.randomSignedBeaconBlock(0); private final BlobSidecar sampleBlobSidecar = data.randomBlobSidecar(); @@ -281,6 +284,15 @@ void shouldPropagateFinalizedCheckpointMessages() throws IOException { checkEvent("finalized_checkpoint", sampleCheckpointEvent); } + @Test + void shouldPropagateFastConfirmationMessages() throws IOException { + when(req.getQueryString()).thenReturn("&topics=fast_confirmation"); + manager.registerClient(client1); + + triggerFastConfirmationEvent(); + checkEvent("fast_confirmation", sampleFastConfirmationEvent); + } + @Test void shouldPropagateSyncState() throws IOException { when(req.getQueryString()).thenReturn("&topics=sync_state"); @@ -613,6 +625,14 @@ private void triggerFinalizedCheckpointEvent() { asyncRunner.executeQueuedActions(); } + private void triggerFastConfirmationEvent() { + manager.onFastConfirmation( + sampleFastConfirmationEvent.getData().block, + sampleFastConfirmationEvent.getData().slot, + sampleFastConfirmationEvent.getData().currentSlot); + asyncRunner.executeQueuedActions(); + } + private void triggerReorgEvent() { manager.chainHeadUpdated( chainReorgEvent.getData().getSlot(), diff --git a/data/serializer/src/main/java/tech/pegasys/teku/api/response/EventType.java b/data/serializer/src/main/java/tech/pegasys/teku/api/response/EventType.java index c918d213662..7806fde323c 100644 --- a/data/serializer/src/main/java/tech/pegasys/teku/api/response/EventType.java +++ b/data/serializer/src/main/java/tech/pegasys/teku/api/response/EventType.java @@ -39,7 +39,8 @@ public enum EventType { execution_payload_available, execution_payload_bid, payload_attestation_message, - proposer_preferences; + proposer_preferences, + fast_confirmation; public static List getTopics(final List topics) { return topics.stream().map(EventType::valueOf).toList(); diff --git a/eth-reference-tests/src/referenceTest/java/tech/pegasys/teku/reference/Eth2ReferenceTestCase.java b/eth-reference-tests/src/referenceTest/java/tech/pegasys/teku/reference/Eth2ReferenceTestCase.java index 26da6c23e9f..1f9ca2c45c1 100644 --- a/eth-reference-tests/src/referenceTest/java/tech/pegasys/teku/reference/Eth2ReferenceTestCase.java +++ b/eth-reference-tests/src/referenceTest/java/tech/pegasys/teku/reference/Eth2ReferenceTestCase.java @@ -52,22 +52,12 @@ public abstract class Eth2ReferenceTestCase { .putAll(OperationsTestExecutor.OPERATIONS_TEST_TYPES) .putAll(SanityTests.SANITY_TEST_TYPES) .putAll(GossipTests.GOSSIP_TEST_TYPES) + .putAll(ForkChoiceTestExecutor.FAST_CONFIRMATION_TEST_TYPES) .put("slashing-protection-interchange", new SlashingProtectionInterchangeTestExecutor()) .put("light_client/single_merkle_proof", TestExecutor.IGNORE_TESTS) .put("light_client/sync", TestExecutor.IGNORE_TESTS) .put("light_client/update_ranking", TestExecutor.IGNORE_TESTS) .put("light_client/data_collection", TestExecutor.IGNORE_TESTS) - // TODO: Fast confirmation reference tests implementation - .put("fast_confirmation/basic", TestExecutor.IGNORE_TESTS) - .put("fast_confirmation/current_epoch", TestExecutor.IGNORE_TESTS) - .put("fast_confirmation/empty_slots", TestExecutor.IGNORE_TESTS) - .put("fast_confirmation/ffg", TestExecutor.IGNORE_TESTS) - .put("fast_confirmation/is_one_confirmed", TestExecutor.IGNORE_TESTS) - .put("fast_confirmation/previous_epoch", TestExecutor.IGNORE_TESTS) - .put("fast_confirmation/reconfirmation", TestExecutor.IGNORE_TESTS) - .put("fast_confirmation/restart_gu", TestExecutor.IGNORE_TESTS) - .put("fast_confirmation/revert_finality", TestExecutor.IGNORE_TESTS) - .put("fast_confirmation/variables", TestExecutor.IGNORE_TESTS) .build(); private static final ImmutableMap PHASE_0_TEST_TYPES = diff --git a/eth-reference-tests/src/referenceTest/java/tech/pegasys/teku/reference/phase0/forkchoice/ForkChoiceTestExecutor.java b/eth-reference-tests/src/referenceTest/java/tech/pegasys/teku/reference/phase0/forkchoice/ForkChoiceTestExecutor.java index edae2190072..c0045532b30 100644 --- a/eth-reference-tests/src/referenceTest/java/tech/pegasys/teku/reference/phase0/forkchoice/ForkChoiceTestExecutor.java +++ b/eth-reference-tests/src/referenceTest/java/tech/pegasys/teku/reference/phase0/forkchoice/ForkChoiceTestExecutor.java @@ -47,8 +47,10 @@ import tech.pegasys.teku.infrastructure.async.AsyncRunnerFactory; import tech.pegasys.teku.infrastructure.async.MetricTrackingExecutorFactory; import tech.pegasys.teku.infrastructure.async.SafeFuture; +import tech.pegasys.teku.infrastructure.async.SyncAsyncRunner; import tech.pegasys.teku.infrastructure.async.eventthread.InlineEventThread; import tech.pegasys.teku.infrastructure.metrics.StubMetricsSystem; +import tech.pegasys.teku.infrastructure.time.StubTimeProvider; import tech.pegasys.teku.infrastructure.unsigned.UInt64; import tech.pegasys.teku.kzg.KZG; import tech.pegasys.teku.kzg.KZGProof; @@ -68,6 +70,7 @@ import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.PayloadAttestationMessage; import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.SignedExecutionPayloadEnvelope; import tech.pegasys.teku.spec.datastructures.execution.PowBlock; +import tech.pegasys.teku.spec.datastructures.forkchoice.FastConfirmationStore; import tech.pegasys.teku.spec.datastructures.forkchoice.ForkChoiceNode; import tech.pegasys.teku.spec.datastructures.forkchoice.ForkChoicePayloadStatus; import tech.pegasys.teku.spec.datastructures.forkchoice.ProtoNodeData; @@ -97,6 +100,8 @@ import tech.pegasys.teku.statetransition.forkchoice.MergeTransitionBlockValidator; import tech.pegasys.teku.statetransition.forkchoice.NoopForkChoiceNotifier; import tech.pegasys.teku.statetransition.forkchoice.TickProcessor; +import tech.pegasys.teku.statetransition.forkchoice.fastconfirmation.FastConfirmationEventChannel; +import tech.pegasys.teku.statetransition.forkchoice.fastconfirmation.FastConfirmationTracker; import tech.pegasys.teku.statetransition.payloadattestation.ValidatablePayloadAttestationMessage; import tech.pegasys.teku.statetransition.util.DebugDataDumper; import tech.pegasys.teku.statetransition.util.RPCFetchDelayProvider; @@ -141,9 +146,30 @@ public class ForkChoiceTestExecutor implements TestExecutor { .put("fork_choice_compliance/shuffling_test", new ForkChoiceTestExecutor()) .build(); + public static final ImmutableMap FAST_CONFIRMATION_TEST_TYPES = + ImmutableMap.builder() + .put("fast_confirmation/basic", new ForkChoiceTestExecutor(true)) + .put("fast_confirmation/current_epoch", new ForkChoiceTestExecutor(true)) + .put("fast_confirmation/empty_slots", new ForkChoiceTestExecutor(true)) + .put("fast_confirmation/ffg", new ForkChoiceTestExecutor(true)) + .put("fast_confirmation/is_one_confirmed", new ForkChoiceTestExecutor(true)) + .put("fast_confirmation/previous_epoch", new ForkChoiceTestExecutor(true)) + .put("fast_confirmation/reconfirmation", new ForkChoiceTestExecutor(true)) + .put("fast_confirmation/restart_gu", new ForkChoiceTestExecutor(true)) + .put("fast_confirmation/revert_finality", new ForkChoiceTestExecutor(true)) + .put("fast_confirmation/variables", new ForkChoiceTestExecutor(true)) + .build(); + private final List testsToSkip; + private final boolean fastConfirmationEnabled; public ForkChoiceTestExecutor(final String... testsToSkip) { + this(false, testsToSkip); + } + + public ForkChoiceTestExecutor( + final boolean fastConfirmationEnabled, final String... testsToSkip) { + this.fastConfirmationEnabled = fastConfirmationEnabled; this.testsToSkip = List.of(testsToSkip); } @@ -203,6 +229,17 @@ spec, new SignedBlockAndState(anchorBlock, anchorState)), final AsyncBLSSignatureVerifier signatureVerifier = AsyncBLSSignatureVerifier.wrap( blsDisabled ? BLSSignatureVerifier.NOOP : BLSSignatureVerifier.SIMPLE); + // Run the fast confirmation side work synchronously so it completes within each onTick, before + // the following checks step reads the confirmation store. + final FastConfirmationTracker fastConfirmationTracker = + fastConfirmationEnabled + ? FastConfirmationTracker.create( + spec, + Optional.of(SyncAsyncRunner.SYNC_RUNNER), + FastConfirmationEventChannel.NOOP, + new StubMetricsSystem(), + StubTimeProvider.withTimeInMillis(0)) + : FastConfirmationTracker.NOOP; final ForkChoice forkChoice = new ForkChoice( spec, @@ -212,6 +249,7 @@ spec, new SignedBlockAndState(anchorBlock, anchorState)), new ForkChoiceStateProvider(eventThread, recentChainData), new TickProcessor(spec, recentChainData), transitionBlockValidator, + fastConfirmationTracker, // forkChoiceLateBlockReorgEnabled is true here always because this is the reference // test executor true, @@ -243,6 +281,7 @@ spec, new SignedBlockAndState(anchorBlock, anchorState)), blobSidecarManager, dataColumnSidecarManager, forkChoice, + fastConfirmationTracker, executionLayer, maybeAttestationValidator, gossipValidationHelper, @@ -289,6 +328,7 @@ private void runSteps( final StubBlobSidecarManager blobSidecarManager, final StubDataColumnSidecarManager dataColumnSidecarManager, final ForkChoice forkChoice, + final FastConfirmationTracker fastConfirmationTracker, final ExecutionLayerChannelStub executionLayer, final Optional maybeAttestationValidator, final GossipValidationHelper gossipValidationHelper, @@ -299,7 +339,7 @@ private void runSteps( for (Map step : steps) { LOG.info("Executing step {}", step); if (step.containsKey("checks")) { - applyChecks(recentChainData, forkChoice, step); + applyChecks(recentChainData, forkChoice, fastConfirmationTracker, step); } else if (step.containsKey("tick")) { forkChoice.onTick(secondsToMillis(getUInt64(step, "tick")), Optional.empty()); @@ -449,8 +489,15 @@ private void applyAttestation( final SafeFuture result = forkChoice.onAttestation(validatableAttestation); assertThat(result).isCompleted(); - AttestationProcessingResult processingResult = safeJoin(result); - assertThat(processingResult.isSuccessful()) + final AttestationProcessingResult processingResult = safeJoin(result); + // A current-slot attestation is valid but deferred by fork choice (stored and applied on the + // next tick). The fast confirmation vectors apply such attestations, so treat deferral as an + // accepted outcome. + final boolean acceptedByForkChoice = + processingResult.isSuccessful() + || processingResult.getStatus() + == AttestationProcessingResult.Status.DEFER_FORK_CHOICE_PROCESSING; + assertThat(acceptedByForkChoice) .withFailMessage(processingResult.getInvalidReason()) .isEqualTo(valid); } @@ -660,6 +707,7 @@ private List> loadSteps(final TestDefinition testDefinition) private void applyChecks( final RecentChainData recentChainData, final ForkChoice forkChoice, + final FastConfirmationTracker fastConfirmationTracker, final Map step) { assertThat(forkChoice.processHead()).isCompleted(); final UpdatableStore store = recentChainData.getStore(); @@ -788,6 +836,42 @@ private void applyChecks( get(checks, checkType), ReadOnlyForkChoiceStrategy::getPayloadDataAvailabilityVote); + case "previous_epoch_observed_justified_checkpoint" -> + assertCheckpoint( + checkType, + fastConfirmationStore(fastConfirmationTracker) + .previousEpochObservedJustifiedCheckpoint(), + get(checks, checkType)); + + case "current_epoch_observed_justified_checkpoint" -> + assertCheckpoint( + checkType, + fastConfirmationStore(fastConfirmationTracker) + .currentEpochObservedJustifiedCheckpoint(), + get(checks, checkType)); + + case "previous_epoch_greatest_unrealized_checkpoint" -> + assertCheckpoint( + checkType, + fastConfirmationStore(fastConfirmationTracker) + .previousEpochGreatestUnrealizedCheckpoint(), + get(checks, checkType)); + + case "previous_slot_head" -> + assertThat(fastConfirmationStore(fastConfirmationTracker).previousSlotHead()) + .describedAs(checkType) + .isEqualTo(getBytes32(checks, checkType)); + + case "current_slot_head" -> + assertThat(fastConfirmationStore(fastConfirmationTracker).currentSlotHead()) + .describedAs(checkType) + .isEqualTo(getBytes32(checks, checkType)); + + case "confirmed_root" -> + assertThat(fastConfirmationStore(fastConfirmationTracker).confirmedRoot()) + .describedAs(checkType) + .isEqualTo(getBytes32(checks, checkType)); + default -> throw new UnsupportedOperationException("Unsupported check type: " + checkType); } @@ -805,6 +889,12 @@ private void applyChecks( } } + private FastConfirmationStore fastConfirmationStore(final FastConfirmationTracker tracker) { + return tracker + .getFastConfirmationStore() + .orElseThrow(() -> new IllegalStateException("Fast confirmation store is not available")); + } + private void assertCheckpoint( final String checkpointType, final Checkpoint actual, diff --git a/eth-reference-tests/src/referenceTest/java/tech/pegasys/teku/reference/phase0/gossip/GossipBeaconAggregateAndProofTestExecutor.java b/eth-reference-tests/src/referenceTest/java/tech/pegasys/teku/reference/phase0/gossip/GossipBeaconAggregateAndProofTestExecutor.java index 4963b5bb398..62e1a29584d 100644 --- a/eth-reference-tests/src/referenceTest/java/tech/pegasys/teku/reference/phase0/gossip/GossipBeaconAggregateAndProofTestExecutor.java +++ b/eth-reference-tests/src/referenceTest/java/tech/pegasys/teku/reference/phase0/gossip/GossipBeaconAggregateAndProofTestExecutor.java @@ -50,6 +50,7 @@ import tech.pegasys.teku.statetransition.forkchoice.MergeTransitionBlockValidator; import tech.pegasys.teku.statetransition.forkchoice.NoopForkChoiceNotifier; import tech.pegasys.teku.statetransition.forkchoice.TickProcessor; +import tech.pegasys.teku.statetransition.forkchoice.fastconfirmation.FastConfirmationTracker; import tech.pegasys.teku.statetransition.util.DebugDataDumper; import tech.pegasys.teku.statetransition.validation.AggregateAttestationValidator; import tech.pegasys.teku.statetransition.validation.AttestationValidator; @@ -118,6 +119,7 @@ public void runTest(final TestDefinition testDefinition) throws Throwable { new ForkChoiceStateProvider(eventThread, recentChainData), new TickProcessor(spec, recentChainData), transitionBlockValidator, + FastConfirmationTracker.NOOP, true, LateBlockReorgPreparationHandler.NOOP, DebugDataDumper.NOOP, diff --git a/eth-reference-tests/src/referenceTest/java/tech/pegasys/teku/reference/phase0/gossip/GossipBeaconAttestationTestExecutor.java b/eth-reference-tests/src/referenceTest/java/tech/pegasys/teku/reference/phase0/gossip/GossipBeaconAttestationTestExecutor.java index 03a225fa67c..3e738fd9cd0 100644 --- a/eth-reference-tests/src/referenceTest/java/tech/pegasys/teku/reference/phase0/gossip/GossipBeaconAttestationTestExecutor.java +++ b/eth-reference-tests/src/referenceTest/java/tech/pegasys/teku/reference/phase0/gossip/GossipBeaconAttestationTestExecutor.java @@ -54,6 +54,7 @@ import tech.pegasys.teku.statetransition.forkchoice.MergeTransitionBlockValidator; import tech.pegasys.teku.statetransition.forkchoice.NoopForkChoiceNotifier; import tech.pegasys.teku.statetransition.forkchoice.TickProcessor; +import tech.pegasys.teku.statetransition.forkchoice.fastconfirmation.FastConfirmationTracker; import tech.pegasys.teku.statetransition.util.DebugDataDumper; import tech.pegasys.teku.statetransition.validation.AttestationValidator; import tech.pegasys.teku.statetransition.validation.BlockBroadcastValidator; @@ -124,6 +125,7 @@ public void runTest(final TestDefinition testDefinition) throws Throwable { new ForkChoiceStateProvider(eventThread, recentChainData), new TickProcessor(spec, recentChainData), transitionBlockValidator, + FastConfirmationTracker.NOOP, true, LateBlockReorgPreparationHandler.NOOP, DebugDataDumper.NOOP, diff --git a/eth-reference-tests/src/referenceTest/java/tech/pegasys/teku/reference/phase0/gossip/GossipBeaconBlockTestExecutor.java b/eth-reference-tests/src/referenceTest/java/tech/pegasys/teku/reference/phase0/gossip/GossipBeaconBlockTestExecutor.java index e29c5edbcec..61c35396274 100644 --- a/eth-reference-tests/src/referenceTest/java/tech/pegasys/teku/reference/phase0/gossip/GossipBeaconBlockTestExecutor.java +++ b/eth-reference-tests/src/referenceTest/java/tech/pegasys/teku/reference/phase0/gossip/GossipBeaconBlockTestExecutor.java @@ -51,6 +51,7 @@ import tech.pegasys.teku.statetransition.forkchoice.MergeTransitionBlockValidator; import tech.pegasys.teku.statetransition.forkchoice.NoopForkChoiceNotifier; import tech.pegasys.teku.statetransition.forkchoice.TickProcessor; +import tech.pegasys.teku.statetransition.forkchoice.fastconfirmation.FastConfirmationTracker; import tech.pegasys.teku.statetransition.util.DebugDataDumper; import tech.pegasys.teku.statetransition.validation.BlockBroadcastValidator; import tech.pegasys.teku.statetransition.validation.BlockGossipValidator; @@ -130,6 +131,7 @@ public void runTest(final TestDefinition testDefinition) throws Throwable { new ForkChoiceStateProvider(eventThread, recentChainData), new TickProcessor(spec, recentChainData), transitionBlockValidator, + FastConfirmationTracker.NOOP, true, LateBlockReorgPreparationHandler.NOOP, DebugDataDumper.NOOP, diff --git a/eth-reference-tests/src/referenceTest/java/tech/pegasys/teku/reference/phase0/gossip/GossipBlobSidecarTestExecutor.java b/eth-reference-tests/src/referenceTest/java/tech/pegasys/teku/reference/phase0/gossip/GossipBlobSidecarTestExecutor.java index 63ffd2e08fe..f488195efbf 100644 --- a/eth-reference-tests/src/referenceTest/java/tech/pegasys/teku/reference/phase0/gossip/GossipBlobSidecarTestExecutor.java +++ b/eth-reference-tests/src/referenceTest/java/tech/pegasys/teku/reference/phase0/gossip/GossipBlobSidecarTestExecutor.java @@ -56,6 +56,7 @@ import tech.pegasys.teku.statetransition.forkchoice.MergeTransitionBlockValidator; import tech.pegasys.teku.statetransition.forkchoice.NoopForkChoiceNotifier; import tech.pegasys.teku.statetransition.forkchoice.TickProcessor; +import tech.pegasys.teku.statetransition.forkchoice.fastconfirmation.FastConfirmationTracker; import tech.pegasys.teku.statetransition.util.DebugDataDumper; import tech.pegasys.teku.statetransition.validation.BlobSidecarGossipValidator; import tech.pegasys.teku.statetransition.validation.BlockBroadcastValidator; @@ -124,6 +125,7 @@ public void runTest(final TestDefinition testDefinition) throws Throwable { new ForkChoiceStateProvider(eventThread, recentChainData), new TickProcessor(spec, recentChainData), transitionBlockValidator, + FastConfirmationTracker.NOOP, true, LateBlockReorgPreparationHandler.NOOP, DebugDataDumper.NOOP, diff --git a/ethereum/networks/src/main/java/tech/pegasys/teku/networks/Eth2NetworkConfiguration.java b/ethereum/networks/src/main/java/tech/pegasys/teku/networks/Eth2NetworkConfiguration.java index c12367be9aa..0d3029b34e6 100644 --- a/ethereum/networks/src/main/java/tech/pegasys/teku/networks/Eth2NetworkConfiguration.java +++ b/ethereum/networks/src/main/java/tech/pegasys/teku/networks/Eth2NetworkConfiguration.java @@ -62,6 +62,8 @@ public class Eth2NetworkConfiguration { public static final boolean DEFAULT_FORK_CHOICE_LATE_BLOCK_REORG_ENABLED = true; + public static final boolean DEFAULT_FAST_CONFIRMATION_ENABLED = false; + public static final boolean DEFAULT_QUARTZ_SCHEDULER_ENABLED = false; public static final boolean DEFAULT_PREPARE_BLOCK_PRODUCTION_ENABLED = true; @@ -150,6 +152,7 @@ public class Eth2NetworkConfiguration { private final int asyncBeaconChainMaxQueue; private final int asyncP2pMaxQueue; private final boolean forkChoiceLateBlockReorgEnabled; + private final boolean fastConfirmationEnabled; private final boolean prepareBlockProductionEnabled; private final boolean forkChoiceUpdatedAlwaysSendPayloadAttributes; private final int pendingAttestationsMaxQueue; @@ -192,6 +195,7 @@ private Eth2NetworkConfiguration( final int asyncBeaconChainMaxThreads, final int asyncBeaconChainMaxQueue, final boolean forkChoiceLateBlockReorgEnabled, + final boolean fastConfirmationEnabled, final boolean prepareBlockProductionEnabled, final boolean forkChoiceUpdatedAlwaysSendPayloadAttributes, final int pendingAttestationsMaxQueue, @@ -235,6 +239,7 @@ private Eth2NetworkConfiguration( this.asyncBeaconChainMaxThreads = asyncBeaconChainMaxThreads; this.asyncBeaconChainMaxQueue = asyncBeaconChainMaxQueue; this.forkChoiceLateBlockReorgEnabled = forkChoiceLateBlockReorgEnabled; + this.fastConfirmationEnabled = fastConfirmationEnabled; this.prepareBlockProductionEnabled = prepareBlockProductionEnabled; this.forkChoiceUpdatedAlwaysSendPayloadAttributes = forkChoiceUpdatedAlwaysSendPayloadAttributes; @@ -373,6 +378,10 @@ public boolean isForkChoiceLateBlockReorgEnabled() { return forkChoiceLateBlockReorgEnabled; } + public boolean isFastConfirmationEnabled() { + return fastConfirmationEnabled; + } + public boolean isPrepareBlockProductionEnabled() { return prepareBlockProductionEnabled; } @@ -442,6 +451,7 @@ public boolean equals(final Object o) { && asyncBeaconChainMaxQueue == that.asyncBeaconChainMaxQueue && asyncP2pMaxQueue == that.asyncP2pMaxQueue && forkChoiceLateBlockReorgEnabled == that.forkChoiceLateBlockReorgEnabled + && fastConfirmationEnabled == that.fastConfirmationEnabled && prepareBlockProductionEnabled == that.prepareBlockProductionEnabled && aggregatingAttestationPoolProfilingEnabled == that.aggregatingAttestationPoolProfilingEnabled @@ -507,6 +517,7 @@ public int hashCode() { asyncBeaconChainMaxQueue, asyncP2pMaxQueue, forkChoiceLateBlockReorgEnabled, + fastConfirmationEnabled, prepareBlockProductionEnabled, forkChoiceUpdatedAlwaysSendPayloadAttributes, rustKzgEnabled, @@ -548,6 +559,7 @@ public static class Builder { private String epochsStoreBlobs; private Spec spec; private boolean forkChoiceLateBlockReorgEnabled = DEFAULT_FORK_CHOICE_LATE_BLOCK_REORG_ENABLED; + private boolean fastConfirmationEnabled = DEFAULT_FAST_CONFIRMATION_ENABLED; private boolean prepareBlockProductionEnabled = DEFAULT_PREPARE_BLOCK_PRODUCTION_ENABLED; private boolean forkChoiceUpdatedAlwaysSendPayloadAttributes = DEFAULT_FORK_CHOICE_UPDATED_ALWAYS_SEND_PAYLOAD_ATTRIBUTES; @@ -658,6 +670,7 @@ public Eth2NetworkConfiguration build() { asyncBeaconChainMaxThreads, asyncBeaconChainMaxQueue.orElse(DEFAULT_ASYNC_BEACON_CHAIN_MAX_QUEUE), forkChoiceLateBlockReorgEnabled, + fastConfirmationEnabled, resolvePrepareBlockProductionAbility(prepareBlockProductionEnabled), forkChoiceUpdatedAlwaysSendPayloadAttributes, pendingAttestationsMaxQueue.orElse(DEFAULT_MAX_QUEUE_PENDING_ATTESTATIONS), @@ -1308,6 +1321,11 @@ public Builder forkChoiceLateBlockReorgEnabled(final boolean forkChoiceLateBlock return this; } + public Builder fastConfirmationEnabled(final boolean fastConfirmationEnabled) { + this.fastConfirmationEnabled = fastConfirmationEnabled; + return this; + } + public Builder prepareBlockProductionEnabled(final boolean prepareBlockProductionEnabled) { this.prepareBlockProductionEnabled = prepareBlockProductionEnabled; return this; diff --git a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/forkchoice/FastConfirmationStore.java b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/forkchoice/FastConfirmationStore.java new file mode 100644 index 00000000000..90fd66a14bb --- /dev/null +++ b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/forkchoice/FastConfirmationStore.java @@ -0,0 +1,51 @@ +/* + * 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.datastructures.forkchoice; + +import org.apache.tuweni.bytes.Bytes32; +import tech.pegasys.teku.spec.datastructures.state.Checkpoint; + +public record FastConfirmationStore( + ReadOnlyStore store, + Bytes32 confirmedRoot, + Checkpoint previousEpochObservedJustifiedCheckpoint, + Checkpoint currentEpochObservedJustifiedCheckpoint, + Checkpoint previousEpochGreatestUnrealizedCheckpoint, + Bytes32 previousSlotHead, + Bytes32 currentSlotHead) { + + public static FastConfirmationStore create(final ReadOnlyStore store) { + final Checkpoint finalizedCheckpoint = store.getFinalizedCheckpoint(); + final Bytes32 finalizedRoot = finalizedCheckpoint.getRoot(); + return new FastConfirmationStore( + store, + finalizedRoot, + finalizedCheckpoint, + finalizedCheckpoint, + finalizedCheckpoint, + finalizedRoot, + finalizedRoot); + } + + public FastConfirmationStore withConfirmedRoot(final Bytes32 confirmedRoot) { + return new FastConfirmationStore( + store, + confirmedRoot, + previousEpochObservedJustifiedCheckpoint, + currentEpochObservedJustifiedCheckpoint, + previousEpochGreatestUnrealizedCheckpoint, + previousSlotHead, + currentSlotHead); + } +} diff --git a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/forkchoice/ReadOnlyStore.java b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/forkchoice/ReadOnlyStore.java index da6a0944496..0319e80fcf8 100644 --- a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/forkchoice/ReadOnlyStore.java +++ b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/forkchoice/ReadOnlyStore.java @@ -153,6 +153,8 @@ SafeFuture> retrieveCheckpointState( VoteTracker getVote(UInt64 validatorIndex); + VoteSnapshot getVoteSnapshot(); + void computeBalanceThresholds(BeaconState justifiedState); UInt64 getReorgThreshold(); diff --git a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/forkchoice/VoteSnapshot.java b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/forkchoice/VoteSnapshot.java new file mode 100644 index 00000000000..4f10c928544 --- /dev/null +++ b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/forkchoice/VoteSnapshot.java @@ -0,0 +1,57 @@ +/* + * 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.datastructures.forkchoice; + +import static com.google.common.base.Preconditions.checkArgument; + +import java.util.Arrays; +import tech.pegasys.teku.infrastructure.unsigned.UInt64; + +public final class VoteSnapshot { + + private final UInt64 highestVotedValidatorIndex; + private final VoteTracker[] votes; + + private VoteSnapshot(final UInt64 highestVotedValidatorIndex, final VoteTracker[] votes) { + this.highestVotedValidatorIndex = highestVotedValidatorIndex; + this.votes = votes; + } + + public static VoteSnapshot create( + final UInt64 highestVotedValidatorIndex, final VoteTracker[] votes) { + final int size = highestVotedValidatorIndex.intValue() + 1; + return new VoteSnapshot(highestVotedValidatorIndex, Arrays.copyOf(votes, size)); + } + + public UInt64 getHighestVotedValidatorIndex() { + return highestVotedValidatorIndex; + } + + public int size() { + return votes.length; + } + + public VoteTracker getVote(final UInt64 validatorIndex) { + return getVote(validatorIndex.intValue()); + } + + public VoteTracker getVote(final int validatorIndex) { + checkArgument(validatorIndex >= 0, "Validator index must be non-negative"); + if (validatorIndex >= votes.length) { + return VoteTracker.DEFAULT; + } + final VoteTracker vote = votes[validatorIndex]; + return vote != null ? vote : VoteTracker.DEFAULT; + } +} diff --git a/ethereum/spec/src/testFixtures/java/tech/pegasys/teku/spec/datastructures/forkchoice/TestStoreImpl.java b/ethereum/spec/src/testFixtures/java/tech/pegasys/teku/spec/datastructures/forkchoice/TestStoreImpl.java index 69d2e831282..7364970443d 100644 --- a/ethereum/spec/src/testFixtures/java/tech/pegasys/teku/spec/datastructures/forkchoice/TestStoreImpl.java +++ b/ethereum/spec/src/testFixtures/java/tech/pegasys/teku/spec/datastructures/forkchoice/TestStoreImpl.java @@ -419,6 +419,14 @@ public VoteTracker getVote(final UInt64 validatorIndex) { return vote != null ? vote : VoteTracker.DEFAULT; } + @Override + public VoteSnapshot getVoteSnapshot() { + final UInt64 highestVotedValidatorIndex = getHighestVotedValidatorIndex(); + final VoteTracker[] voteArray = new VoteTracker[highestVotedValidatorIndex.intValue() + 1]; + votes.forEach((validatorIndex, vote) -> voteArray[validatorIndex.intValue()] = vote); + return VoteSnapshot.create(highestVotedValidatorIndex, voteArray); + } + @Override public void putVote(final UInt64 validatorIndex, final VoteTracker vote) { votes.put(validatorIndex, vote); diff --git a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/attestation/VoteUpdates.java b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/attestation/VoteUpdates.java index f5f6c6a3039..bf6b4189c1c 100644 --- a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/attestation/VoteUpdates.java +++ b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/attestation/VoteUpdates.java @@ -14,11 +14,9 @@ package tech.pegasys.teku.statetransition.attestation; import static com.google.common.base.Preconditions.checkArgument; -import static java.util.Collections.newSetFromMap; -import java.util.Collection; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; +import java.util.NavigableMap; +import java.util.concurrent.ConcurrentSkipListMap; import org.apache.tuweni.bytes.Bytes32; import tech.pegasys.teku.infrastructure.unsigned.UInt64; import tech.pegasys.teku.spec.datastructures.operations.IndexedAttestationLight; @@ -26,8 +24,16 @@ public class VoteUpdates implements DeferredVotes { private final UInt64 slot; - private final Map> - votingIndicesByBlockRootAndFullPayloadHint = new ConcurrentHashMap<>(); + + // Keyed by validator index so that each validator holds a single vote for the slot. A validator's + // first vote wins (putIfAbsent), mirroring the spec's update_latest_messages, which only + // overwrites a latest message when the new attestation's target epoch is strictly greater (a + // same-epoch vote never overwrites). This is why a validator that attests to two competing blocks + // in one slot (equivocation) is applied to its first-received vote rather than to whichever block + // root a root-keyed map happened to iterate first. A navigable map keeps iteration deterministic + // (ordered by validator index). + private final NavigableMap voteByValidatorIndex = + new ConcurrentSkipListMap<>(); public VoteUpdates(final UInt64 slot) { this.slot = slot; @@ -40,16 +46,13 @@ public UInt64 getSlot() { @Override public void forEachDeferredVote(final DeferredVoteConsumer consumer) { - votingIndicesByBlockRootAndFullPayloadHint.forEach( - (key, indices) -> - indices.forEach( - validatorIndex -> - consumer.accept(key.blockRoot(), validatorIndex, key.fullPayloadHint()))); + voteByValidatorIndex.forEach( + (validatorIndex, vote) -> + consumer.accept(vote.blockRoot(), validatorIndex, vote.fullPayloadHint())); } public boolean isEmpty() { - return votingIndicesByBlockRootAndFullPayloadHint.values().stream() - .allMatch(Collection::isEmpty); + return voteByValidatorIndex.isEmpty(); } public void addAttestation( @@ -59,21 +62,15 @@ public void addAttestation( "Attempting to store vote update for wrong slot. Expected %s but got %s", slot, attestation.data().getSlot()); - final Bytes32 blockRoot = attestation.data().getBeaconBlockRoot(); - votingIndicesByBlockRootAndFullPayloadHint - .computeIfAbsent( - new BlockRootAndFullPayloadHint(blockRoot, fullPayloadHint), - __ -> newSetFromMap(new ConcurrentHashMap<>())) - .addAll(attestation.attestingIndices()); + final BlockRootAndFullPayloadHint vote = + new BlockRootAndFullPayloadHint(attestation.data().getBeaconBlockRoot(), fullPayloadHint); + attestation.attestingIndices().forEach(index -> voteByValidatorIndex.putIfAbsent(index, vote)); } public void addVote( final Bytes32 blockRoot, final UInt64 validatorIndex, final boolean fullPayloadHint) { - votingIndicesByBlockRootAndFullPayloadHint - .computeIfAbsent( - new BlockRootAndFullPayloadHint(blockRoot, fullPayloadHint), - __ -> newSetFromMap(new ConcurrentHashMap<>())) - .add(validatorIndex); + voteByValidatorIndex.putIfAbsent( + validatorIndex, new BlockRootAndFullPayloadHint(blockRoot, fullPayloadHint)); } private record BlockRootAndFullPayloadHint(Bytes32 blockRoot, boolean fullPayloadHint) {} diff --git a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/ForkChoice.java b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/ForkChoice.java index 91bcc2bf29b..f0579106ac0 100644 --- a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/ForkChoice.java +++ b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/ForkChoice.java @@ -63,6 +63,7 @@ import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.PayloadAttestationMessage; import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.SignedExecutionPayloadEnvelope; import tech.pegasys.teku.spec.datastructures.execution.ExecutionPayload; +import tech.pegasys.teku.spec.datastructures.forkchoice.FastConfirmationStore; import tech.pegasys.teku.spec.datastructures.forkchoice.ForkChoiceNode; import tech.pegasys.teku.spec.datastructures.forkchoice.ForkChoicePayloadStatus; import tech.pegasys.teku.spec.datastructures.forkchoice.InvalidCheckpointException; @@ -95,6 +96,8 @@ import tech.pegasys.teku.statetransition.attestation.DeferredAttestations; import tech.pegasys.teku.statetransition.attestation.VoteUpdates; import tech.pegasys.teku.statetransition.block.BlockImportPerformance; +import tech.pegasys.teku.statetransition.forkchoice.fastconfirmation.FastConfirmationTracker; +import tech.pegasys.teku.statetransition.forkchoice.fastconfirmation.ForkChoiceFastConfirmation; import tech.pegasys.teku.statetransition.payloadattestation.ValidatablePayloadAttestationMessage; import tech.pegasys.teku.statetransition.util.DebugDataDumper; import tech.pegasys.teku.statetransition.validation.AttestationStateSelector; @@ -125,6 +128,7 @@ public class ForkChoice implements ForkChoiceUpdatedResultSubscriber { private final Subscribers optimisticSyncSubscribers = Subscribers.create(true); private final TickProcessor tickProcessor; + private final FastConfirmationTracker fastConfirmationTracker; private final boolean forkChoiceLateBlockReorgEnabled; private final LateBlockReorgPreparationHandler lateBlockReorgPreparationHandler; private Optional optimisticSyncing = Optional.empty(); @@ -144,6 +148,7 @@ public ForkChoice( final ForkChoiceStateProvider forkChoiceStateProvider, final TickProcessor tickProcessor, final MergeTransitionBlockValidator transitionBlockValidator, + final FastConfirmationTracker fastConfirmationTracker, final boolean forkChoiceLateBlockReorgEnabled, final LateBlockReorgPreparationHandler lateBlockReorgPreparationHandler, final DebugDataDumper debugDataDumper, @@ -158,9 +163,11 @@ public ForkChoice( this.attestationStateSelector = new AttestationStateSelector(spec, recentChainData, metricsSystem); this.tickProcessor = tickProcessor; + this.fastConfirmationTracker = fastConfirmationTracker; this.forkChoiceLateBlockReorgEnabled = forkChoiceLateBlockReorgEnabled; this.lateBlockReorgPreparationHandler = lateBlockReorgPreparationHandler; this.lastProcessHeadSlot.set(UInt64.ZERO); + LOG.debug("fastConfirmationEnabled is set to {}", fastConfirmationTracker.isEnabled()); LOG.debug("forkChoiceLateBlockReorgEnabled is set to {}", forkChoiceLateBlockReorgEnabled); this.debugDataDumper = debugDataDumper; this.signatureVerifier = signatureVerifier; @@ -170,7 +177,7 @@ public ForkChoice( "get_proposer_head_selection_total", "when late_block_reorg is enabled, counts based on the proposer parent being based on fork choice, head, or parent of head.", "selected_source"); - recentChainData.subscribeStoreInitialized(this::initializeProtoArrayForkChoice); + recentChainData.subscribeStoreInitialized(this::onStoreInitialized); forkChoiceNotifier.subscribeToForkChoiceUpdatedResult(this); } @@ -190,6 +197,7 @@ public ForkChoice( new ForkChoiceStateProvider(forkChoiceExecutor, recentChainData), new TickProcessor(spec, recentChainData), transitionBlockValidator, + FastConfirmationTracker.NOOP, false, LateBlockReorgPreparationHandler.NOOP, DebugDataDumper.NOOP, @@ -402,11 +410,25 @@ public void onTick( performanceRecord.ifPresent(TickProcessingPerformance::tickProcessorComplete); final UInt64 currentSlot = spec.getCurrentSlot(store); if (currentSlot.isGreaterThan(slotAtStartOfTick)) { - applyDeferredAttestations(currentSlot).finishStackTrace(); + final SafeFuture deferredAttestationsFuture = applyDeferredAttestations(currentSlot); + // When fast confirmation is disabled this is exactly the master behaviour. When enabled, the + // slot-start work runs entirely off the fork-choice thread (on the fast confirmation runner) + // with no blocking join; it never gates tick processing. + if (fastConfirmationTracker.isEnabled()) { + ForkChoiceFastConfirmation.processForSlot( + fastConfirmationTracker, currentSlot, deferredAttestationsFuture, this::processHead); + } else { + deferredAttestationsFuture.finishStackTrace(); + } } performanceRecord.ifPresent(TickProcessingPerformance::deferredAttestationsApplied); } + private void onStoreInitialized() { + fastConfirmationTracker.initialize(recentChainData.getStore()); + initializeProtoArrayForkChoice(); + } + private void initializeProtoArrayForkChoice() { processHead().join(); } @@ -1296,6 +1318,16 @@ public UInt64 getLastProcessHeadSlot() { return lastProcessHeadSlot.get(); } + @VisibleForTesting + boolean isFastConfirmationEnabled() { + return fastConfirmationTracker.isEnabled(); + } + + @VisibleForTesting + Optional getFastConfirmationStore() { + return fastConfirmationTracker.getFastConfirmationStore(); + } + SafeFuture prepareForBlockProduction( final UInt64 slot, final BlockProductionPerformance blockProductionPerformance) { final UInt64 slotStartTimeMillis = diff --git a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationCalculator.java b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationCalculator.java new file mode 100644 index 00000000000..b9a264491fc --- /dev/null +++ b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationCalculator.java @@ -0,0 +1,715 @@ +/* + * 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.statetransition.forkchoice.fastconfirmation; + +import it.unimi.dsi.fastutil.ints.IntOpenHashSet; +import it.unimi.dsi.fastutil.ints.IntSet; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.tuweni.bytes.Bytes32; +import tech.pegasys.teku.infrastructure.ssz.SszList; +import tech.pegasys.teku.infrastructure.unsigned.UInt64; +import tech.pegasys.teku.spec.Spec; +import tech.pegasys.teku.spec.SpecMilestone; +import tech.pegasys.teku.spec.datastructures.blocks.BlockCheckpoints; +import tech.pegasys.teku.spec.datastructures.forkchoice.FastConfirmationStore; +import tech.pegasys.teku.spec.datastructures.forkchoice.ForkChoiceNode; +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.forkchoice.VoteSnapshot; +import tech.pegasys.teku.spec.datastructures.forkchoice.VoteTracker; +import tech.pegasys.teku.spec.datastructures.state.Checkpoint; +import tech.pegasys.teku.spec.datastructures.state.Validator; +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; +import tech.pegasys.teku.spec.logic.common.util.ForkChoiceUtil; + +/** + * Executes the Fast Confirmation Rule against a fixed slot-start snapshot: the fork-choice {@code + * store}, the head selected at slot start, and the current slot. Instances are short-lived and + * single-threaded (created per slot on the fast confirmation runner). + * + *

It reads block metadata and ancestry from the protoarray fork-choice strategy and derives the + * source-state helpers from the {@link FastConfirmationStates} loaded for the slot. Ancestry checks + * delegate to the fork-choice {@code is_ancestor} via {@link ForkChoiceUtil}, so Gloas + * payload-status semantics are preserved; block roots are lifted to the spec's {@code + * ForkChoiceNode} through {@code get_node_for_root}. + */ +class FastConfirmationCalculator { + + private static final Logger LOG = LogManager.getLogger(); + + private final Spec spec; + private final ForkChoiceUtil forkChoiceUtil; + private final FastConfirmationStore fcrStore; + private final ReadOnlyStore store; + private final ReadOnlyForkChoiceStrategy forkChoice; + private final VoteSnapshot votes; + private final FastConfirmationStates states; + private final Bytes32 head; + private final UInt64 currentSlot; + private final UInt64 currentEpoch; + private final boolean isGloas; + + // Lazily computed once per instance (single-threaded per slot); see getPulledUpHeadState. + private BeaconState pulledUpHeadState; + + FastConfirmationCalculator( + final Spec spec, + final FastConfirmationStore fcrStore, + final FastConfirmationStates states, + final UInt64 currentSlot) { + this.spec = spec; + this.forkChoiceUtil = spec.atSlot(currentSlot).getForkChoiceUtil(); + this.fcrStore = fcrStore; + this.store = fcrStore.store(); + this.forkChoice = store.getForkChoiceStrategy(); + this.votes = store.getVoteSnapshot(); + this.states = states; + // After update_fast_confirmation_variables, current_slot_head == get_head(store).root. + this.head = fcrStore.currentSlotHead(); + this.currentSlot = currentSlot; + this.currentEpoch = spec.computeEpochAtSlot(currentSlot); + this.isGloas = + spec.atSlot(currentSlot).getMilestone().isGreaterThanOrEqualTo(SpecMilestone.GLOAS); + } + + /** Implements {@code get_block_slot}. */ + UInt64 getBlockSlot(final Bytes32 blockRoot) { + return forkChoice + .blockSlot(blockRoot) + .orElseThrow(() -> new IllegalStateException("Missing block slot for " + blockRoot)); + } + + /** Implements {@code get_block_epoch}. */ + UInt64 getBlockEpoch(final Bytes32 blockRoot) { + return spec.computeEpochAtSlot(getBlockSlot(blockRoot)); + } + + Bytes32 getBlockParentRoot(final Bytes32 blockRoot) { + return forkChoice + .blockParentRoot(blockRoot) + .orElseThrow(() -> new IllegalStateException("Missing parent root for " + blockRoot)); + } + + /** Implements {@code store.unrealized_justifications[block_root]}. */ + Checkpoint getUnrealizedJustification(final Bytes32 blockRoot) { + return getCheckpoints(blockRoot).getUnrealizedJustifiedCheckpoint(); + } + + /** Implements {@code get_checkpoint_for_block}. */ + Checkpoint getCheckpointForBlock(final Bytes32 blockRoot, final UInt64 epoch) { + return new Checkpoint(epoch, getCheckpointBlock(blockRoot, epoch)); + } + + /** Implements {@code get_current_target}. */ + Checkpoint getCurrentTarget() { + return getCheckpointForBlock(head, currentEpoch); + } + + /** + * Implements {@code get_pulled_up_head_state}: the head state advanced to the start of the + * current epoch when it lags behind, otherwise the head state as-is. Memoized because the FFG + * helpers read it repeatedly within a single slot. + */ + BeaconState getPulledUpHeadState() { + if (pulledUpHeadState == null) { + pulledUpHeadState = computePulledUpHeadState(); + } + return pulledUpHeadState; + } + + /** + * Implements {@code get_slot_committee}: all validators assigned to any committee in {@code + * slot}, using the raw head state as the shuffling source. + */ + IntSet getSlotCommittee(final UInt64 slot) { + final BeaconState shufflingSource = states.headBlockState(); + final UInt64 epoch = spec.computeEpochAtSlot(slot); + final int committeesCount = spec.getCommitteeCountPerSlot(shufflingSource, epoch).intValue(); + final IntSet participants = new IntOpenHashSet(); + for (int committeeIndex = 0; committeeIndex < committeesCount; committeeIndex++) { + participants.addAll( + spec.getBeaconCommittee(shufflingSource, slot, UInt64.valueOf(committeeIndex))); + } + return participants; + } + + /** + * Implements {@code get_attestation_score}: the total effective balance (per {@code + * balanceSource}) of unslashed, active, non-equivocating validators whose latest vote supports a + * descendant of {@code nodeRoot}. + */ + UInt64 getAttestationScore(final Bytes32 nodeRoot, final BeaconState balanceSource) { + final UInt64 balanceSourceEpoch = spec.getCurrentEpoch(balanceSource); + final SszList validators = balanceSource.getValidators(); + UInt64 score = UInt64.ZERO; + for (final int index : spec.getActiveValidatorIndices(balanceSource, balanceSourceEpoch)) { + final Validator validator = validators.get(index); + if (validator.isSlashed()) { + continue; + } + final VoteTracker vote = votes.getVote(index); + if (vote.isEquivocating()) { + continue; + } + final Bytes32 votedRoot = vote.getNextRoot(); + // A zero root means the validator is not in store.latest_messages. + if (!votedRoot.isZero() && isAncestor(votedRoot, nodeRoot)) { + score = score.plus(validator.getEffectiveBalance()); + } + } + return score; + } + + /** + * Implements {@code get_block_support_between_slots}: the total effective balance (per {@code + * balanceSource}) of unslashed, active, non-equivocating validators assigned to the inclusive + * slot range whose latest vote is exactly {@code blockRoot}. + */ + UInt64 getBlockSupportBetweenSlots( + final BeaconState balanceSource, + final Bytes32 blockRoot, + final UInt64 startSlot, + final UInt64 endSlot) { + final UInt64 balanceSourceEpoch = spec.getCurrentEpoch(balanceSource); + final SszList validators = balanceSource.getValidators(); + UInt64 support = UInt64.ZERO; + for (final int index : getCommitteeBetweenSlots(startSlot, endSlot)) { + final Validator validator = validators.get(index); + if (validator.isSlashed() || !isActiveValidator(validator, balanceSourceEpoch)) { + continue; + } + final VoteTracker vote = votes.getVote(index); + if (!vote.isEquivocating() && vote.getNextRoot().equals(blockRoot)) { + support = support.plus(validator.getEffectiveBalance()); + } + } + return support; + } + + /** + * Implements {@code get_equivocation_score}: the total effective balance (per {@code + * balanceSource}) of active, equivocating validators assigned to the inclusive slot range. Per + * spec, slashed validators are not filtered out here (they are very likely already equivocating). + */ + UInt64 getEquivocationScore( + final BeaconState balanceSource, final UInt64 startSlot, final UInt64 endSlot) { + final UInt64 balanceSourceEpoch = spec.getCurrentEpoch(balanceSource); + final SszList validators = balanceSource.getValidators(); + UInt64 score = UInt64.ZERO; + for (final int index : getCommitteeBetweenSlots(startSlot, endSlot)) { + if (!votes.getVote(index).isEquivocating()) { + continue; + } + final Validator validator = validators.get(index); + if (isActiveValidator(validator, balanceSourceEpoch)) { + score = score.plus(validator.getEffectiveBalance()); + } + } + return score; + } + + /** + * Implements {@code compute_adversarial_weight}: the maximum weight that could be adversarial in + * the committees of the slot range, assuming {@code CONFIRMATION_BYZANTINE_THRESHOLD} and + * discounting validators already known to be equivocating. + */ + UInt64 computeAdversarialWeight( + final BeaconState balanceSource, final UInt64 startSlot, final UInt64 endSlot) { + final UInt64 totalActiveBalance = spec.getTotalActiveBalance(balanceSource); + final UInt64 maximumWeight = + FastConfirmationRuleUtil.estimateCommitteeWeightBetweenSlots( + spec, totalActiveBalance, startSlot, endSlot); + final UInt64 maxAdversarialWeight = + maximumWeight + .dividedBy(100) + .times(FastConfirmationRuleUtil.CONFIRMATION_BYZANTINE_THRESHOLD); + final UInt64 equivocationScore = getEquivocationScore(balanceSource, startSlot, endSlot); + return maxAdversarialWeight.isGreaterThan(equivocationScore) + ? maxAdversarialWeight.minus(equivocationScore) + : UInt64.ZERO; + } + + /** + * Implements {@code get_adversarial_weight}: the maximum adversarial weight that can support the + * block, from the block's first relevant slot up to the slot before the current one. + */ + UInt64 getAdversarialWeight(final BeaconState balanceSource, final Bytes32 blockRoot) { + final UInt64 lastSlot = currentSlot.minus(1); + final UInt64 blockEpoch = getBlockEpoch(blockRoot); + if (blockEpoch.isGreaterThan(getBlockEpoch(getBlockParentRoot(blockRoot)))) { + // Use the first epoch slot as the start slot when crossing an epoch boundary. + return computeAdversarialWeight( + balanceSource, spec.computeStartSlotAtEpoch(blockEpoch), lastSlot); + } + return computeAdversarialWeight(balanceSource, getBlockSlot(blockRoot), lastSlot); + } + + /** + * Implements {@code compute_empty_slot_support_discount}: weight discountable from the safety + * threshold when empty slots precede the block, i.e. parent support from the empty slots' + * committees beyond what could be adversarial. + */ + UInt64 computeEmptySlotSupportDiscount(final BeaconState balanceSource, final Bytes32 blockRoot) { + final Bytes32 parentRoot = getBlockParentRoot(blockRoot); + final UInt64 blockSlot = getBlockSlot(blockRoot); + final UInt64 parentSlot = getBlockSlot(parentRoot); + if (parentSlot.plus(1).equals(blockSlot)) { + // No empty slot. + return UInt64.ZERO; + } + final UInt64 firstEmptySlot = parentSlot.plus(1); + final UInt64 lastEmptySlot = blockSlot.minus(1); + final UInt64 parentSupportInEmptySlots = + getBlockSupportBetweenSlots(balanceSource, parentRoot, firstEmptySlot, lastEmptySlot); + final UInt64 adversarialWeight = + computeAdversarialWeight(balanceSource, firstEmptySlot, lastEmptySlot); + return parentSupportInEmptySlots.isGreaterThan(adversarialWeight) + ? parentSupportInEmptySlots.minus(adversarialWeight) + : UInt64.ZERO; + } + + /** Implements {@code get_support_discount}. */ + UInt64 getSupportDiscount(final BeaconState balanceSource, final Bytes32 blockRoot) { + return computeEmptySlotSupportDiscount(balanceSource, blockRoot); + } + + /** Implements {@code compute_safety_threshold}: the LMD-GHOST safety threshold for the block. */ + UInt64 computeSafetyThreshold(final Bytes32 blockRoot, final BeaconState balanceSource) { + final UInt64 parentSlot = getBlockSlot(getBlockParentRoot(blockRoot)); + final UInt64 totalActiveBalance = spec.getTotalActiveBalance(balanceSource); + final UInt64 proposerScore = spec.getProposerBoostAmount(balanceSource); + final UInt64 maximumSupport = + FastConfirmationRuleUtil.estimateCommitteeWeightBetweenSlots( + spec, totalActiveBalance, parentSlot.plus(1), currentSlot.minus(1)); + final UInt64 supportDiscount = getSupportDiscount(balanceSource, blockRoot); + final UInt64 adversarialWeight = getAdversarialWeight(balanceSource, blockRoot); + + // (maximumSupport + proposerScore + 2 * adversarialWeight - supportDiscount) // 2, guarded + // against underflow. + final UInt64 gross = maximumSupport.plus(proposerScore).plus(adversarialWeight.times(2)); + return supportDiscount.isLessThan(gross) + ? gross.minus(supportDiscount).dividedBy(2) + : UInt64.ZERO; + } + + /** + * Implements {@code is_one_confirmed}: whether the block is LMD-GHOST safe (its support exceeds + * the safety threshold). Returns {@code false} for a block that is not fully validated (not + * {@code VALID} per optimistic sync). + */ + boolean isOneConfirmed(final BeaconState balanceSource, final Bytes32 blockRoot) { + if (!isValidForConfirmation(blockRoot)) { + return false; + } + final UInt64 support = getAttestationScore(blockRoot, balanceSource); + final UInt64 safetyThreshold = computeSafetyThreshold(blockRoot, balanceSource); + return support.isGreaterThan(safetyThreshold); + } + + /** + * From the {@code is_one_confirmed} spec: "This function MUST return {@code False} if {@code + * block_root} status is not {@code VALID} according to the Optimistic sync specification." + * + *

Pre-Gloas this maps directly to {@code isFullyValidated} (protoarray {@code VALID}), so an + * optimistically-imported block is never confirmed. In Gloas the rule confirms the {@code + * PENDING} (block-level) node — see {@code get_node_for_root} — which is reached before the + * execution payload envelope is revealed. Such a block is {@code OPTIMISTIC} in Teku's protoarray + * purely because its payload has not been validated yet, whereas an execution-invalid block is + * pruned from fork choice entirely. Block-level presence ({@code contains}) is therefore the + * correct notion of {@code VALID} for Gloas confirmation. + */ + private boolean isValidForConfirmation(final Bytes32 blockRoot) { + return isGloas ? forkChoice.contains(blockRoot) : forkChoice.isFullyValidated(blockRoot); + } + + /** + * Implements {@code get_current_target_score}: the estimated FFG support of the current-epoch + * target, using the pulled-up head state's validator set and the LMD votes received so far. + */ + UInt64 getCurrentTargetScore() { + final Checkpoint target = getCurrentTarget(); + final BeaconState state = getPulledUpHeadState(); + final UInt64 epoch = spec.getCurrentEpoch(state); + final SszList validators = state.getValidators(); + UInt64 score = UInt64.ZERO; + for (final int index : spec.getActiveValidatorIndices(state, epoch)) { + final Validator validator = validators.get(index); + if (validator.isSlashed()) { + continue; + } + final VoteTracker vote = votes.getVote(index); + if (vote.isEquivocating()) { + continue; + } + final Bytes32 votedRoot = vote.getNextRoot(); + if (votedRoot.isZero() || !forkChoice.contains(votedRoot)) { + continue; + } + final UInt64 messageEpoch = spec.computeEpochAtSlot(vote.getNextSlot()); + if (target.equals(getCheckpointForBlock(votedRoot, messageEpoch))) { + score = score.plus(validator.getEffectiveBalance()); + } + } + return score; + } + + /** + * Implements {@code compute_honest_ffg_support_for_current_target}: the minimum honest FFG + * support the current-epoch target can be assured of, assuming synchrony and {@code + * CONFIRMATION_BYZANTINE_THRESHOLD}. + */ + UInt64 computeHonestFfgSupportForCurrentTarget() { + final BeaconState balanceSource = getPulledUpHeadState(); + final UInt64 totalActiveBalance = spec.getTotalActiveBalance(balanceSource); + final UInt64 ffgSupportForCheckpoint = getCurrentTargetScore(); + final UInt64 epochStart = spec.computeStartSlotAtEpoch(currentEpoch); + final UInt64 lastSlot = currentSlot.minus(1); + + // Total FFG weight already assigned up to, but excluding, the current slot. + final UInt64 ffgWeightTillNow = + FastConfirmationRuleUtil.estimateCommitteeWeightBetweenSlots( + spec, totalActiveBalance, epochStart, lastSlot); + final UInt64 remainingFfgWeight = totalActiveBalance.minusMinZero(ffgWeightTillNow); + final UInt64 remainingHonestFfgWeight = + remainingFfgWeight + .dividedBy(100) + .times(100 - FastConfirmationRuleUtil.CONFIRMATION_BYZANTINE_THRESHOLD); + + final UInt64 adversarialWeight = computeAdversarialWeight(balanceSource, epochStart, lastSlot); + // ffg_support - min(adversarial_weight, ffg_support) + final UInt64 minHonestFfgSupport = ffgSupportForCheckpoint.minusMinZero(adversarialWeight); + + return minHonestFfgSupport.plus(remainingHonestFfgWeight); + } + + /** + * Implements {@code will_no_conflicting_checkpoint_be_justified}: whether no checkpoint + * conflicting with the current target can ever be justified. + */ + boolean willNoConflictingCheckpointBeJustified() { + // If the target is already the greatest unrealized justified checkpoint, nothing can conflict. + if (getCurrentTarget().equals(getStoreUnrealizedJustifiedCheckpoint())) { + return true; + } + final UInt64 totalActiveBalance = spec.getTotalActiveBalance(getPulledUpHeadState()); + return computeHonestFfgSupportForCurrentTarget().times(3).isGreaterThan(totalActiveBalance); + } + + /** + * Implements {@code will_current_target_be_justified}: whether the current target will eventually + * be justified. + */ + boolean willCurrentTargetBeJustified() { + final UInt64 totalActiveBalance = spec.getTotalActiveBalance(getPulledUpHeadState()); + return computeHonestFfgSupportForCurrentTarget() + .times(3) + .isGreaterThanOrEqualTo(totalActiveBalance.times(2)); + } + + /** + * Implements {@code is_confirmed_chain_safe}: whether every block of the confirmed chain, from + * the current-epoch observed justified checkpoint up to {@code confirmedRoot}, is LMD-GHOST safe + * against the previous balance source. Run at the start of each epoch to relax the synchrony + * assumption (reconfirmation). + */ + boolean isConfirmedChainSafe(final Bytes32 confirmedRoot) { + final Checkpoint observedJustified = fcrStore.currentEpochObservedJustifiedCheckpoint(); + // The observed justified checkpoint must be on the confirmed chain. + if (!observedJustified.equals( + getCheckpointForBlock(confirmedRoot, observedJustified.getEpoch()))) { + return false; + } + + final Bytes32 startRootExclusive; + if (observedJustified.getEpoch().plus(1).isGreaterThanOrEqualTo(currentEpoch)) { + // Exclude the justified checkpoint block: from the previous epoch it is always canonical. + startRootExclusive = observedJustified.getRoot(); + } else { + // Limit reconfirmation to the first block of the previous epoch; confirming it implies its + // ancestors. + final Bytes32 ancestorAtPreviousEpochStart = + getAncestorRoot(confirmedRoot, spec.computeStartSlotAtEpoch(currentEpoch.minus(1))); + startRootExclusive = + getBlockEpoch(ancestorAtPreviousEpochStart).plus(1).equals(currentEpoch) + ? getBlockParentRoot(ancestorAtPreviousEpochStart) + : ancestorAtPreviousEpochStart; + } + + final BeaconState previousBalanceSource = + states + .previousBalanceSource() + .orElseThrow( + () -> + new IllegalStateException( + "Previous balance source is required for reconfirmation")); + return getAncestorRoots(confirmedRoot, startRootExclusive).stream() + .allMatch(root -> isOneConfirmed(previousBalanceSource, root)); + } + + /** + * Implements {@code find_latest_confirmed_descendant}: the most recent confirmed block in the + * canonical suffix starting from {@code latestConfirmedRoot}. It first tries to advance across + * previous-epoch blocks (relying on the previous slot head), then further into the current epoch + * (gated on the current target being justifiable), keeping the result only if it cannot be + * reorged out in the current or next epoch. + */ + Bytes32 findLatestConfirmedDescendant(final Bytes32 latestConfirmedRoot) { + final boolean atEpochStart = FastConfirmationRuleUtil.isStartSlotAtEpoch(spec, currentSlot); + final Bytes32 previousSlotHead = fcrStore.previousSlotHead(); + final BeaconState currentBalanceSource = states.currentBalanceSource(); + Bytes32 confirmedRoot = latestConfirmedRoot; + + if (getBlockEpoch(confirmedRoot).plus(1).equals(currentEpoch) + && epochPlusIsAtLeastCurrent(getVotingSource(previousSlotHead).getEpoch(), 2) + && (atEpochStart + || (willNoConflictingCheckpointBeJustified() + && (epochPlusIsAtLeastCurrent( + getUnrealizedJustification(previousSlotHead).getEpoch(), 1) + || epochPlusIsAtLeastCurrent( + getUnrealizedJustification(head).getEpoch(), 1))))) { + // Advance towards the head over previous-epoch blocks; stop at the first unconfirmed one. + for (final Bytes32 blockRoot : getAncestorRoots(head, confirmedRoot)) { + // Only meant to confirm previous-epoch blocks. + if (getBlockEpoch(blockRoot).equals(currentEpoch)) { + break; + } + // Can only rely on the previous head if it descends from the block being confirmed. + if (!isAncestor(previousSlotHead, blockRoot)) { + break; + } + if (!isOneConfirmed(currentBalanceSource, blockRoot)) { + break; + } + confirmedRoot = blockRoot; + } + } + + if (atEpochStart || epochPlusIsAtLeastCurrent(getUnrealizedJustification(head).getEpoch(), 1)) { + Bytes32 tentativeConfirmedRoot = confirmedRoot; + for (final Bytes32 blockRoot : getAncestorRoots(head, confirmedRoot)) { + // Only true the first time the walk advances into the current epoch. + if (getBlockEpoch(blockRoot).isGreaterThan(getBlockEpoch(tentativeConfirmedRoot)) + && !willCurrentTargetBeJustified()) { + break; + } + if (!isOneConfirmed(currentBalanceSource, blockRoot)) { + break; + } + tentativeConfirmedRoot = blockRoot; + } + + // Only keep the advance if the block cannot be reorged out this epoch or the next. + if (getBlockEpoch(tentativeConfirmedRoot).equals(currentEpoch) + || (epochPlusIsAtLeastCurrent(getVotingSource(tentativeConfirmedRoot).getEpoch(), 2) + && (atEpochStart || willNoConflictingCheckpointBeJustified()))) { + confirmedRoot = tentativeConfirmedRoot; + } + } + + return confirmedRoot; + } + + /** + * Implements {@code get_latest_confirmed}: the FCR entry point. Reverts {@code confirmed_root} to + * the finalized block when it is too old, no longer canonical, or (at an epoch boundary) fails + * reconfirmation; optionally restarts the chain from the observed justified checkpoint; then + * advances via {@link #findLatestConfirmedDescendant}. + */ + Bytes32 getLatestConfirmed() { + final boolean atEpochStart = FastConfirmationRuleUtil.isStartSlotAtEpoch(spec, currentSlot); + Bytes32 confirmedRoot = fcrStore.confirmedRoot(); + + // Revert to the finalized block if the confirmed block is no longer tracked by fork choice + // (pruned below the finalized anchor as finality advanced — the spec assumes every block stays + // in store.blocks, but Teku's protoarray only keeps finalized-onward), is more than one epoch + // old, no longer an ancestor of the head, or (at an epoch boundary) its chain can no longer be + // reconfirmed. The fork-choice-presence check is first so the epoch/ancestry lookups below + // never + // run on a pruned root. + if (!forkChoice.contains(confirmedRoot) + || getBlockEpoch(confirmedRoot).plus(1).isLessThan(currentEpoch) + || !isAncestor(head, confirmedRoot) + || (atEpochStart && !isConfirmedChainSafe(confirmedRoot))) { + confirmedRoot = store.getFinalizedCheckpoint().getRoot(); + } + + // Restart the confirmation chain from the observed justified checkpoint when, at an epoch + // boundary, that checkpoint is from the previous epoch, equals the head's unrealized + // justification, and the confirmed block is older than it. Skip when the checkpoint block has + // been pruned from fork choice. + final Checkpoint observedJustified = fcrStore.currentEpochObservedJustifiedCheckpoint(); + if (atEpochStart && forkChoice.contains(observedJustified.getRoot())) { + final UInt64 observedJustifiedBlockSlot = getBlockSlot(observedJustified.getRoot()); + if (spec.computeEpochAtSlot(observedJustifiedBlockSlot).plus(1).equals(currentEpoch) + && observedJustified.equals(getUnrealizedJustification(head)) + && getBlockSlot(confirmedRoot).isLessThan(observedJustifiedBlockSlot)) { + confirmedRoot = observedJustified.getRoot(); + } + } + + // Attempt to advance the confirmed block further; only meaningful while it is recent. + final boolean advanceRan = + getBlockEpoch(confirmedRoot).plus(1).isGreaterThanOrEqualTo(currentEpoch); + final Bytes32 result = + advanceRan ? findLatestConfirmedDescendant(confirmedRoot) : confirmedRoot; + + if (LOG.isTraceEnabled()) { + final Object headUnrealizedJustifiedEpoch = + forkChoice.getBlockData(head).isPresent() + ? getUnrealizedJustification(head).getEpoch() + : "n/a"; + LOG.trace( + "FCR getLatestConfirmed slot={} epoch={} atEpochStart={} isGloas={} headFullyValidated={} finalizedEpoch={} observedJustifiedEpoch={} headUnrealizedJustifiedEpoch={} confirmedEpochBeforeAdvance={} advanceRan={} resultEpoch={}", + currentSlot, + currentEpoch, + atEpochStart, + isGloas, + forkChoice.isFullyValidated(head), + getBlockEpoch(store.getFinalizedCheckpoint().getRoot()), + observedJustified.getEpoch(), + headUnrealizedJustifiedEpoch, + getBlockEpoch(confirmedRoot), + advanceRan, + getBlockEpoch(result)); + } + return result; + } + + /** + * Returns {@code epoch + offset >= currentEpoch} (spec pattern {@code checkpoint.epoch + n >= + * current_epoch}). + */ + private boolean epochPlusIsAtLeastCurrent(final UInt64 epoch, final long offset) { + return epoch.plus(offset).isGreaterThanOrEqualTo(currentEpoch); + } + + /** Reconstructs {@code store.unrealized_justified_checkpoint} (the store-level greatest). */ + private Checkpoint getStoreUnrealizedJustifiedCheckpoint() { + return FastConfirmationRuleUtil.getGreatestUnrealizedJustifiedCheckpoint(store); + } + + /** + * Union of {@code get_slot_committee} over the inclusive slot range {@code [startSlot, endSlot]}. + */ + private IntSet getCommitteeBetweenSlots(final UInt64 startSlot, final UInt64 endSlot) { + final IntSet participants = new IntOpenHashSet(); + for (UInt64 slot = startSlot; slot.isLessThanOrEqualTo(endSlot); slot = slot.increment()) { + participants.addAll(getSlotCommittee(slot)); + } + return participants; + } + + private boolean isActiveValidator(final Validator validator, final UInt64 epoch) { + return spec.atEpoch(epoch).predicates().isActiveValidator(validator, epoch); + } + + private BeaconState computePulledUpHeadState() { + final BeaconState headState = states.headBlockState(); + if (spec.getCurrentEpoch(headState).isLessThan(currentEpoch)) { + try { + return spec.processSlots(headState, spec.computeStartSlotAtEpoch(currentEpoch)); + } catch (final SlotProcessingException | EpochProcessingException e) { + throw new IllegalStateException("Failed to pull up head state for fast confirmation", e); + } + } + return headState; + } + + /** + * Implements {@code get_voting_source}: the voting source of a block is its unrealized justified + * checkpoint when the block is from a prior epoch (pulled up), otherwise its realized justified + * checkpoint. Teku's protoarray stores the block's post-state realized justified checkpoint, + * which equals the spec's {@code store.block_states[block_root].current_justified_checkpoint}, so + * no block state needs to be loaded here. + */ + Checkpoint getVotingSource(final Bytes32 blockRoot) { + final UInt64 blockEpoch = getBlockEpoch(blockRoot); + if (currentEpoch.isGreaterThan(blockEpoch)) { + return getUnrealizedJustification(blockRoot); + } + return getCheckpoints(blockRoot).getJustifiedCheckpoint(); + } + + /** + * Implements {@code is_ancestor(store, get_node_for_root(descendantRoot), + * get_node_for_root(ancestorRoot))}: {@code true} if {@code ancestorRoot} is an ancestor of + * {@code descendantRoot} (a block is considered its own ancestor). + * + *

Delegates to the fork-choice {@code is_ancestor} rather than re-deriving ancestry, so the + * Gloas payload-status semantics are honoured. + */ + boolean isAncestor(final Bytes32 descendantRoot, final Bytes32 ancestorRoot) { + return forkChoiceUtil.isAncestor( + forkChoice, getNodeForRoot(descendantRoot), getNodeForRoot(ancestorRoot)); + } + + /** + * Implements {@code get_node_for_root}: pre-Gloas {@code ForkChoiceNode(root)}, Gloas {@code + * ForkChoiceNode(root, PAYLOAD_STATUS_PENDING)}. Both map to the {@linkplain + * ForkChoiceNode#createBase(Bytes32) base (PENDING)} node. + */ + private ForkChoiceNode getNodeForRoot(final Bytes32 blockRoot) { + return ForkChoiceNode.createBase(blockRoot); + } + + /** + * Implements {@code get_ancestor_roots}: the ancestors of {@code blockRoot} (inclusive) down to, + * but excluding, {@code terminalRoot}, ordered oldest first. Returns an empty list when {@code + * terminalRoot} is not in the chain of {@code blockRoot}. + */ + List getAncestorRoots(final Bytes32 blockRoot, final Bytes32 terminalRoot) { + final Deque ancestorRoots = new ArrayDeque<>(); + final UInt64 terminalSlot = getBlockSlot(terminalRoot); + Bytes32 root = blockRoot; + while (getBlockSlot(root).isGreaterThan(terminalSlot)) { + ancestorRoots.addFirst(root); + root = getBlockParentRoot(root); + // Return when terminalRoot is reached + if (root.equals(terminalRoot)) { + return new ArrayList<>(ancestorRoots); + } + } + // Return empty list if terminalRoot is not in the chain of blockRoot + return List.of(); + } + + /** Implements {@code get_checkpoint_block}. */ + private Bytes32 getCheckpointBlock(final Bytes32 blockRoot, final UInt64 epoch) { + return getAncestorRoot(blockRoot, spec.computeStartSlotAtEpoch(epoch)); + } + + /** Implements {@code get_ancestor(store, node, slot).root}. */ + private Bytes32 getAncestorRoot(final Bytes32 blockRoot, final UInt64 slot) { + return forkChoice + .getAncestor(blockRoot, slot) + .orElseThrow( + () -> + new IllegalStateException("Missing ancestor of " + blockRoot + " at slot " + slot)); + } + + private BlockCheckpoints getCheckpoints(final Bytes32 blockRoot) { + return forkChoice + .getBlockData(blockRoot) + .map(ProtoNodeData::getCheckpoints) + .orElseThrow(() -> new IllegalStateException("Missing checkpoints for " + blockRoot)); + } +} diff --git a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationEventChannel.java b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationEventChannel.java new file mode 100644 index 00000000000..a53d5395269 --- /dev/null +++ b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationEventChannel.java @@ -0,0 +1,35 @@ +/* + * 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.statetransition.forkchoice.fastconfirmation; + +import org.apache.tuweni.bytes.Bytes32; +import tech.pegasys.teku.infrastructure.events.VoidReturningChannelInterface; +import tech.pegasys.teku.infrastructure.unsigned.UInt64; + +/** + * Fired once per slot after the fast confirmation algorithm ({@code on_fast_confirmation}) has run, + * regardless of whether the confirmed block changed. Backs the {@code fast_confirmation} Beacon API + * event stream topic. + */ +public interface FastConfirmationEventChannel extends VoidReturningChannelInterface { + + FastConfirmationEventChannel NOOP = (confirmedRoot, confirmedSlot, currentSlot) -> {}; + + /** + * @param confirmedRoot root of the most recent confirmed block + * @param confirmedSlot slot of the most recent confirmed block + * @param currentSlot wall-clock slot at which the algorithm was executed + */ + void onFastConfirmation(Bytes32 confirmedRoot, UInt64 confirmedSlot, UInt64 currentSlot); +} diff --git a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationInput.java b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationInput.java new file mode 100644 index 00000000000..59192601b6a --- /dev/null +++ b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationInput.java @@ -0,0 +1,25 @@ +/* + * 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.statetransition.forkchoice.fastconfirmation; + +import org.apache.tuweni.bytes.Bytes32; +import tech.pegasys.teku.infrastructure.unsigned.UInt64; + +/** + * Per-slot snapshot captured on the fork-choice thread and handed to the fast confirmation runner. + * Only the head root and slot are pinned here; all remaining inputs (epoch-boundary flags, greatest + * unrealized justified checkpoint, source states) are derived on the runner to keep work off the + * latency-critical fork-choice thread. + */ +record FastConfirmationInput(UInt64 slot, Bytes32 headRoot) {} diff --git a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationRuleUtil.java b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationRuleUtil.java new file mode 100644 index 00000000000..8668eb191cb --- /dev/null +++ b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationRuleUtil.java @@ -0,0 +1,187 @@ +/* + * 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.statetransition.forkchoice.fastconfirmation; + +import org.apache.tuweni.bytes.Bytes32; +import tech.pegasys.teku.infrastructure.unsigned.UInt64; +import tech.pegasys.teku.spec.Spec; +import tech.pegasys.teku.spec.datastructures.forkchoice.FastConfirmationStore; +import tech.pegasys.teku.spec.datastructures.forkchoice.ProtoNodeData; +import tech.pegasys.teku.spec.datastructures.forkchoice.ReadOnlyStore; +import tech.pegasys.teku.spec.datastructures.state.Checkpoint; + +public final class FastConfirmationRuleUtil { + + /** + * {@code COMMITTEE_WEIGHT_ESTIMATION_ADJUSTMENT_FACTOR}: per mille value added to the committee + * weight estimate over a slot range that spans an epoch boundary without covering a full epoch, + * to ensure the safety of the confirmation rule with high probability. Defined as a preset + * constant ({@code uint64(5)}) in the Fast Confirmation spec. + */ + static final UInt64 COMMITTEE_WEIGHT_ESTIMATION_ADJUSTMENT_FACTOR = UInt64.valueOf(5); + + /** + * {@code CONFIRMATION_BYZANTINE_THRESHOLD}: assumed maximum percentage of Byzantine validators. + * The spec exposes it as configuration with a maximum of {@code 25}; both the mainnet and minimal + * presets set it to {@code 25}, so it is treated here as a fixed constant to avoid touching + * {@code SpecConfig}. + */ + static final int CONFIRMATION_BYZANTINE_THRESHOLD = 25; + + private FastConfirmationRuleUtil() {} + + public static boolean isStartSlotAtEpoch(final Spec spec, final UInt64 slot) { + return spec.computeStartSlotAtEpoch(spec.computeEpochAtSlot(slot)).equals(slot); + } + + /** Implements {@code compute_slots_since_epoch_start} from the Consensus Spec. */ + static UInt64 computeSlotsSinceEpochStart(final Spec spec, final UInt64 slot) { + return slot.minus(spec.computeStartSlotAtEpoch(spec.computeEpochAtSlot(slot))); + } + + /** + * Implements {@code is_full_validator_set_covered} from the Fast Confirmation spec: returns + * {@code true} if the inclusive range {@code [startSlot, endSlot]} includes an entire epoch. + */ + static boolean isFullValidatorSetCovered( + final Spec spec, final UInt64 startSlot, final UInt64 endSlot) { + final int slotsPerEpoch = spec.getSlotsPerEpoch(startSlot); + final UInt64 startFullEpoch = spec.computeEpochAtSlot(startSlot.plus(slotsPerEpoch - 1)); + final UInt64 endFullEpoch = spec.computeEpochAtSlot(endSlot.plus(1)); + return startFullEpoch.isLessThan(endFullEpoch); + } + + /** + * Implements {@code adjust_committee_weight_estimate_to_ensure_safety} from the Fast Confirmation + * spec. + */ + static UInt64 adjustCommitteeWeightEstimateToEnsureSafety(final UInt64 estimate) { + final UInt64 ceil = estimate.plus(999).dividedBy(1000); + return ceil.times(UInt64.valueOf(1000).plus(COMMITTEE_WEIGHT_ESTIMATION_ADJUSTMENT_FACTOR)); + } + + /** + * Implements {@code estimate_committee_weight_between_slots} from the Fast Confirmation spec: + * estimates the total weight of the committees assigned to the inclusive slot range {@code + * [startSlot, endSlot]}. + */ + static UInt64 estimateCommitteeWeightBetweenSlots( + final Spec spec, + final UInt64 totalActiveBalance, + final UInt64 startSlot, + final UInt64 endSlot) { + // Sanity check + if (startSlot.isGreaterThan(endSlot)) { + return UInt64.ZERO; + } + + // If an entire epoch is covered by the range, return the total active balance + if (isFullValidatorSetCovered(spec, startSlot, endSlot)) { + return totalActiveBalance; + } + + final int slotsPerEpoch = spec.getSlotsPerEpoch(startSlot); + final UInt64 startEpoch = spec.computeEpochAtSlot(startSlot); + final UInt64 endEpoch = spec.computeEpochAtSlot(endSlot); + final UInt64 committeeWeight = totalActiveBalance.dividedBy(slotsPerEpoch); + if (startEpoch.equals(endEpoch)) { + return committeeWeight.times(endSlot.minus(startSlot).plus(1)); + } + + // First, calculate the number of committees in the end epoch + final UInt64 numSlotsInEndEpoch = computeSlotsSinceEpochStart(spec, endSlot).plus(1); + // Next, calculate the number of slots remaining in the end epoch + final UInt64 remainingSlotsInEndEpoch = UInt64.valueOf(slotsPerEpoch).minus(numSlotsInEndEpoch); + // Then, calculate the number of slots in the start epoch + final UInt64 numSlotsInStartEpoch = + UInt64.valueOf(slotsPerEpoch).minus(computeSlotsSinceEpochStart(spec, startSlot)); + + final UInt64 startEpochWeight = committeeWeight.times(numSlotsInStartEpoch); + final UInt64 endEpochWeight = committeeWeight.times(numSlotsInEndEpoch); + + // A range that spans an epoch boundary but does not span any full epoch needs a pro-rata + // calculation of the start epoch weight. + final UInt64 startEpochWeightProRated = + startEpochWeight.dividedBy(slotsPerEpoch).times(remainingSlotsInEndEpoch); + + return adjustCommitteeWeightEstimateToEnsureSafety( + startEpochWeightProRated.plus(endEpochWeight)); + } + + /** + * Reconstructs {@code store.unrealized_justified_checkpoint} (the greatest unrealized justified + * checkpoint) from Teku's per-block checkpoint metadata. + * + *

Mirrors {@code update_unrealized_checkpoints}: it is initialized to the (realized) justified + * checkpoint and raised only when a block reports an unrealized justified checkpoint from a + * strictly higher epoch. Starting from the justified checkpoint matters at/near genesis, where + * blocks carry a zero unrealized justified checkpoint but the store value is the genesis + * checkpoint. This is fork-correct: like the spec's store-level value, it rises from any + * processed block on any fork. + */ + static Checkpoint getGreatestUnrealizedJustifiedCheckpoint(final ReadOnlyStore store) { + Checkpoint greatest = store.getJustifiedCheckpoint(); + for (final ProtoNodeData block : store.getForkChoiceStrategy().getBlockData()) { + final Checkpoint unrealizedJustified = + block.getCheckpoints().getUnrealizedJustifiedCheckpoint(); + if (unrealizedJustified.getEpoch().isGreaterThan(greatest.getEpoch())) { + greatest = unrealizedJustified; + } + } + return greatest; + } + + static FastConfirmationStore updateFastConfirmationVariables( + final FastConfirmationStore fcrStore, + final Bytes32 currentSlotHead, + final Checkpoint greatestUnrealizedJustifiedCheckpoint, + final boolean currentSlotIsEpochStart, + final boolean nextSlotIsEpochStart) { + FastConfirmationStore updatedStore = + new FastConfirmationStore( + fcrStore.store(), + fcrStore.confirmedRoot(), + fcrStore.previousEpochObservedJustifiedCheckpoint(), + fcrStore.currentEpochObservedJustifiedCheckpoint(), + fcrStore.previousEpochGreatestUnrealizedCheckpoint(), + fcrStore.currentSlotHead(), + currentSlotHead); + + if (nextSlotIsEpochStart) { + updatedStore = + new FastConfirmationStore( + updatedStore.store(), + updatedStore.confirmedRoot(), + updatedStore.previousEpochObservedJustifiedCheckpoint(), + updatedStore.currentEpochObservedJustifiedCheckpoint(), + greatestUnrealizedJustifiedCheckpoint, + updatedStore.previousSlotHead(), + updatedStore.currentSlotHead()); + } + + if (currentSlotIsEpochStart) { + updatedStore = + new FastConfirmationStore( + updatedStore.store(), + updatedStore.confirmedRoot(), + updatedStore.currentEpochObservedJustifiedCheckpoint(), + updatedStore.previousEpochGreatestUnrealizedCheckpoint(), + updatedStore.previousEpochGreatestUnrealizedCheckpoint(), + updatedStore.previousSlotHead(), + updatedStore.currentSlotHead()); + } + + return updatedStore; + } +} diff --git a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationStateLoader.java b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationStateLoader.java new file mode 100644 index 00000000000..3c86bf36dca --- /dev/null +++ b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationStateLoader.java @@ -0,0 +1,79 @@ +/* + * 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.statetransition.forkchoice.fastconfirmation; + +import java.util.Optional; +import org.apache.tuweni.bytes.Bytes32; +import tech.pegasys.teku.infrastructure.async.SafeFuture; +import tech.pegasys.teku.spec.datastructures.forkchoice.FastConfirmationStore; +import tech.pegasys.teku.spec.datastructures.forkchoice.ReadOnlyStore; +import tech.pegasys.teku.spec.datastructures.state.beaconstate.BeaconState; + +/** + * Retrieves the source states the Fast Confirmation Rule needs for a slot. + * + *

The three retrievals run concurrently on the store's own runners and are composed + * asynchronously, so no state load ever blocks the (single-thread) fast confirmation runner. If any + * required state is unavailable (e.g. pruned), the result is empty and the caller skips the slot. + */ +final class FastConfirmationStateLoader { + + private FastConfirmationStateLoader() {} + + /** + * @param includePreviousBalanceSource whether to also retrieve {@code + * get_previous_balance_source}. It is only read by reconfirmation, which runs at epoch-start + * slots, so callers pass {@code is_start_slot_at_epoch(currentSlot)} to avoid an unnecessary + * (and often uncached) fetch of the previous epoch's checkpoint state on other slots. + */ + static SafeFuture> load( + final FastConfirmationStore fcrStore, + final Bytes32 head, + final boolean includePreviousBalanceSource) { + final ReadOnlyStore store = fcrStore.store(); + final SafeFuture> previousBalanceSource = + includePreviousBalanceSource + ? store.retrieveCheckpointState(fcrStore.previousEpochObservedJustifiedCheckpoint()) + : SafeFuture.completedFuture(Optional.empty()); + final SafeFuture> currentBalanceSource = + store.retrieveCheckpointState(fcrStore.currentEpochObservedJustifiedCheckpoint()); + final SafeFuture> headBlockState = store.retrieveBlockState(head); + + // The futures are already running; compose (rather than join) so the runner is not blocked + // while they complete. + return previousBalanceSource.thenCompose( + previous -> + currentBalanceSource.thenCompose( + current -> + headBlockState.thenApply( + headState -> + combine(includePreviousBalanceSource, previous, current, headState)))); + } + + private static Optional combine( + final boolean includePreviousBalanceSource, + final Optional previousBalanceSource, + final Optional currentBalanceSource, + final Optional headBlockState) { + // The mandatory sources must be present; the previous balance source only when requested. + if (currentBalanceSource.isEmpty() + || headBlockState.isEmpty() + || (includePreviousBalanceSource && previousBalanceSource.isEmpty())) { + return Optional.empty(); + } + return Optional.of( + new FastConfirmationStates( + previousBalanceSource, currentBalanceSource.get(), headBlockState.get())); + } +} diff --git a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationStates.java b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationStates.java new file mode 100644 index 00000000000..15df6edf472 --- /dev/null +++ b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationStates.java @@ -0,0 +1,43 @@ +/* + * 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.statetransition.forkchoice.fastconfirmation; + +import java.util.Optional; +import tech.pegasys.teku.spec.datastructures.state.beaconstate.BeaconState; + +/** + * The source beacon states the Fast Confirmation Rule reads from, retrieved once per slot. + * + *

The spec distinguishes four state sources; not all are needed every slot: + * + *

    + *
  • {@code previousBalanceSource} — {@code get_previous_balance_source}: {@code + * store.checkpoint_states[previous_epoch_observed_justified_checkpoint]}. Used only by + * reconfirmation ({@code is_confirmed_chain_safe}), which runs only at epoch-start slots, so + * it is {@link Optional} and left empty on other slots. + *
  • {@code currentBalanceSource} — {@code get_current_balance_source}: {@code + * store.checkpoint_states[current_epoch_observed_justified_checkpoint]}, used for + * current-epoch weights. + *
  • {@code headBlockState} — {@code store.block_states[get_head(store).root]}, the raw head + * state used as the committee shuffling source in {@code get_slot_committee}. + *
+ * + *

The fourth source, {@code get_pulled_up_head_state}, is derived from {@code headBlockState} + * (by advancing it to the current epoch if it lags), so it is computed downstream rather than + * loaded. + */ +record FastConfirmationStates( + Optional previousBalanceSource, + BeaconState currentBalanceSource, + BeaconState headBlockState) {} diff --git a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationTracker.java b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationTracker.java new file mode 100644 index 00000000000..2bdb50c9567 --- /dev/null +++ b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationTracker.java @@ -0,0 +1,275 @@ +/* + * 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.statetransition.forkchoice.fastconfirmation; + +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.tuweni.bytes.Bytes32; +import org.hyperledger.besu.plugin.services.MetricsSystem; +import org.hyperledger.besu.plugin.services.metrics.Counter; +import tech.pegasys.teku.infrastructure.async.AsyncRunner; +import tech.pegasys.teku.infrastructure.async.SafeFuture; +import tech.pegasys.teku.infrastructure.exceptions.ExceptionUtil; +import tech.pegasys.teku.infrastructure.metrics.MetricsHistogram; +import tech.pegasys.teku.infrastructure.metrics.TekuMetricCategory; +import tech.pegasys.teku.infrastructure.time.TimeProvider; +import tech.pegasys.teku.infrastructure.unsigned.UInt64; +import tech.pegasys.teku.spec.Spec; +import tech.pegasys.teku.spec.datastructures.forkchoice.FastConfirmationStore; +import tech.pegasys.teku.spec.datastructures.forkchoice.ReadOnlyStore; +import tech.pegasys.teku.spec.datastructures.state.Checkpoint; + +public class FastConfirmationTracker { + private static final Logger LOG = LogManager.getLogger(); + + public static final FastConfirmationTracker NOOP = + new FastConfirmationTracker( + false, null, Optional.empty(), FastConfirmationEventChannel.NOOP, null, null); + + private final boolean enabled; + private final Spec spec; + private final FastConfirmationEventChannel fastConfirmationEventChannel; + + /** Wall-clock duration of a single per-slot fast confirmation run; {@code null} when disabled. */ + private final MetricsHistogram calculationTimer; + + /** Incremented when a per-slot run does not finish within {@link #updateTimeout}. */ + private final Counter timeoutCounter; + + private final AtomicReference fastConfirmationStore = + new AtomicReference<>(); + + /** + * Slot of the most recently applied update. Guards against stale or duplicate async tasks + * clobbering a newer store: {@code update_fast_confirmation_variables} MUST run exactly once per + * slot and slot heads rotate in slot order, so an update is applied only when its slot is + * strictly greater than this value. {@code null} until the first update after (re)initialization. + */ + private final AtomicReference lastProcessedSlot = new AtomicReference<>(); + + private final Optional asyncRunner; + + private FastConfirmationTracker( + final boolean enabled, + final Spec spec, + final Optional asyncRunner, + final FastConfirmationEventChannel fastConfirmationEventChannel, + final MetricsHistogram calculationTimer, + final Counter timeoutCounter) { + this.enabled = enabled; + this.spec = spec; + this.asyncRunner = asyncRunner; + this.fastConfirmationEventChannel = fastConfirmationEventChannel; + this.calculationTimer = calculationTimer; + this.timeoutCounter = timeoutCounter; + } + + public static FastConfirmationTracker create( + final Spec spec, + final Optional asyncRunner, + final FastConfirmationEventChannel fastConfirmationEventChannel, + final MetricsSystem metricsSystem, + final TimeProvider timeProvider) { + final MetricsHistogram calculationTimer = + new MetricsHistogram( + metricsSystem, + timeProvider, + TekuMetricCategory.BEACON, + "fast_confirmation_calculation_seconds", + "Time taken to run the fast confirmation algorithm (incl. source-state loading) each slot"); + final Counter timeoutCounter = + metricsSystem.createCounter( + TekuMetricCategory.BEACON, + "fast_confirmation_timeout_total", + "Number of slots where the fast confirmation update did not complete within its timeout"); + return new FastConfirmationTracker( + true, spec, asyncRunner, fastConfirmationEventChannel, calculationTimer, timeoutCounter); + } + + public boolean isEnabled() { + return enabled; + } + + public void initialize(final ReadOnlyStore store) { + if (!enabled) { + return; + } + lastProcessedSlot.set(null); + fastConfirmationStore.set(FastConfirmationStore.create(store)); + } + + /** + * Hands off the per-slot head snapshot to the dedicated runner. Only the head root is captured on + * the (latency-critical) fork-choice thread; the epoch-boundary flags, greatest unrealized + * justified checkpoint, source states and the confirmation computation are all derived on the + * runner. + */ + public SafeFuture onSlot(final UInt64 slot, final Bytes32 headRoot) { + if (!enabled) { + return SafeFuture.COMPLETE; + } + + if (fastConfirmationStore.get() == null) { + LOG.debug("Skipping fast confirmation update because store is not initialized"); + return SafeFuture.COMPLETE; + } + + if (asyncRunner.isEmpty()) { + LOG.warn("Skipping fast confirmation update because no async runner is configured"); + return SafeFuture.COMPLETE; + } + + final FastConfirmationInput input = new FastConfirmationInput(slot, headRoot); + final Duration updateTimeout = updateTimeout(slot); + return asyncRunner + .orElseThrow() + .runAsync(() -> processFastConfirmationInput(input)) + .orTimeout(updateTimeout) + .exceptionallyCompose( + error -> { + // A timeout means the side computation is lagging (e.g. slow state retrieval): log it + // and move on rather than failing the fork-choice tick. Real errors still propagate. + if (ExceptionUtil.hasCause(error, TimeoutException.class)) { + timeoutCounter.inc(); + LOG.warn( + "Fast confirmation update for slot {} timed out after {}", slot, updateTimeout); + return SafeFuture.COMPLETE; + } + return SafeFuture.failedFuture(error); + }); + } + + /** + * Upper bound for a single per-slot update: half a slot. The confirmation computation should + * finish well within a slot; this only guards against a hung or very slow state retrieval + * monopolizing the dedicated single-thread runner. Applied via the default scheduler so it fires + * independently of that runner thread. + */ + private Duration updateTimeout(final UInt64 slot) { + return Duration.ofMillis(spec.getSlotDurationMillis(slot) / 2L); + } + + private void processFastConfirmationInput(final FastConfirmationInput input) { + final FastConfirmationStore currentStore = fastConfirmationStore.get(); + if (currentStore == null) { + LOG.debug("Skipping fast confirmation update because store is not initialized"); + return; + } + + // Drop stale or duplicate updates. Tasks run on a dedicated single-thread runner so this + // read-modify-write is serialized; the guard additionally ensures that an out-of-order or + // repeated slot never overwrites a newer store (and enforces the spec's once-per-slot rule for + // update_fast_confirmation_variables). + final UInt64 lastSlot = lastProcessedSlot.get(); + if (lastSlot != null && input.slot().isLessThanOrEqualTo(lastSlot)) { + LOG.debug( + "Skipping fast confirmation update for slot {}: already processed slot {}", + input.slot(), + lastSlot); + return; + } + + // Time the actual per-slot computation (state loading + get_latest_confirmed), which is what + // the timeout bounds; the cheap guards above are deliberately left out of the measurement. + final MetricsHistogram.Timer calculationTimerContext = calculationTimer.startTimer(); + try { + runFastConfirmation(input, currentStore); + } finally { + calculationTimerContext.closeUnchecked().run(); + } + } + + private void runFastConfirmation( + final FastConfirmationInput input, final FastConfirmationStore currentStore) { + // Derived off the fork-choice thread. The greatest unrealized justified checkpoint is only + // needed on the last slot of an epoch (when the next slot starts a new epoch); otherwise it is + // left at the finalized checkpoint and unused by update_fast_confirmation_variables. + final boolean currentSlotIsEpochStart = + FastConfirmationRuleUtil.isStartSlotAtEpoch(spec, input.slot()); + final boolean nextSlotIsEpochStart = + FastConfirmationRuleUtil.isStartSlotAtEpoch(spec, input.slot().plus(1)); + final ReadOnlyStore store = currentStore.store(); + final Checkpoint greatestUnrealizedJustifiedCheckpoint = + nextSlotIsEpochStart + ? FastConfirmationRuleUtil.getGreatestUnrealizedJustifiedCheckpoint(store) + : store.getFinalizedCheckpoint(); + + final FastConfirmationStore withUpdatedVariables = + FastConfirmationRuleUtil.updateFastConfirmationVariables( + currentStore, + input.headRoot(), + greatestUnrealizedJustifiedCheckpoint, + currentSlotIsEpochStart, + nextSlotIsEpochStart); + + // on_fast_confirmation: fcr_store.confirmed_root = get_latest_confirmed(fcr_store). + final FastConfirmationStore updatedStore = + updateConfirmedRoot(withUpdatedVariables, input.slot(), currentSlotIsEpochStart); + fastConfirmationStore.set(updatedStore); + lastProcessedSlot.set(input.slot()); + + final Bytes32 confirmedRoot = updatedStore.confirmedRoot(); + final UInt64 confirmedSlot = + store.getForkChoiceStrategy().blockSlot(confirmedRoot).orElse(UInt64.ZERO); + + LOG.info( + "Fast confirmation update for slot {}: head={}, confirmed_root={}, confirmed_slot={}", + input.slot(), + input.headRoot(), + confirmedRoot, + confirmedSlot); + + // The fast_confirmation event is emitted every time the algorithm runs, regardless of whether + // the confirmed block changed (per the Beacon API event stream spec). + fastConfirmationEventChannel.onFastConfirmation(confirmedRoot, confirmedSlot, input.slot()); + } + + /** + * Runs {@code get_latest_confirmed} against the loaded source states. The state loads are + * composed concurrently and joined here; in practice the required states are cache-resident (the + * head state and the justified checkpoint state), so the join is effectively non-blocking, and + * the overall task is bounded by {@link #updateTimeout}. When a required state is unavailable the + * confirmed root is left unchanged for this slot. + */ + private FastConfirmationStore updateConfirmedRoot( + final FastConfirmationStore fcrStore, final UInt64 slot, final boolean atEpochStart) { + final Optional maybeStates = + FastConfirmationStateLoader.load(fcrStore, fcrStore.currentSlotHead(), atEpochStart).join(); + if (maybeStates.isEmpty()) { + // The source states could not be loaded (e.g. the observed-justified checkpoint state has + // been pruned, which happens for a short window after startup before that checkpoint has + // advanced to a still-hot one). We cannot run get_latest_confirmed, so fall back to the + // finalized block: it is always safe to consider confirmed, its root is known without a state + // load, and this keeps confirmed_root tracking finality rather than going stale. + final Bytes32 finalizedRoot = fcrStore.store().getFinalizedCheckpoint().getRoot(); + LOG.debug( + "Fast confirmation for slot {}: source states unavailable, falling back to finalized {}", + slot, + finalizedRoot); + return fcrStore.withConfirmedRoot(finalizedRoot); + } + final Bytes32 confirmedRoot = + new FastConfirmationCalculator(spec, fcrStore, maybeStates.get(), slot) + .getLatestConfirmed(); + return fcrStore.withConfirmedRoot(confirmedRoot); + } + + public Optional getFastConfirmationStore() { + return Optional.ofNullable(fastConfirmationStore.get()); + } +} diff --git a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/ForkChoiceFastConfirmation.java b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/ForkChoiceFastConfirmation.java new file mode 100644 index 00000000000..903adc5dc2d --- /dev/null +++ b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/ForkChoiceFastConfirmation.java @@ -0,0 +1,52 @@ +/* + * 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.statetransition.forkchoice.fastconfirmation; + +import java.util.Optional; +import java.util.function.Function; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import tech.pegasys.teku.infrastructure.async.SafeFuture; +import tech.pegasys.teku.infrastructure.unsigned.UInt64; +import tech.pegasys.teku.storage.client.ChainHead; + +/** Glue between {@code ForkChoice.onTick} and the fast confirmation side runner. */ +public final class ForkChoiceFastConfirmation { + private static final Logger LOG = LogManager.getLogger(); + + private ForkChoiceFastConfirmation() {} + + /** + * Runs the FCR slot-start work once deferred attestations have been applied, entirely without + * blocking the fork-choice thread. FCR is defined from the post-deferred-attestation head at slot + * start, so this composes {@code processHead} onto the deferred-attestation future rather than + * joining it. Everything after the head snapshot (epoch-boundary flags, greatest unrealized + * justified checkpoint, source states, and the confirmation computation) is handed to the fast + * confirmation runner via {@link FastConfirmationTracker#onSlot}. + */ + public static void processForSlot( + final FastConfirmationTracker fastConfirmationTracker, + final UInt64 currentSlot, + final SafeFuture deferredAttestationsFuture, + final Function>> processHead) { + deferredAttestationsFuture + .thenCompose(__ -> processHead.apply(currentSlot)) + .thenCompose( + maybeHead -> + maybeHead + .map(head -> fastConfirmationTracker.onSlot(currentSlot, head.getRoot())) + .orElse(SafeFuture.COMPLETE)) + .finish(error -> LOG.error("Fast confirmation update failed", error)); + } +} diff --git a/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/attestation/VoteUpdatesTest.java b/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/attestation/VoteUpdatesTest.java new file mode 100644 index 00000000000..2fa58542503 --- /dev/null +++ b/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/attestation/VoteUpdatesTest.java @@ -0,0 +1,70 @@ +/* + * 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.statetransition.attestation; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.Test; +import tech.pegasys.teku.infrastructure.unsigned.UInt64; + +class VoteUpdatesTest { + + private static final Bytes32 BLOCK_A = + Bytes32.fromHexString("0x1111111111111111111111111111111111111111111111111111111111111111"); + private static final Bytes32 BLOCK_B = + Bytes32.fromHexString("0x2222222222222222222222222222222222222222222222222222222222222222"); + + private final VoteUpdates voteUpdates = new VoteUpdates(UInt64.valueOf(16)); + + @Test + void keepsOnlyTheFirstVoteWhenAValidatorVotesTwoBlocksInTheSameSlot() { + // A validator equivocating within a slot: the first vote must win, matching the spec's + // update_latest_messages (a same-epoch vote never overwrites the latest message). + voteUpdates.addVote(BLOCK_A, UInt64.ZERO, false); + voteUpdates.addVote(BLOCK_B, UInt64.ZERO, false); + + assertThat(collectVotes()).containsExactly(new Vote(BLOCK_A, UInt64.ZERO, false)); + } + + @Test + void keepsFirstVoteRegardlessOfWhichBlockIsSeenFirst() { + voteUpdates.addVote(BLOCK_B, UInt64.ZERO, false); + voteUpdates.addVote(BLOCK_A, UInt64.ZERO, false); + + assertThat(collectVotes()).containsExactly(new Vote(BLOCK_B, UInt64.ZERO, false)); + } + + @Test + void recordsDistinctValidatorsVotingDifferentBlocks() { + voteUpdates.addVote(BLOCK_A, UInt64.ZERO, false); + voteUpdates.addVote(BLOCK_B, UInt64.ONE, false); + + assertThat(collectVotes()) + .containsExactlyInAnyOrder( + new Vote(BLOCK_A, UInt64.ZERO, false), new Vote(BLOCK_B, UInt64.ONE, false)); + } + + private List collectVotes() { + final List votes = new ArrayList<>(); + voteUpdates.forEachDeferredVote( + (blockRoot, validatorIndex, fullPayloadHint) -> + votes.add(new Vote(blockRoot, validatorIndex, fullPayloadHint))); + return votes; + } + + private record Vote(Bytes32 blockRoot, UInt64 validatorIndex, boolean fullPayloadHint) {} +} diff --git a/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/forkchoice/ForkChoiceTest.java b/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/forkchoice/ForkChoiceTest.java index dd097f52ca3..a44c48afebd 100644 --- a/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/forkchoice/ForkChoiceTest.java +++ b/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/forkchoice/ForkChoiceTest.java @@ -65,8 +65,10 @@ import tech.pegasys.teku.bls.BLSSignatureVerifier; import tech.pegasys.teku.ethereum.performance.trackers.BlockProductionPerformance; import tech.pegasys.teku.infrastructure.async.SafeFuture; +import tech.pegasys.teku.infrastructure.async.StubAsyncRunner; import tech.pegasys.teku.infrastructure.async.eventthread.InlineEventThread; import tech.pegasys.teku.infrastructure.metrics.StubMetricsSystem; +import tech.pegasys.teku.infrastructure.time.StubTimeProvider; import tech.pegasys.teku.infrastructure.unsigned.UInt64; import tech.pegasys.teku.kzg.KZG; import tech.pegasys.teku.spec.Spec; @@ -82,6 +84,7 @@ import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.PayloadAttestationData; import tech.pegasys.teku.spec.datastructures.execution.ExecutionPayload; import tech.pegasys.teku.spec.datastructures.execution.PowBlock; +import tech.pegasys.teku.spec.datastructures.forkchoice.FastConfirmationStore; import tech.pegasys.teku.spec.datastructures.forkchoice.ForkChoiceNode; import tech.pegasys.teku.spec.datastructures.forkchoice.ForkChoicePayloadStatus; import tech.pegasys.teku.spec.datastructures.forkchoice.ReadOnlyForkChoiceStrategy; @@ -114,6 +117,8 @@ import tech.pegasys.teku.statetransition.datacolumns.DataAvailabilitySampler; import tech.pegasys.teku.statetransition.forkchoice.ForkChoice.OptimisticHeadSubscriber; import tech.pegasys.teku.statetransition.forkchoice.ForkChoiceUpdatedResultSubscriber.ForkChoiceUpdatedResultNotification; +import tech.pegasys.teku.statetransition.forkchoice.fastconfirmation.FastConfirmationEventChannel; +import tech.pegasys.teku.statetransition.forkchoice.fastconfirmation.FastConfirmationTracker; import tech.pegasys.teku.statetransition.payloadattestation.ValidatablePayloadAttestationMessage; import tech.pegasys.teku.statetransition.util.DebugDataDumper; import tech.pegasys.teku.statetransition.validation.BlockBroadcastValidator; @@ -192,6 +197,7 @@ private void setupWithSpec(final Spec unmockedSpec) { new ForkChoiceStateProvider(eventThread, recentChainData), new TickProcessor(spec, recentChainData), transitionBlockValidator, + FastConfirmationTracker.NOOP, DEFAULT_FORK_CHOICE_LATE_BLOCK_REORG_ENABLED, LateBlockReorgPreparationHandler.NOOP, debugDataDumper, @@ -223,6 +229,52 @@ private void setupWithSpec(final Spec unmockedSpec) { } } + @Test + void shouldNotInitializeFastConfirmationStoreWhenDisabled() { + assertThat(forkChoice.isFastConfirmationEnabled()).isFalse(); + assertThat(forkChoice.getFastConfirmationStore()).isEmpty(); + } + + @Test + void shouldInitializeFastConfirmationStoreWhenEnabled() { + recreateForkChoice(true, LateBlockReorgPreparationHandler.NOOP); + + final Checkpoint finalizedCheckpoint = recentChainData.getStore().getFinalizedCheckpoint(); + final FastConfirmationStore fastConfirmationStore = + forkChoice.getFastConfirmationStore().orElseThrow(); + assertThat(forkChoice.isFastConfirmationEnabled()).isTrue(); + assertThat(fastConfirmationStore.store()).isSameAs(recentChainData.getStore()); + assertThat(fastConfirmationStore.confirmedRoot()).isEqualTo(finalizedCheckpoint.getRoot()); + assertThat(fastConfirmationStore.previousEpochObservedJustifiedCheckpoint()) + .isEqualTo(finalizedCheckpoint); + assertThat(fastConfirmationStore.currentEpochObservedJustifiedCheckpoint()) + .isEqualTo(finalizedCheckpoint); + assertThat(fastConfirmationStore.previousEpochGreatestUnrealizedCheckpoint()) + .isEqualTo(finalizedCheckpoint); + assertThat(fastConfirmationStore.previousSlotHead()).isEqualTo(finalizedCheckpoint.getRoot()); + assertThat(fastConfirmationStore.currentSlotHead()).isEqualTo(finalizedCheckpoint.getRoot()); + } + + @Test + void shouldScheduleFastConfirmationUpdateOnSlotTickWhenEnabled() { + final StubAsyncRunner fastConfirmationAsyncRunner = new StubAsyncRunner(); + recreateForkChoice( + FastConfirmationTracker.create( + spec, + Optional.of(fastConfirmationAsyncRunner), + FastConfirmationEventChannel.NOOP, + metricsSystem, + StubTimeProvider.withTimeInMillis(0)), + LateBlockReorgPreparationHandler.NOOP); + final UInt64 nextSlot = recentChainData.getCurrentSlot().orElseThrow().plus(ONE); + + forkChoice.onTick( + spec.computeTimeMillisAtSlot(nextSlot, recentChainData.getGenesisTimeMillis()), + Optional.empty()); + + assertThat(fastConfirmationAsyncRunner.countDelayedActions()).isOne(); + } + @Test void shouldNotTriggerReorgWhenEmptyHeadSlotFilled() { // Run fork choice with an empty slot 1 @@ -469,6 +521,7 @@ void onBlock_shouldReorgWhenProposerWeightingMakesForkBestChain( new ForkChoiceStateProvider(eventThread, recentChainData), new TickProcessor(spec, recentChainData), transitionBlockValidator, + FastConfirmationTracker.NOOP, DEFAULT_FORK_CHOICE_LATE_BLOCK_REORG_ENABLED, LateBlockReorgPreparationHandler.NOOP, DebugDataDumper.NOOP, @@ -1116,6 +1169,7 @@ void onForkChoiceUpdatedResult_validGloasNode_shouldForwardExactHeadNode() { new ForkChoiceStateProvider(eventThread, recentChainData), new TickProcessor(spec, recentChainData), transitionBlockValidator, + FastConfirmationTracker.NOOP, DEFAULT_FORK_CHOICE_LATE_BLOCK_REORG_ENABLED, LateBlockReorgPreparationHandler.NOOP, debugDataDumper, @@ -1160,6 +1214,7 @@ void onForkChoiceUpdatedResult_invalidGloasNode_shouldForwardExactHeadNode() { new ForkChoiceStateProvider(eventThread, recentChainData), new TickProcessor(spec, recentChainData), transitionBlockValidator, + FastConfirmationTracker.NOOP, DEFAULT_FORK_CHOICE_LATE_BLOCK_REORG_ENABLED, LateBlockReorgPreparationHandler.NOOP, debugDataDumper, @@ -1888,6 +1943,27 @@ private void processHead(final UInt64 slot) { private void recreateForkChoice( final LateBlockReorgPreparationHandler lateBlockReorgPreparationHandler) { + recreateForkChoice(false, lateBlockReorgPreparationHandler); + } + + private void recreateForkChoice( + final boolean fastConfirmationEnabled, + final LateBlockReorgPreparationHandler lateBlockReorgPreparationHandler) { + final FastConfirmationTracker fastConfirmationTracker = + fastConfirmationEnabled + ? FastConfirmationTracker.create( + spec, + Optional.empty(), + FastConfirmationEventChannel.NOOP, + metricsSystem, + StubTimeProvider.withTimeInMillis(0)) + : FastConfirmationTracker.NOOP; + recreateForkChoice(fastConfirmationTracker, lateBlockReorgPreparationHandler); + } + + private void recreateForkChoice( + final FastConfirmationTracker fastConfirmationTracker, + final LateBlockReorgPreparationHandler lateBlockReorgPreparationHandler) { forkChoice = new ForkChoice( spec, @@ -1897,6 +1973,7 @@ private void recreateForkChoice( new ForkChoiceStateProvider(eventThread, recentChainData), new TickProcessor(spec, recentChainData), transitionBlockValidator, + fastConfirmationTracker, DEFAULT_FORK_CHOICE_LATE_BLOCK_REORG_ENABLED, lateBlockReorgPreparationHandler, debugDataDumper, diff --git a/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationCalculatorTest.java b/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationCalculatorTest.java new file mode 100644 index 00000000000..fce43389670 --- /dev/null +++ b/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationCalculatorTest.java @@ -0,0 +1,775 @@ +/* + * 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.statetransition.forkchoice.fastconfirmation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import it.unimi.dsi.fastutil.ints.IntOpenHashSet; +import it.unimi.dsi.fastutil.ints.IntSet; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import tech.pegasys.teku.infrastructure.unsigned.UInt64; +import tech.pegasys.teku.spec.Spec; +import tech.pegasys.teku.spec.TestSpecFactory; +import tech.pegasys.teku.spec.datastructures.blocks.BlockCheckpoints; +import tech.pegasys.teku.spec.datastructures.forkchoice.FastConfirmationStore; +import tech.pegasys.teku.spec.datastructures.forkchoice.ForkChoiceNode; +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.forkchoice.VoteSnapshot; +import tech.pegasys.teku.spec.datastructures.forkchoice.VoteTracker; +import tech.pegasys.teku.spec.datastructures.state.Checkpoint; +import tech.pegasys.teku.spec.datastructures.state.beaconstate.BeaconState; +import tech.pegasys.teku.spec.generator.ChainBuilder; + +class FastConfirmationCalculatorTest { + + // Minimal preset: SLOTS_PER_EPOCH == 8 + private final Spec spec = TestSpecFactory.createMinimalPhase0(); + private static final Spec GLOAS_SPEC = TestSpecFactory.createMinimalGloas(); + private final ReadOnlyStore store = mock(ReadOnlyStore.class); + private final ReadOnlyForkChoiceStrategy forkChoice = mock(ReadOnlyForkChoiceStrategy.class); + + // A canonical linear chain where block at index i has slot i and parent = chain(i - 1). + private final List chain = new ArrayList<>(); + private final Map slotByRoot = new HashMap<>(); + + @BeforeEach + void setUp() { + when(store.getForkChoiceStrategy()).thenReturn(forkChoice); + // Default to no votes; the weight tests re-stub with specific votes before building a + // calculator. + when(store.getVoteSnapshot()) + .thenReturn(VoteSnapshot.create(UInt64.ZERO, new VoteTracker[] {VoteTracker.DEFAULT})); + } + + @Test + void shouldReturnBlockSlotAndEpoch() { + buildLinearChain(12); + final FastConfirmationCalculator calculator = calculator(chain.get(11), 11); + + assertThat(calculator.getBlockSlot(chain.get(10))).isEqualTo(UInt64.valueOf(10)); + // slot 10 -> epoch 1 with SLOTS_PER_EPOCH == 8 + assertThat(calculator.getBlockEpoch(chain.get(10))).isEqualTo(UInt64.ONE); + assertThat(calculator.getBlockEpoch(chain.get(7))).isEqualTo(UInt64.ZERO); + } + + @Test + void shouldCollectAncestorRootsDownToButExcludingTerminal() { + buildLinearChain(6); + final FastConfirmationCalculator calculator = calculator(chain.get(5), 5); + + // Ancestors of block 5 down to (excluding) block 2, oldest first. + assertThat(calculator.getAncestorRoots(chain.get(5), chain.get(2))) + .containsExactly(chain.get(3), chain.get(4), chain.get(5)); + } + + @Test + void shouldReturnEmptyAncestorRootsWhenTerminalNotInChain() { + buildLinearChain(6); + // A terminal block that exists (has a slot) but is not on the chain of block 5. + final Bytes32 sideTerminal = Bytes32.random(); + when(forkChoice.blockSlot(sideTerminal)).thenReturn(Optional.of(UInt64.valueOf(3))); + final FastConfirmationCalculator calculator = calculator(chain.get(5), 5); + + assertThat(calculator.getAncestorRoots(chain.get(5), sideTerminal)).isEmpty(); + } + + @Test + void shouldResolveIsAncestor() { + buildLinearChain(6); + final FastConfirmationCalculator calculator = calculator(chain.get(5), 5); + + assertThat(calculator.isAncestor(chain.get(5), chain.get(2))).isTrue(); + // A block is its own ancestor + assertThat(calculator.isAncestor(chain.get(3), chain.get(3))).isTrue(); + // The descendant/ancestor relationship is not symmetric + assertThat(calculator.isAncestor(chain.get(2), chain.get(5))).isFalse(); + // An unknown descendant is not a descendant of anything + assertThat(calculator.isAncestor(Bytes32.random(), chain.get(2))).isFalse(); + } + + @Test + void shouldComputeCheckpointForBlockAndCurrentTarget() { + buildLinearChain(11); + // currentSlot 10 -> currentEpoch 1 + final FastConfirmationCalculator calculator = calculator(chain.get(10), 10); + + // Checkpoint block for epoch 0 is the block at the epoch-0 start slot (0). + assertThat(calculator.getCheckpointForBlock(chain.get(5), UInt64.ZERO)) + .isEqualTo(new Checkpoint(UInt64.ZERO, chain.get(0))); + + // Current target = checkpoint for the head at the current epoch (1), start slot 8. + assertThat(calculator.getCurrentTarget()).isEqualTo(new Checkpoint(UInt64.ONE, chain.get(8))); + } + + @Test + void shouldUseUnrealizedJustificationAsVotingSourceForPriorEpochBlock() { + buildLinearChain(11); + final Checkpoint realized = checkpoint(0); + final Checkpoint unrealized = checkpoint(1); + setCheckpoints(chain.get(5), realized, unrealized); + // currentSlot 10 -> currentEpoch 1; block 5 is in epoch 0 (prior epoch) + final FastConfirmationCalculator calculator = calculator(chain.get(10), 10); + + assertThat(calculator.getVotingSource(chain.get(5))).isEqualTo(unrealized); + assertThat(calculator.getUnrealizedJustification(chain.get(5))).isEqualTo(unrealized); + } + + @Test + void shouldUseRealizedJustificationAsVotingSourceForCurrentEpochBlock() { + buildLinearChain(11); + final Checkpoint realized = checkpoint(1); + final Checkpoint unrealized = checkpoint(2); + setCheckpoints(chain.get(9), realized, unrealized); + // currentSlot 10 -> currentEpoch 1; block 9 is in epoch 1 (current epoch) + final FastConfirmationCalculator calculator = calculator(chain.get(10), 10); + + assertThat(calculator.getVotingSource(chain.get(9))).isEqualTo(realized); + } + + @Test + void shouldReturnHeadStateAsPulledUpWhenAlreadyInCurrentEpoch() { + final BeaconState headState = genesisState(); + // currentSlot 0 -> currentEpoch 0 == head state epoch, so no pull-up occurs. + final FastConfirmationCalculator calculator = calculatorWithHeadState(headState, 0); + + assertThat(calculator.getPulledUpHeadState()).isSameAs(headState); + } + + @Test + void shouldPullUpHeadStateWhenBehindCurrentEpoch() { + final BeaconState headState = genesisState(); + // currentSlot 8 -> currentEpoch 1 > head state epoch 0, so pull up to slot 8. + final FastConfirmationCalculator calculator = calculatorWithHeadState(headState, 8); + + final BeaconState pulledUp = calculator.getPulledUpHeadState(); + assertThat(pulledUp.getSlot()).isEqualTo(UInt64.valueOf(8)); + // Memoized: repeated reads return the same instance. + assertThat(calculator.getPulledUpHeadState()).isSameAs(pulledUp); + } + + @Test + void shouldUnionAllSlotCommitteesToTheActiveValidatorSetOverAnEpoch() { + final BeaconState headState = genesisState(); + final FastConfirmationCalculator calculator = calculatorWithHeadState(headState, 0); + + final IntSet allCommitteeMembers = new IntOpenHashSet(); + for (int slot = 0; slot < spec.getSlotsPerEpoch(UInt64.ZERO); slot++) { + allCommitteeMembers.addAll(calculator.getSlotCommittee(UInt64.valueOf(slot))); + } + + // Every active validator is assigned to exactly one committee per epoch, so the union of all + // slot committees over epoch 0 must be exactly the active validator set. + final IntSet activeValidators = + new IntOpenHashSet(spec.getActiveValidatorIndices(headState, UInt64.ZERO)); + assertThat(allCommitteeMembers).isEqualTo(activeValidators); + assertThat(calculator.getSlotCommittee(UInt64.ZERO)).isNotEmpty(); + } + + @Test + void shouldSumAttestationScoreForNonEquivocatingUnslashedVotersSupportingADescendant() { + buildLinearChain(6); + // Validator 5 votes for a descendant too, but is slashed, so it must be excluded. + final BeaconState balanceSource = withSlashedValidator(genesisState(), 5); + when(store.getVoteSnapshot()) + .thenReturn( + voteSnapshot( + Map.of( + 0, vote(chain.get(5)), // descendant of chain[3] -> counts + 1, vote(chain.get(2)), // ancestor of chain[3], not a descendant -> excluded + 2, equivocatingVote(chain.get(5)), // equivocating -> excluded + 4, vote(chain.get(3)), // chain[3] itself -> counts + 5, vote(chain.get(5))))); // descendant but slashed -> excluded + final FastConfirmationCalculator calculator = calculator(chain.get(5), 5); + + final UInt64 expected = + effectiveBalance(balanceSource, 0).plus(effectiveBalance(balanceSource, 4)); + assertThat(calculator.getAttestationScore(chain.get(3), balanceSource)).isEqualTo(expected); + } + + @Test + void shouldSumBlockSupportOnlyForInRangeVotersOfTheExactBlockRoot() { + final BeaconState balanceSource = genesisState(); + final int voterForBlock = firstCommitteeMember(balanceSource, UInt64.ZERO); // slot 0 + final int voterForOther = firstCommitteeMember(balanceSource, UInt64.ONE); // slot 1 + final int outOfRangeVoter = firstCommitteeMember(balanceSource, UInt64.valueOf(3)); // slot 3 + final Bytes32 blockRoot = Bytes32.random(); + when(store.getVoteSnapshot()) + .thenReturn( + voteSnapshot( + Map.of( + voterForBlock, vote(blockRoot), + voterForOther, vote(Bytes32.random()), + outOfRangeVoter, vote(blockRoot)))); + final FastConfirmationCalculator calculator = calculatorWithHeadState(balanceSource, 0); + + // Range [0,1] covers voterForBlock (counts) and voterForOther (wrong root); slot 3 is excluded. + assertThat( + calculator.getBlockSupportBetweenSlots( + balanceSource, blockRoot, UInt64.ZERO, UInt64.ONE)) + .isEqualTo(effectiveBalance(balanceSource, voterForBlock)); + } + + @Test + void shouldExcludeEquivocatingVotersFromBlockSupport() { + final BeaconState balanceSource = genesisState(); + final int voter = firstCommitteeMember(balanceSource, UInt64.ZERO); + final Bytes32 blockRoot = Bytes32.random(); + when(store.getVoteSnapshot()) + .thenReturn(voteSnapshot(Map.of(voter, equivocatingVote(blockRoot)))); + final FastConfirmationCalculator calculator = calculatorWithHeadState(balanceSource, 0); + + assertThat( + calculator.getBlockSupportBetweenSlots( + balanceSource, blockRoot, UInt64.ZERO, UInt64.ZERO)) + .isEqualTo(UInt64.ZERO); + } + + @Test + void shouldSumEquivocationScoreForInRangeEquivocatingValidators() { + final BeaconState balanceSource = genesisState(); + final int equivocator = firstCommitteeMember(balanceSource, UInt64.ZERO); // slot 0 + final int outOfRange = firstCommitteeMember(balanceSource, UInt64.valueOf(3)); // slot 3 + when(store.getVoteSnapshot()) + .thenReturn( + voteSnapshot( + Map.of( + equivocator, equivocatingVote(Bytes32.random()), + outOfRange, equivocatingVote(Bytes32.random())))); + final FastConfirmationCalculator calculator = calculatorWithHeadState(balanceSource, 0); + + // Only the equivocator assigned to slot 0 is inside the [0,0] range. + assertThat(calculator.getEquivocationScore(balanceSource, UInt64.ZERO, UInt64.ZERO)) + .isEqualTo(effectiveBalance(balanceSource, equivocator)); + } + + @Test + void shouldComputeAdversarialWeightAsByzantineFractionWhenNoEquivocation() { + buildLinearChain(11); + final BeaconState balanceSource = genesisState(); + final FastConfirmationCalculator calculator = calculatorWithHeadState(balanceSource, 10); + + final UInt64 total = spec.getTotalActiveBalance(balanceSource); + final UInt64 expected = + estimate(total, 3, 6) + .dividedBy(100) + .times(FastConfirmationRuleUtil.CONFIRMATION_BYZANTINE_THRESHOLD); + assertThat( + calculator.computeAdversarialWeight( + balanceSource, UInt64.valueOf(3), UInt64.valueOf(6))) + .isEqualTo(expected); + } + + @Test + void shouldReduceAdversarialWeightByEquivocationScore() { + buildLinearChain(11); + final BeaconState balanceSource = genesisState(); + final UInt64 withoutEquivocation = + calculatorWithHeadState(balanceSource, 10) + .computeAdversarialWeight(balanceSource, UInt64.valueOf(3), UInt64.valueOf(6)); + + // Make the slot-3 committee member equivocate; it is inside the [3,6] range. + final int equivocator = firstCommitteeMember(balanceSource, UInt64.valueOf(3)); + when(store.getVoteSnapshot()) + .thenReturn(voteSnapshot(Map.of(equivocator, equivocatingVote(Bytes32.random())))); + final UInt64 withEquivocation = + calculatorWithHeadState(balanceSource, 10) + .computeAdversarialWeight(balanceSource, UInt64.valueOf(3), UInt64.valueOf(6)); + + assertThat(withEquivocation).isLessThan(withoutEquivocation); + } + + @Test + void shouldDiscountParentSupportInEmptySlotsBeyondAdversarialWeight() { + final BeaconState balanceSource = genesisState(); + // Block at slot 4 whose parent is at slot 2 leaves slot 3 empty. + final Bytes32 parent = Bytes32.random(); + final Bytes32 block = Bytes32.random(); + when(forkChoice.blockSlot(parent)).thenReturn(Optional.of(UInt64.valueOf(2))); + when(forkChoice.blockSlot(block)).thenReturn(Optional.of(UInt64.valueOf(4))); + when(forkChoice.blockParentRoot(block)).thenReturn(Optional.of(parent)); + // A slot-3 committee member supports the parent across the empty slot. + final int voter = firstCommitteeMember(balanceSource, UInt64.valueOf(3)); + when(store.getVoteSnapshot()).thenReturn(voteSnapshot(Map.of(voter, vote(parent)))); + final FastConfirmationCalculator calculator = calculatorWithHeadState(balanceSource, 10); + + final UInt64 total = spec.getTotalActiveBalance(balanceSource); + final UInt64 adversarial = + estimate(total, 3, 3) + .dividedBy(100) + .times(FastConfirmationRuleUtil.CONFIRMATION_BYZANTINE_THRESHOLD); + final UInt64 expected = effectiveBalance(balanceSource, voter).minus(adversarial); + assertThat(calculator.computeEmptySlotSupportDiscount(balanceSource, block)) + .isEqualTo(expected); + } + + @Test + void shouldComputeSafetyThresholdFromWeightsProposerScoreAndAdversarialWeight() { + buildLinearChain(11); + final BeaconState balanceSource = genesisState(); + // Block chain[3] (slot 3), parent chain[2] (slot 2): no empty slot, so no support discount. + final FastConfirmationCalculator calculator = calculatorWithHeadState(balanceSource, 5); + + final UInt64 total = spec.getTotalActiveBalance(balanceSource); + // parentSlot + 1 = 3 .. currentSlot - 1 = 4 + final UInt64 maximumSupport = estimate(total, 3, 4); + final UInt64 proposerScore = spec.getProposerBoostAmount(balanceSource); + // Not crossing an epoch boundary, so the adversarial range starts at the block slot (3). + final UInt64 adversarial = + estimate(total, 3, 4) + .dividedBy(100) + .times(FastConfirmationRuleUtil.CONFIRMATION_BYZANTINE_THRESHOLD); + final UInt64 expected = + maximumSupport.plus(proposerScore).plus(adversarial.times(2)).dividedBy(2); + assertThat(calculator.computeSafetyThreshold(chain.get(3), balanceSource)).isEqualTo(expected); + } + + @Test + void shouldNotConfirmBlockThatIsNotFullyValidated() { + buildLinearChain(11); + final BeaconState balanceSource = genesisState(); + when(forkChoice.isFullyValidated(chain.get(3))).thenReturn(false); + final FastConfirmationCalculator calculator = calculatorWithHeadState(balanceSource, 5); + + assertThat(calculator.isOneConfirmed(balanceSource, chain.get(3))).isFalse(); + } + + @Test + void shouldConfirmBlockWhenSupportExceedsSafetyThreshold() { + buildLinearChain(11); + final BeaconState balanceSource = genesisState(); + when(forkChoice.isFullyValidated(chain.get(3))).thenReturn(true); + // All active validators vote for the block, so support equals the total active balance. + when(store.getVoteSnapshot()) + .thenReturn(voteSnapshot(allValidatorsVotingFor(balanceSource, chain.get(3)))); + final FastConfirmationCalculator calculator = calculatorWithHeadState(balanceSource, 5); + + assertThat(calculator.isOneConfirmed(balanceSource, chain.get(3))).isTrue(); + } + + @Test + void shouldNotConfirmBlockWithoutSupport() { + buildLinearChain(11); + final BeaconState balanceSource = genesisState(); + when(forkChoice.isFullyValidated(chain.get(3))).thenReturn(true); + // No votes -> zero support, which cannot exceed the positive safety threshold. + final FastConfirmationCalculator calculator = calculatorWithHeadState(balanceSource, 5); + + assertThat(calculator.isOneConfirmed(balanceSource, chain.get(3))).isFalse(); + } + + @Test + void shouldConfirmGloasBlockThatIsPresentEvenWhenNotFullyValidated() { + buildLinearChain(11); + final BeaconState balanceSource = gloasGenesisState(); + // A Gloas block stays OPTIMISTIC (not fully validated) until its execution payload envelope is + // revealed, but the rule confirms the PENDING (block-level) node, so presence in fork choice is + // enough. contains() defaults to true from buildLinearChain. + when(forkChoice.isFullyValidated(chain.get(3))).thenReturn(false); + when(store.getVoteSnapshot()) + .thenReturn(voteSnapshot(allValidatorsVotingFor(balanceSource, chain.get(3)))); + final FastConfirmationCalculator calculator = gloasCalculator(balanceSource, chain.get(5), 5); + + assertThat(calculator.isOneConfirmed(balanceSource, chain.get(3))).isTrue(); + } + + @Test + void shouldNotConfirmGloasBlockThatIsAbsentFromForkChoice() { + buildLinearChain(11); + // An execution-invalid Gloas block is pruned from fork choice, so contains() is false and it + // must never be confirmed even though isFullyValidated would report true here. + when(forkChoice.isFullyValidated(chain.get(3))).thenReturn(true); + when(forkChoice.contains(chain.get(3))).thenReturn(false); + final FastConfirmationCalculator calculator = + gloasCalculator(mock(BeaconState.class), chain.get(5), 5); + + assertThat(calculator.isOneConfirmed(mock(BeaconState.class), chain.get(3))).isFalse(); + } + + @Test + void shouldScoreOnlyVotesWhoseTargetMatchesTheCurrentTarget() { + buildLinearChain(11); + final BeaconState balanceSource = genesisState(); + // currentSlot 10 -> currentEpoch 1; current target = checkpoint(1, chain[8]). + when(store.getVoteSnapshot()) + .thenReturn( + voteSnapshot( + Map.of( + 0, vote(chain.get(9), 9), // target checkpoint(1, chain[8]) -> counts + 1, vote(chain.get(9), 9), // counts + 2, vote(chain.get(3), 3), // target checkpoint(0, chain[0]) -> excluded + 3, equivocatingVote(chain.get(9))))); // equivocating -> excluded + final FastConfirmationCalculator calculator = calculator(balanceSource, chain.get(10), 10); + + final BeaconState pulledUp = calculator.getPulledUpHeadState(); + final UInt64 expected = effectiveBalance(pulledUp, 0).plus(effectiveBalance(pulledUp, 1)); + assertThat(calculator.getCurrentTargetScore()).isEqualTo(expected); + } + + @Test + void shouldComputeHonestFfgSupportFromRemainingWeightWhenNoVotes() { + buildLinearChain(11); + final BeaconState balanceSource = genesisState(); + // No votes: honest support is just the honest fraction of the not-yet-assigned FFG weight. + final FastConfirmationCalculator calculator = calculator(balanceSource, chain.get(10), 12); + + final UInt64 total = spec.getTotalActiveBalance(calculator.getPulledUpHeadState()); + // epochStart(epoch 1) = 8, currentSlot - 1 = 11 + final UInt64 ffgWeightTillNow = estimate(total, 8, 11); + final UInt64 expected = + total + .minusMinZero(ffgWeightTillNow) + .dividedBy(100) + .times(100 - FastConfirmationRuleUtil.CONFIRMATION_BYZANTINE_THRESHOLD); + assertThat(calculator.computeHonestFfgSupportForCurrentTarget()).isEqualTo(expected); + } + + @Test + void shouldReportNoConflictingCheckpointWhenTargetIsGreatestUnrealizedJustified() { + buildLinearChain(11); + final BeaconState balanceSource = genesisState(); + // currentSlot 10 -> current target = checkpoint(1, chain[8]); make it the greatest unrealized. + final Checkpoint target = new Checkpoint(UInt64.ONE, chain.get(8)); + final Checkpoint zero = new Checkpoint(UInt64.ZERO, Bytes32.ZERO); + when(store.getJustifiedCheckpoint()).thenReturn(zero); + final ProtoNodeData blockData = mock(ProtoNodeData.class); + when(blockData.getCheckpoints()).thenReturn(new BlockCheckpoints(zero, zero, target, zero)); + when(forkChoice.getBlockData()).thenReturn(List.of(blockData)); + final FastConfirmationCalculator calculator = calculator(balanceSource, chain.get(10), 10); + + assertThat(calculator.willNoConflictingCheckpointBeJustified()).isTrue(); + } + + @Test + void shouldExpectCurrentTargetToBeJustifiedUnderFullParticipation() { + buildLinearChain(11); + final BeaconState balanceSource = genesisState(); + // Everyone votes for the target block (chain[8]) in the current epoch. + final Map allVotes = new HashMap<>(); + for (final int index : spec.getActiveValidatorIndices(balanceSource, UInt64.ONE)) { + allVotes.put(index, vote(chain.get(8), 8)); + } + when(store.getVoteSnapshot()).thenReturn(voteSnapshot(allVotes)); + final FastConfirmationCalculator calculator = calculator(balanceSource, chain.get(10), 10); + + assertThat(calculator.willCurrentTargetBeJustified()).isTrue(); + } + + @Test + void shouldNotReconfirmWhenObservedJustifiedCheckpointNotInConfirmedChain() { + buildLinearChain(11); + final BeaconState genesis = genesisState(); + // Observed justified checkpoint references a block that is not on chain[3]'s chain. + final Checkpoint observed = new Checkpoint(UInt64.ZERO, Bytes32.random()); + final FastConfirmationCalculator calculator = + reconfirmationCalculator(genesis, chain.get(3), observed, 5); + + assertThat(calculator.isConfirmedChainSafe(chain.get(3))).isFalse(); + } + + @Test + void shouldReconfirmWhenEveryChainBlockIsOneConfirmed() { + buildLinearChain(11); + final BeaconState genesis = genesisState(); + when(forkChoice.isFullyValidated(any())).thenReturn(true); + when(store.getVoteSnapshot()) + .thenReturn(voteSnapshot(allValidatorsVotingFor(genesis, chain.get(3)))); + // Observed justified checkpoint = (0, chain[0]); reconfirms chain[1..3]. + final Checkpoint observed = new Checkpoint(UInt64.ZERO, chain.get(0)); + final FastConfirmationCalculator calculator = + reconfirmationCalculator(genesis, chain.get(3), observed, 5); + + assertThat(calculator.isConfirmedChainSafe(chain.get(3))).isTrue(); + } + + @Test + void shouldNotReconfirmWhenAChainBlockIsNotOneConfirmed() { + buildLinearChain(11); + final BeaconState genesis = genesisState(); + when(forkChoice.isFullyValidated(any())).thenReturn(true); + // No votes -> zero support, so no chain block is one-confirmed. + final Checkpoint observed = new Checkpoint(UInt64.ZERO, chain.get(0)); + final FastConfirmationCalculator calculator = + reconfirmationCalculator(genesis, chain.get(3), observed, 5); + + assertThat(calculator.isConfirmedChainSafe(chain.get(3))).isFalse(); + } + + @Test + void shouldNotAdvanceConfirmedRootWhenItIsAlreadyTheHead() { + buildLinearChain(11); + // Head's unrealized justification (epoch 0) satisfies the phase-2 guard. + setCheckpoints(chain.get(10), checkpoint(0), checkpoint(0)); + // currentSlot 10 -> currentEpoch 1; head == confirmed == chain[10]. + final FastConfirmationCalculator calculator = + descendantCalculator(genesisState(), chain.get(10), chain.get(9), 10); + + assertThat(calculator.findLatestConfirmedDescendant(chain.get(10))).isEqualTo(chain.get(10)); + } + + @Test + void shouldAdvanceConfirmedRootToHeadAtEpochStartUnderFullParticipation() { + buildLinearChain(11); + final BeaconState genesis = genesisState(); + when(forkChoice.isFullyValidated(any())).thenReturn(true); + // Everyone votes for the head, supporting every ancestor. + when(store.getVoteSnapshot()) + .thenReturn(voteSnapshot(allValidatorsVotingFor(genesis, chain.get(10)))); + // Voting source of the previous slot head (chain[9]) for the phase-1 guard. + setCheckpoints(chain.get(9), checkpoint(0), checkpoint(0)); + // currentSlot 8 = epoch 1 start; head=chain[10], previousSlotHead=chain[9], confirmed=chain[6]. + final FastConfirmationCalculator calculator = + descendantCalculator(genesis, chain.get(10), chain.get(9), 8); + + assertThat(calculator.findLatestConfirmedDescendant(chain.get(6))).isEqualTo(chain.get(10)); + } + + @Test + void shouldRevertToFinalizedWhenConfirmedRootIsStale() { + buildLinearChain(18); + when(store.getFinalizedCheckpoint()).thenReturn(new Checkpoint(UInt64.ZERO, chain.get(0))); + // confirmed = chain[0] (epoch 0); currentSlot 17 -> currentEpoch 2, so confirmed is >1 epoch + // old + // and reverts to the finalized block, which is itself too old to advance. + final FastConfirmationCalculator calculator = + latestConfirmedCalculator(genesisState(), chain.get(0), chain.get(17), 17); + + assertThat(calculator.getLatestConfirmed()).isEqualTo(chain.get(0)); + } + + @Test + void shouldAdvanceRecentConfirmedRootTowardHead() { + buildLinearChain(11); + // Head's unrealized justification (epoch 0) satisfies find_latest_confirmed_descendant phase 2. + setCheckpoints(chain.get(10), checkpoint(0), checkpoint(0)); + when(store.getFinalizedCheckpoint()).thenReturn(new Checkpoint(UInt64.ZERO, chain.get(0))); + // confirmed == head == chain[10]; currentSlot 10 -> currentEpoch 1, not an epoch start. + final FastConfirmationCalculator calculator = + latestConfirmedCalculator(genesisState(), chain.get(10), chain.get(10), 10); + + assertThat(calculator.getLatestConfirmed()).isEqualTo(chain.get(10)); + } + + private UInt64 estimate( + final UInt64 totalActiveBalance, final long startSlot, final long endSlot) { + return FastConfirmationRuleUtil.estimateCommitteeWeightBetweenSlots( + spec, totalActiveBalance, UInt64.valueOf(startSlot), UInt64.valueOf(endSlot)); + } + + private Map allValidatorsVotingFor( + final BeaconState state, final Bytes32 root) { + final Map votesByIndex = new HashMap<>(); + for (final int index : spec.getActiveValidatorIndices(state, UInt64.ZERO)) { + votesByIndex.put(index, vote(root)); + } + return votesByIndex; + } + + private FastConfirmationCalculator calculator(final Bytes32 head, final long currentSlot) { + return new FastConfirmationCalculator( + spec, fcrStore(head), placeholderStates(), UInt64.valueOf(currentSlot)); + } + + private FastConfirmationCalculator calculatorWithHeadState( + final BeaconState headState, final long currentSlot) { + return calculator(headState, Bytes32.random(), currentSlot); + } + + private FastConfirmationCalculator calculator( + final BeaconState headState, final Bytes32 head, final long currentSlot) { + final FastConfirmationStates states = + new FastConfirmationStates(Optional.empty(), mock(BeaconState.class), headState); + return new FastConfirmationCalculator( + spec, fcrStore(head), states, UInt64.valueOf(currentSlot)); + } + + private FastConfirmationCalculator reconfirmationCalculator( + final BeaconState state, + final Bytes32 head, + final Checkpoint currentObservedJustified, + final long currentSlot) { + // The state doubles as the previous balance source and the committee shuffling source. + final FastConfirmationStates states = + new FastConfirmationStates(Optional.of(state), mock(BeaconState.class), state); + return new FastConfirmationCalculator( + spec, fcrStore(head, currentObservedJustified), states, UInt64.valueOf(currentSlot)); + } + + private FastConfirmationCalculator descendantCalculator( + final BeaconState state, + final Bytes32 head, + final Bytes32 previousSlotHead, + final long currentSlot) { + // The state doubles as the current balance source and the committee shuffling source. + final Checkpoint zero = new Checkpoint(UInt64.ZERO, Bytes32.ZERO); + final FastConfirmationStore fcrStore = + new FastConfirmationStore(store, Bytes32.ZERO, zero, zero, zero, previousSlotHead, head); + final FastConfirmationStates states = + new FastConfirmationStates(Optional.empty(), state, state); + return new FastConfirmationCalculator(spec, fcrStore, states, UInt64.valueOf(currentSlot)); + } + + private FastConfirmationCalculator latestConfirmedCalculator( + final BeaconState state, + final Bytes32 confirmedRoot, + final Bytes32 head, + final long currentSlot) { + final Checkpoint zero = new Checkpoint(UInt64.ZERO, Bytes32.ZERO); + // The observed justified checkpoint root must be an in-tree block; get_latest_confirmed reads + // its slot unconditionally. chain[0] exists in every test chain. + final Checkpoint observedJustified = new Checkpoint(UInt64.ZERO, chain.get(0)); + final FastConfirmationStore fcrStore = + new FastConfirmationStore(store, confirmedRoot, zero, observedJustified, zero, head, head); + final FastConfirmationStates states = + new FastConfirmationStates(Optional.of(state), state, state); + return new FastConfirmationCalculator(spec, fcrStore, states, UInt64.valueOf(currentSlot)); + } + + private FastConfirmationStore fcrStore(final Bytes32 head) { + return fcrStore(head, new Checkpoint(UInt64.ZERO, Bytes32.ZERO)); + } + + private FastConfirmationStore fcrStore( + final Bytes32 head, final Checkpoint currentObservedJustified) { + final Checkpoint zero = new Checkpoint(UInt64.ZERO, Bytes32.ZERO); + return new FastConfirmationStore( + store, Bytes32.ZERO, zero, currentObservedJustified, zero, Bytes32.ZERO, head); + } + + private FastConfirmationStates placeholderStates() { + return new FastConfirmationStates( + Optional.empty(), mock(BeaconState.class), mock(BeaconState.class)); + } + + private BeaconState genesisState() { + return ChainBuilder.create(spec).generateGenesis().getState(); + } + + private BeaconState gloasGenesisState() { + return ChainBuilder.create(GLOAS_SPEC).generateGenesis().getState(); + } + + private FastConfirmationCalculator gloasCalculator( + final BeaconState headState, final Bytes32 head, final long currentSlot) { + final FastConfirmationStates states = + new FastConfirmationStates(Optional.empty(), mock(BeaconState.class), headState); + return new FastConfirmationCalculator( + GLOAS_SPEC, fcrStore(head), states, UInt64.valueOf(currentSlot)); + } + + private void buildLinearChain(final int length) { + for (int slot = 0; slot < length; slot++) { + final Bytes32 root = Bytes32.random(); + chain.add(root); + slotByRoot.put(root, slot); + } + for (int slot = 0; slot < length; slot++) { + final Bytes32 root = chain.get(slot); + when(forkChoice.blockSlot(root)).thenReturn(Optional.of(UInt64.valueOf(slot))); + when(forkChoice.contains(root)).thenReturn(true); + final Bytes32 parent = slot > 0 ? chain.get(slot - 1) : Bytes32.ZERO; + when(forkChoice.blockParentRoot(root)).thenReturn(Optional.of(parent)); + } + // get_ancestor(root, slot): walk up the parent chain until reaching a block at or before slot. + when(forkChoice.getAncestor(any(), any())) + .thenAnswer( + invocation -> { + final Bytes32 root = invocation.getArgument(0); + final UInt64 targetSlot = invocation.getArgument(1); + return ancestorIndex(root, targetSlot).map(chain::get); + }); + // Node-based ancestry used by ForkChoiceUtil.isAncestor. + when(forkChoice.getAncestorNode(any(), any())) + .thenAnswer( + invocation -> { + final ForkChoiceNode node = invocation.getArgument(0); + final UInt64 targetSlot = invocation.getArgument(1); + return ancestorIndex(node.blockRoot(), targetSlot) + .map(chain::get) + .map(ForkChoiceNode::createBase); + }); + } + + private Optional ancestorIndex(final Bytes32 root, final UInt64 targetSlot) { + final Integer index = slotByRoot.get(root); + if (index == null) { + return Optional.empty(); + } + int i = index; + while (UInt64.valueOf(i).isGreaterThan(targetSlot)) { + i--; + } + return Optional.of(i); + } + + private void setCheckpoints( + final Bytes32 root, final Checkpoint justified, final Checkpoint unrealizedJustified) { + final Checkpoint zero = new Checkpoint(UInt64.ZERO, Bytes32.ZERO); + final ProtoNodeData data = mock(ProtoNodeData.class); + when(data.getCheckpoints()) + .thenReturn(new BlockCheckpoints(justified, zero, unrealizedJustified, zero)); + when(forkChoice.getBlockData(root)).thenReturn(Optional.of(data)); + } + + private Checkpoint checkpoint(final int epoch) { + return new Checkpoint(UInt64.valueOf(epoch), Bytes32.random()); + } + + private VoteSnapshot voteSnapshot(final Map votesByIndex) { + final int size = votesByIndex.keySet().stream().mapToInt(Integer::intValue).max().orElse(0) + 1; + final VoteTracker[] voteArray = new VoteTracker[size]; + votesByIndex.forEach((index, voteTracker) -> voteArray[index] = voteTracker); + return VoteSnapshot.create(UInt64.valueOf(size - 1), voteArray); + } + + private VoteTracker vote(final Bytes32 root) { + return new VoteTracker(Bytes32.ZERO, root); + } + + private VoteTracker vote(final Bytes32 root, final long slot) { + return new VoteTracker( + Bytes32.ZERO, root, false, false, UInt64.valueOf(slot), false, UInt64.ZERO, false); + } + + private VoteTracker equivocatingVote(final Bytes32 root) { + return vote(root).createNextEquivocating(); + } + + private UInt64 effectiveBalance(final BeaconState state, final int validatorIndex) { + return state.getValidators().get(validatorIndex).getEffectiveBalance(); + } + + private int firstCommitteeMember(final BeaconState state, final UInt64 slot) { + return spec.getBeaconCommittee(state, slot, UInt64.ZERO).getInt(0); + } + + private BeaconState withSlashedValidator(final BeaconState state, final int index) { + return state.updated( + mutable -> + mutable + .getValidators() + .set(index, mutable.getValidators().get(index).withSlashed(true))); + } +} diff --git a/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationRuleUtilTest.java b/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationRuleUtilTest.java new file mode 100644 index 00000000000..a69deb19fd2 --- /dev/null +++ b/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationRuleUtilTest.java @@ -0,0 +1,256 @@ +/* + * 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.statetransition.forkchoice.fastconfirmation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.Test; +import tech.pegasys.teku.infrastructure.unsigned.UInt64; +import tech.pegasys.teku.spec.Spec; +import tech.pegasys.teku.spec.TestSpecFactory; +import tech.pegasys.teku.spec.datastructures.blocks.BlockCheckpoints; +import tech.pegasys.teku.spec.datastructures.forkchoice.FastConfirmationStore; +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.Checkpoint; + +class FastConfirmationRuleUtilTest { + + private final Spec spec = TestSpecFactory.createMinimalPhase0(); + private final ReadOnlyStore store = mock(ReadOnlyStore.class); + + @Test + void shouldUpdateSlotHeadsOnlyForOrdinarySlot() { + final Bytes32 previousHead = Bytes32.random(); + final Bytes32 currentHead = Bytes32.random(); + final Bytes32 newHead = Bytes32.random(); + final Checkpoint previousObserved = checkpoint(1); + final Checkpoint currentObserved = checkpoint(2); + final Checkpoint previousGreatestUnrealized = checkpoint(3); + final Checkpoint greatestUnrealized = checkpoint(4); + final FastConfirmationStore fcrStore = + fastConfirmationStore( + previousObserved, + currentObserved, + previousGreatestUnrealized, + previousHead, + currentHead); + + final FastConfirmationStore updatedStore = + FastConfirmationRuleUtil.updateFastConfirmationVariables( + fcrStore, newHead, greatestUnrealized, false, false); + + assertThat(updatedStore.previousSlotHead()).isEqualTo(currentHead); + assertThat(updatedStore.currentSlotHead()).isEqualTo(newHead); + assertThat(updatedStore.previousEpochObservedJustifiedCheckpoint()).isEqualTo(previousObserved); + assertThat(updatedStore.currentEpochObservedJustifiedCheckpoint()).isEqualTo(currentObserved); + assertThat(updatedStore.previousEpochGreatestUnrealizedCheckpoint()) + .isEqualTo(previousGreatestUnrealized); + } + + @Test + void shouldCaptureGreatestUnrealizedJustifiedCheckpointOnLastSlotOfEpoch() { + final Checkpoint previousGreatestUnrealized = checkpoint(3); + final Checkpoint greatestUnrealized = checkpoint(4); + final FastConfirmationStore fcrStore = + fastConfirmationStore( + checkpoint(1), + checkpoint(2), + previousGreatestUnrealized, + Bytes32.random(), + Bytes32.random()); + + final FastConfirmationStore updatedStore = + FastConfirmationRuleUtil.updateFastConfirmationVariables( + fcrStore, Bytes32.random(), greatestUnrealized, false, true); + + assertThat(updatedStore.previousEpochGreatestUnrealizedCheckpoint()) + .isEqualTo(greatestUnrealized); + } + + @Test + void shouldRotateObservedJustifiedCheckpointsOnStartSlotOfEpoch() { + final Checkpoint previousObserved = checkpoint(1); + final Checkpoint currentObserved = checkpoint(2); + final Checkpoint previousGreatestUnrealized = checkpoint(3); + final FastConfirmationStore fcrStore = + fastConfirmationStore( + previousObserved, + currentObserved, + previousGreatestUnrealized, + Bytes32.random(), + Bytes32.random()); + + final FastConfirmationStore updatedStore = + FastConfirmationRuleUtil.updateFastConfirmationVariables( + fcrStore, Bytes32.random(), checkpoint(4), true, false); + + assertThat(updatedStore.previousEpochObservedJustifiedCheckpoint()).isEqualTo(currentObserved); + assertThat(updatedStore.currentEpochObservedJustifiedCheckpoint()) + .isEqualTo(previousGreatestUnrealized); + } + + @Test + void shouldDetectEpochStartSlots() { + final UInt64 epochStart = spec.computeStartSlotAtEpoch(UInt64.ONE); + + assertThat(FastConfirmationRuleUtil.isStartSlotAtEpoch(spec, epochStart)).isTrue(); + assertThat(FastConfirmationRuleUtil.isStartSlotAtEpoch(spec, epochStart.plus(1))).isFalse(); + } + + @Test + void shouldComputeSlotsSinceEpochStart() { + // Minimal preset: SLOTS_PER_EPOCH == 8 + assertThat(FastConfirmationRuleUtil.computeSlotsSinceEpochStart(spec, UInt64.valueOf(8))) + .isEqualTo(UInt64.ZERO); + assertThat(FastConfirmationRuleUtil.computeSlotsSinceEpochStart(spec, UInt64.valueOf(10))) + .isEqualTo(UInt64.valueOf(2)); + assertThat(FastConfirmationRuleUtil.computeSlotsSinceEpochStart(spec, UInt64.valueOf(7))) + .isEqualTo(UInt64.valueOf(7)); + } + + @Test + void shouldDetectWhenFullValidatorSetIsCovered() { + // Slots 0..7 span the whole of epoch 0 + assertThat(FastConfirmationRuleUtil.isFullValidatorSetCovered(spec, UInt64.ZERO, uint(7))) + .isTrue(); + // Slots 0..6 do not cover any full epoch + assertThat(FastConfirmationRuleUtil.isFullValidatorSetCovered(spec, UInt64.ZERO, uint(6))) + .isFalse(); + // Slots 1..8 span the boundary but cover no full epoch + assertThat(FastConfirmationRuleUtil.isFullValidatorSetCovered(spec, UInt64.ONE, uint(8))) + .isFalse(); + // Slots 1..15 span the whole of epoch 1 + assertThat(FastConfirmationRuleUtil.isFullValidatorSetCovered(spec, UInt64.ONE, uint(15))) + .isTrue(); + } + + @Test + void shouldAdjustCommitteeWeightEstimateToEnsureSafety() { + // ceil(1000 / 1000) == 1 -> 1 * (1000 + 5) + assertThat(FastConfirmationRuleUtil.adjustCommitteeWeightEstimateToEnsureSafety(uint(1000))) + .isEqualTo(UInt64.valueOf(1005)); + // ceil(1001 / 1000) == 2 -> 2 * (1000 + 5) + assertThat(FastConfirmationRuleUtil.adjustCommitteeWeightEstimateToEnsureSafety(uint(1001))) + .isEqualTo(UInt64.valueOf(2010)); + assertThat(FastConfirmationRuleUtil.adjustCommitteeWeightEstimateToEnsureSafety(UInt64.ZERO)) + .isEqualTo(UInt64.ZERO); + } + + @Test + void shouldReturnZeroCommitteeWeightWhenStartAfterEnd() { + assertThat( + FastConfirmationRuleUtil.estimateCommitteeWeightBetweenSlots( + spec, uint(8000), uint(5), uint(4))) + .isEqualTo(UInt64.ZERO); + } + + @Test + void shouldReturnTotalActiveBalanceWhenFullEpochCovered() { + final UInt64 totalActiveBalance = uint(8000); + assertThat( + FastConfirmationRuleUtil.estimateCommitteeWeightBetweenSlots( + spec, totalActiveBalance, UInt64.ZERO, uint(7))) + .isEqualTo(totalActiveBalance); + } + + @Test + void shouldEstimateCommitteeWeightWithinSingleEpoch() { + // committeeWeight = 8000 / 8 = 1000; slots 2..4 -> 3 committees -> 3000 + assertThat( + FastConfirmationRuleUtil.estimateCommitteeWeightBetweenSlots( + spec, uint(8000), uint(2), uint(4))) + .isEqualTo(UInt64.valueOf(3000)); + } + + @Test + void shouldEstimateCommitteeWeightAcrossEpochBoundaryWithoutFullEpoch() { + // total = 8000, committeeWeight = 1000; range slots 6..9 (epoch 0: 6,7; epoch 1: 8,9). + // numSlotsInEndEpoch = 2, remainingSlotsInEndEpoch = 6, numSlotsInStartEpoch = 2. + // startEpochWeight = 2000, endEpochWeight = 2000, proRated = (2000 / 8) * 6 = 1500. + // sum = 3500 -> adjust: ceil(3500 / 1000) = 4 -> 4 * 1005 = 4020. + assertThat( + FastConfirmationRuleUtil.estimateCommitteeWeightBetweenSlots( + spec, uint(8000), uint(6), uint(9))) + .isEqualTo(UInt64.valueOf(4020)); + } + + @Test + void shouldRaiseGreatestUnrealizedJustifiedCheckpointAboveTheJustifiedFloor() { + final ReadOnlyForkChoiceStrategy forkChoice = mock(ReadOnlyForkChoiceStrategy.class); + when(store.getForkChoiceStrategy()).thenReturn(forkChoice); + when(store.getJustifiedCheckpoint()).thenReturn(checkpoint(0)); + final Checkpoint greatest = checkpoint(5); + // Materialize the per-block mocks before stubbing getBlockData to avoid nested stubbing. + final ProtoNodeData lower = blockDataWithUnrealizedJustified(checkpoint(3)); + final ProtoNodeData highest = blockDataWithUnrealizedJustified(greatest); + final ProtoNodeData middle = blockDataWithUnrealizedJustified(checkpoint(4)); + when(forkChoice.getBlockData()).thenReturn(List.of(lower, highest, middle)); + + assertThat(FastConfirmationRuleUtil.getGreatestUnrealizedJustifiedCheckpoint(store)) + .isEqualTo(greatest); + } + + @Test + void shouldReturnJustifiedCheckpointWhenNoBlockHasHigherUnrealizedJustification() { + final ReadOnlyForkChoiceStrategy forkChoice = mock(ReadOnlyForkChoiceStrategy.class); + when(store.getForkChoiceStrategy()).thenReturn(forkChoice); + final Checkpoint justifiedCheckpoint = checkpoint(9); + when(store.getJustifiedCheckpoint()).thenReturn(justifiedCheckpoint); + // Blocks report a lower-epoch (zero) unrealized justified checkpoint, so the floor wins. + final ProtoNodeData block = + blockDataWithUnrealizedJustified(new Checkpoint(UInt64.ZERO, Bytes32.ZERO)); + when(forkChoice.getBlockData()).thenReturn(List.of(block)); + + assertThat(FastConfirmationRuleUtil.getGreatestUnrealizedJustifiedCheckpoint(store)) + .isEqualTo(justifiedCheckpoint); + } + + private ProtoNodeData blockDataWithUnrealizedJustified(final Checkpoint unrealizedJustified) { + final ProtoNodeData data = mock(ProtoNodeData.class); + final Checkpoint zero = new Checkpoint(UInt64.ZERO, Bytes32.ZERO); + when(data.getCheckpoints()) + .thenReturn(new BlockCheckpoints(zero, zero, unrealizedJustified, zero)); + return data; + } + + private FastConfirmationStore fastConfirmationStore( + final Checkpoint previousObserved, + final Checkpoint currentObserved, + final Checkpoint previousGreatestUnrealized, + final Bytes32 previousHead, + final Bytes32 currentHead) { + return new FastConfirmationStore( + store, + Bytes32.random(), + previousObserved, + currentObserved, + previousGreatestUnrealized, + previousHead, + currentHead); + } + + private Checkpoint checkpoint(final int epoch) { + return new Checkpoint(UInt64.valueOf(epoch), Bytes32.random()); + } + + private UInt64 uint(final long value) { + return UInt64.valueOf(value); + } +} diff --git a/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationStateLoaderTest.java b/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationStateLoaderTest.java new file mode 100644 index 00000000000..f270f49de6e --- /dev/null +++ b/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationStateLoaderTest.java @@ -0,0 +1,121 @@ +/* + * 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.statetransition.forkchoice.fastconfirmation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static tech.pegasys.teku.infrastructure.async.SafeFutureAssert.assertThatSafeFuture; + +import java.util.Optional; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.Test; +import tech.pegasys.teku.infrastructure.async.SafeFuture; +import tech.pegasys.teku.infrastructure.unsigned.UInt64; +import tech.pegasys.teku.spec.datastructures.forkchoice.FastConfirmationStore; +import tech.pegasys.teku.spec.datastructures.forkchoice.ReadOnlyStore; +import tech.pegasys.teku.spec.datastructures.state.Checkpoint; +import tech.pegasys.teku.spec.datastructures.state.beaconstate.BeaconState; + +class FastConfirmationStateLoaderTest { + + private final ReadOnlyStore store = mock(ReadOnlyStore.class); + private final Checkpoint previousObserved = new Checkpoint(UInt64.valueOf(1), Bytes32.random()); + private final Checkpoint currentObserved = new Checkpoint(UInt64.valueOf(2), Bytes32.random()); + private final Bytes32 head = Bytes32.random(); + + private final BeaconState previousState = mock(BeaconState.class); + private final BeaconState currentState = mock(BeaconState.class); + private final BeaconState headState = mock(BeaconState.class); + + private final FastConfirmationStore fcrStore = + new FastConfirmationStore( + store, + Bytes32.random(), + previousObserved, + currentObserved, + previousObserved, + Bytes32.random(), + head); + + @Test + void shouldLoadAllSourceStatesAtEpochStart() { + stubCheckpointState(previousObserved, Optional.of(previousState)); + stubCheckpointState(currentObserved, Optional.of(currentState)); + stubHeadState(Optional.of(headState)); + + final FastConfirmationStates states = load(true).join().orElseThrow(); + + assertThat(states.previousBalanceSource()).contains(previousState); + assertThat(states.currentBalanceSource()).isSameAs(currentState); + assertThat(states.headBlockState()).isSameAs(headState); + } + + @Test + void shouldSkipPreviousBalanceSourceWhenNotAtEpochStart() { + stubCheckpointState(currentObserved, Optional.of(currentState)); + stubHeadState(Optional.of(headState)); + + final FastConfirmationStates states = load(false).join().orElseThrow(); + + assertThat(states.previousBalanceSource()).isEmpty(); + assertThat(states.currentBalanceSource()).isSameAs(currentState); + assertThat(states.headBlockState()).isSameAs(headState); + // The previous epoch's checkpoint state must not be fetched off epoch-start slots. + verify(store, never()).retrieveCheckpointState(previousObserved); + } + + @Test + void shouldReturnEmptyWhenHeadStateUnavailable() { + stubCheckpointState(currentObserved, Optional.of(currentState)); + stubHeadState(Optional.empty()); + + assertThat(load(false).join()).isEmpty(); + } + + @Test + void shouldReturnEmptyWhenCurrentBalanceSourceUnavailable() { + stubCheckpointState(currentObserved, Optional.empty()); + stubHeadState(Optional.of(headState)); + + assertThat(load(false).join()).isEmpty(); + } + + @Test + void shouldReturnEmptyWhenPreviousBalanceSourceUnavailableAtEpochStart() { + stubCheckpointState(previousObserved, Optional.empty()); + stubCheckpointState(currentObserved, Optional.of(currentState)); + stubHeadState(Optional.of(headState)); + + assertThat(load(true).join()).isEmpty(); + } + + private SafeFuture> load( + final boolean includePreviousBalanceSource) { + final SafeFuture> result = + FastConfirmationStateLoader.load(fcrStore, head, includePreviousBalanceSource); + assertThatSafeFuture(result).isCompleted(); + return result; + } + + private void stubCheckpointState(final Checkpoint checkpoint, final Optional state) { + when(store.retrieveCheckpointState(checkpoint)).thenReturn(SafeFuture.completedFuture(state)); + } + + private void stubHeadState(final Optional state) { + when(store.retrieveBlockState(head)).thenReturn(SafeFuture.completedFuture(state)); + } +} diff --git a/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationTrackerTest.java b/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationTrackerTest.java new file mode 100644 index 00000000000..fa3811ad988 --- /dev/null +++ b/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationTrackerTest.java @@ -0,0 +1,209 @@ +/* + * 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.statetransition.forkchoice.fastconfirmation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static tech.pegasys.teku.infrastructure.async.SafeFutureAssert.assertThatSafeFuture; + +import java.util.Optional; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import tech.pegasys.teku.infrastructure.async.SafeFuture; +import tech.pegasys.teku.infrastructure.async.StubAsyncRunner; +import tech.pegasys.teku.infrastructure.metrics.StubMetricsSystem; +import tech.pegasys.teku.infrastructure.metrics.TekuMetricCategory; +import tech.pegasys.teku.infrastructure.time.StubTimeProvider; +import tech.pegasys.teku.infrastructure.unsigned.UInt64; +import tech.pegasys.teku.spec.Spec; +import tech.pegasys.teku.spec.TestSpecFactory; +import tech.pegasys.teku.spec.datastructures.forkchoice.FastConfirmationStore; +import tech.pegasys.teku.spec.datastructures.forkchoice.ReadOnlyForkChoiceStrategy; +import tech.pegasys.teku.spec.datastructures.forkchoice.ReadOnlyStore; +import tech.pegasys.teku.spec.datastructures.state.Checkpoint; + +class FastConfirmationTrackerTest { + + private final Spec spec = TestSpecFactory.createMinimalPhase0(); + private final ReadOnlyStore store = mock(ReadOnlyStore.class); + private final ReadOnlyForkChoiceStrategy forkChoice = mock(ReadOnlyForkChoiceStrategy.class); + private final FastConfirmationEventChannel eventChannel = + mock(FastConfirmationEventChannel.class); + private final StubMetricsSystem metricsSystem = new StubMetricsSystem(); + private final StubTimeProvider timeProvider = StubTimeProvider.withTimeInMillis(0); + private final Checkpoint finalizedCheckpoint = + new Checkpoint(UInt64.valueOf(12), Bytes32.random()); + + @BeforeEach + void setUp() { + // No source states available: get_latest_confirmed is skipped and the confirmed root is left + // unchanged, so these tests exercise only update_fast_confirmation_variables and the guard. + when(store.retrieveCheckpointState(any())) + .thenReturn(SafeFuture.completedFuture(Optional.empty())); + when(store.retrieveBlockState(any(Bytes32.class))) + .thenReturn(SafeFuture.completedFuture(Optional.empty())); + // Used by the fast_confirmation event to resolve the confirmed block's slot. + when(store.getForkChoiceStrategy()).thenReturn(forkChoice); + when(forkChoice.blockSlot(any())).thenReturn(Optional.of(finalizedCheckpoint.getEpoch())); + } + + @Test + void shouldNotInitializeStoreWhenDisabled() { + final FastConfirmationTracker tracker = FastConfirmationTracker.NOOP; + + tracker.initialize(store); + + assertThat(tracker.isEnabled()).isFalse(); + assertThat(tracker.getFastConfirmationStore()).isEmpty(); + } + + @Test + void shouldInitializeStoreFromFinalizedCheckpointWhenEnabled() { + when(store.getFinalizedCheckpoint()).thenReturn(finalizedCheckpoint); + final FastConfirmationTracker tracker = + FastConfirmationTracker.create( + spec, Optional.empty(), eventChannel, metricsSystem, timeProvider); + + tracker.initialize(store); + + final FastConfirmationStore fastConfirmationStore = + tracker.getFastConfirmationStore().orElseThrow(); + assertThat(tracker.isEnabled()).isTrue(); + assertThat(fastConfirmationStore.store()).isSameAs(store); + assertThat(fastConfirmationStore.confirmedRoot()).isEqualTo(finalizedCheckpoint.getRoot()); + assertThat(fastConfirmationStore.previousEpochObservedJustifiedCheckpoint()) + .isEqualTo(finalizedCheckpoint); + assertThat(fastConfirmationStore.currentEpochObservedJustifiedCheckpoint()) + .isEqualTo(finalizedCheckpoint); + assertThat(fastConfirmationStore.previousEpochGreatestUnrealizedCheckpoint()) + .isEqualTo(finalizedCheckpoint); + assertThat(fastConfirmationStore.previousSlotHead()).isEqualTo(finalizedCheckpoint.getRoot()); + assertThat(fastConfirmationStore.currentSlotHead()).isEqualTo(finalizedCheckpoint.getRoot()); + } + + @Test + void shouldNotScheduleUpdateWhenDisabled() { + final StubAsyncRunner asyncRunner = new StubAsyncRunner(); + final FastConfirmationTracker tracker = FastConfirmationTracker.NOOP; + + final SafeFuture result = tracker.onSlot(UInt64.valueOf(13), Bytes32.random()); + + assertThatSafeFuture(result).isCompleted(); + assertThat(asyncRunner.countDelayedActions()).isZero(); + } + + @Test + void shouldScheduleUpdateOnAsyncRunnerWhenEnabledAndInitialized() { + final StubAsyncRunner asyncRunner = new StubAsyncRunner(); + when(store.getFinalizedCheckpoint()).thenReturn(finalizedCheckpoint); + final FastConfirmationTracker tracker = + FastConfirmationTracker.create( + spec, Optional.of(asyncRunner), eventChannel, metricsSystem, timeProvider); + tracker.initialize(store); + final Bytes32 headRoot = Bytes32.random(); + + // Slot 13 is a mid-epoch slot (minimal SLOTS_PER_EPOCH == 8), so only the slot heads rotate. + final SafeFuture result = tracker.onSlot(UInt64.valueOf(13), headRoot); + + assertThat(result).isNotDone(); + assertThat(asyncRunner.countDelayedActions()).isOne(); + + asyncRunner.executeQueuedActions(); + + assertThatSafeFuture(result).isCompleted(); + final FastConfirmationStore fastConfirmationStore = + tracker.getFastConfirmationStore().orElseThrow(); + assertThat(fastConfirmationStore.previousSlotHead()).isEqualTo(finalizedCheckpoint.getRoot()); + assertThat(fastConfirmationStore.currentSlotHead()).isEqualTo(headRoot); + } + + @Test + void shouldEmitFastConfirmationEventWhenAlgorithmRuns() { + final StubAsyncRunner asyncRunner = new StubAsyncRunner(); + when(store.getFinalizedCheckpoint()).thenReturn(finalizedCheckpoint); + final FastConfirmationTracker tracker = + FastConfirmationTracker.create( + spec, Optional.of(asyncRunner), eventChannel, metricsSystem, timeProvider); + tracker.initialize(store); + + applyUpdate(tracker, asyncRunner, UInt64.valueOf(13), Bytes32.random()); + + // Emitted with the confirmed root (still the finalized root, as no source states are loaded), + // the confirmed block's slot, and the wall-clock slot the algorithm ran at. + verify(eventChannel) + .onFastConfirmation( + finalizedCheckpoint.getRoot(), finalizedCheckpoint.getEpoch(), UInt64.valueOf(13)); + // The timeout counter is registered and unaffected by a normal (non-timed-out) run. + assertThat( + metricsSystem.getCounterValue( + TekuMetricCategory.BEACON, "fast_confirmation_timeout_total")) + .isZero(); + } + + @Test + void shouldSkipStaleOrDuplicateSlotUpdates() { + final StubAsyncRunner asyncRunner = new StubAsyncRunner(); + when(store.getFinalizedCheckpoint()).thenReturn(finalizedCheckpoint); + final FastConfirmationTracker tracker = + FastConfirmationTracker.create( + spec, Optional.of(asyncRunner), eventChannel, metricsSystem, timeProvider); + tracker.initialize(store); + + final Bytes32 headA = Bytes32.random(); + final Bytes32 headB = Bytes32.random(); + final Bytes32 headC = Bytes32.random(); + + // Slot 13: rotates current -> previous, sets current = headA + applyUpdate(tracker, asyncRunner, UInt64.valueOf(13), headA); + assertThat(currentSlotHead(tracker)).isEqualTo(headA); + assertThat(previousSlotHead(tracker)).isEqualTo(finalizedCheckpoint.getRoot()); + + // Slot 14: rotates again, previous = headA, current = headB + applyUpdate(tracker, asyncRunner, UInt64.valueOf(14), headB); + assertThat(currentSlotHead(tracker)).isEqualTo(headB); + assertThat(previousSlotHead(tracker)).isEqualTo(headA); + + // Duplicate slot 14 must be ignored (no second rotation) + applyUpdate(tracker, asyncRunner, UInt64.valueOf(14), headC); + assertThat(currentSlotHead(tracker)).isEqualTo(headB); + assertThat(previousSlotHead(tracker)).isEqualTo(headA); + + // Older slot 13 must be ignored as well + applyUpdate(tracker, asyncRunner, UInt64.valueOf(13), headC); + assertThat(currentSlotHead(tracker)).isEqualTo(headB); + assertThat(previousSlotHead(tracker)).isEqualTo(headA); + } + + private void applyUpdate( + final FastConfirmationTracker tracker, + final StubAsyncRunner asyncRunner, + final UInt64 slot, + final Bytes32 headRoot) { + final SafeFuture result = tracker.onSlot(slot, headRoot); + asyncRunner.executeQueuedActions(); + assertThatSafeFuture(result).isCompleted(); + } + + private Bytes32 currentSlotHead(final FastConfirmationTracker tracker) { + return tracker.getFastConfirmationStore().orElseThrow().currentSlotHead(); + } + + private Bytes32 previousSlotHead(final FastConfirmationTracker tracker) { + return tracker.getFastConfirmationStore().orElseThrow().previousSlotHead(); + } +} diff --git a/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/validation/BlockGossipValidatorTest.java b/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/validation/BlockGossipValidatorTest.java index fdd070c7a8a..33d70c22968 100644 --- a/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/validation/BlockGossipValidatorTest.java +++ b/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/validation/BlockGossipValidatorTest.java @@ -54,6 +54,7 @@ import tech.pegasys.teku.statetransition.forkchoice.MergeTransitionBlockValidator; import tech.pegasys.teku.statetransition.forkchoice.NoopForkChoiceNotifier; import tech.pegasys.teku.statetransition.forkchoice.TickProcessor; +import tech.pegasys.teku.statetransition.forkchoice.fastconfirmation.FastConfirmationTracker; import tech.pegasys.teku.statetransition.util.DebugDataDumper; import tech.pegasys.teku.statetransition.validation.BlockGossipValidator.EquivocationCheckResult; import tech.pegasys.teku.storage.api.LateBlockReorgPreparationHandler; @@ -109,6 +110,7 @@ private void seedGenesisExecutionPayloadForGloas() { new ForkChoiceStateProvider(eventThread, recentChainData), new TickProcessor(spec, recentChainData), mock(MergeTransitionBlockValidator.class), + FastConfirmationTracker.NOOP, DEFAULT_FORK_CHOICE_LATE_BLOCK_REORG_ENABLED, LateBlockReorgPreparationHandler.NOOP, mock(DebugDataDumper.class), diff --git a/services/beaconchain/src/main/java/tech/pegasys/teku/services/beaconchain/BeaconChainController.java b/services/beaconchain/src/main/java/tech/pegasys/teku/services/beaconchain/BeaconChainController.java index d8cdde021c7..49935dfc3f4 100644 --- a/services/beaconchain/src/main/java/tech/pegasys/teku/services/beaconchain/BeaconChainController.java +++ b/services/beaconchain/src/main/java/tech/pegasys/teku/services/beaconchain/BeaconChainController.java @@ -228,6 +228,8 @@ import tech.pegasys.teku.statetransition.forkchoice.TerminalPowBlockMonitor; import tech.pegasys.teku.statetransition.forkchoice.TickProcessingPerformance; import tech.pegasys.teku.statetransition.forkchoice.TickProcessor; +import tech.pegasys.teku.statetransition.forkchoice.fastconfirmation.FastConfirmationEventChannel; +import tech.pegasys.teku.statetransition.forkchoice.fastconfirmation.FastConfirmationTracker; import tech.pegasys.teku.statetransition.genesis.GenesisHandler; import tech.pegasys.teku.statetransition.payloadattestation.AggregatingPayloadAttestationPool; import tech.pegasys.teku.statetransition.payloadattestation.PayloadAttestationMessageGossipValidator; @@ -351,6 +353,7 @@ public class BeaconChainController extends Service implements BeaconChainControl private final AsyncRunner operationPoolAsyncRunner; private final AsyncRunner dasAsyncRunner; + private final FastConfirmationTracker fastConfirmationTracker; protected final AtomicReference dataColumnSidecarCustodyRef = new AtomicReference<>(DataColumnSidecarRecoveringCustody.NOOP); @@ -466,6 +469,19 @@ public BeaconChainController( // larger default size. das runner should be separate to the operation pool runner as it's a // bunch of tasks, not just operation pool activities this.dasAsyncRunner = serviceConfig.createAsyncRunner("das", 4, 20_000); + if (eth2NetworkConfig.isFastConfirmationEnabled()) { + final AsyncRunner fastConfirmationAsyncRunner = + serviceConfig.createAsyncRunner("fastconfirmation", 1); + this.fastConfirmationTracker = + FastConfirmationTracker.create( + spec, + Optional.of(fastConfirmationAsyncRunner), + serviceConfig.getEventChannels().getPublisher(FastConfirmationEventChannel.class), + serviceConfig.getMetricsSystem(), + serviceConfig.getTimeProvider()); + } else { + this.fastConfirmationTracker = FastConfirmationTracker.NOOP; + } this.timeProvider = serviceConfig.getTimeProvider(); this.eventChannels = serviceConfig.getEventChannels(); this.metricsSystem = serviceConfig.getMetricsSystem(); @@ -1643,6 +1659,7 @@ protected void initForkChoice() { forkChoiceStateProvider, new TickProcessor(spec, recentChainData), new MergeTransitionBlockValidator(spec, recentChainData), + fastConfirmationTracker, beaconConfig.eth2NetworkConfig().isForkChoiceLateBlockReorgEnabled(), (slot, blockRoot) -> beaconAsyncRunner.runAsync( diff --git a/specrefs/.ethspecify.yml b/specrefs/.ethspecify.yml index 720633accf6..eaad3907f76 100644 --- a/specrefs/.ethspecify.yml +++ b/specrefs/.ethspecify.yml @@ -85,9 +85,6 @@ specrefs: # No light client support - LightClientStore - # Not implemented: phase0 - - FastConfirmationStore#phase0 - # Not defined, implemented differently - Seen#phase0 - Seen#altair @@ -233,7 +230,6 @@ specrefs: - get_current_target#phase0 - get_current_target_score#phase0 - get_equivocation_score#phase0 - - get_fast_confirmation_store#phase0 - get_latest_confirmed#phase0 - get_latest_message_epoch#phase0 - get_previous_balance_source#phase0 @@ -245,7 +241,6 @@ specrefs: - is_one_confirmed#phase0 - is_start_slot_at_epoch#phase0 - on_fast_confirmation#phase0 - - update_fast_confirmation_variables#phase0 - will_current_target_be_justified#phase0 - will_no_conflicting_checkpoint_be_justified#phase0 - get_node_for_root#phase0 diff --git a/specrefs/dataclasses.yml b/specrefs/dataclasses.yml index 8b89f098190..97578f9baff 100644 --- a/specrefs/dataclasses.yml +++ b/specrefs/dataclasses.yml @@ -78,7 +78,9 @@ - name: FastConfirmationStore#phase0 - sources: [] + sources: + - file: ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/forkchoice/FastConfirmationStore.java + search: public record FastConfirmationStore( spec: | class FastConfirmationStore: diff --git a/specrefs/functions.yml b/specrefs/functions.yml index 287804f9795..5151732cf2f 100644 --- a/specrefs/functions.yml +++ b/specrefs/functions.yml @@ -4869,7 +4869,9 @@ - name: get_fast_confirmation_store#phase0 - sources: [] + sources: + - file: ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/forkchoice/FastConfirmationStore.java + search: public static FastConfirmationStore create(final ReadOnlyStore store) { spec: | def get_fast_confirmation_store(store: Store) -> FastConfirmationStore: @@ -12754,7 +12756,9 @@ - name: update_fast_confirmation_variables#phase0 - sources: [] + sources: + - file: ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationRuleUtil.java + search: static FastConfirmationStore updateFastConfirmationVariables( spec: | def update_fast_confirmation_variables(fcr_store: FastConfirmationStore) -> None: diff --git a/storage/src/main/java/tech/pegasys/teku/storage/store/Store.java b/storage/src/main/java/tech/pegasys/teku/storage/store/Store.java index 1649e62e11b..37c76a3b863 100644 --- a/storage/src/main/java/tech/pegasys/teku/storage/store/Store.java +++ b/storage/src/main/java/tech/pegasys/teku/storage/store/Store.java @@ -66,6 +66,7 @@ import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.SignedExecutionPayloadEnvelope; import tech.pegasys.teku.spec.datastructures.execution.SlotAndExecutionPayloadSummary; import tech.pegasys.teku.spec.datastructures.forkchoice.ProtoNodeData; +import tech.pegasys.teku.spec.datastructures.forkchoice.VoteSnapshot; import tech.pegasys.teku.spec.datastructures.forkchoice.VoteTracker; import tech.pegasys.teku.spec.datastructures.forkchoice.VoteUpdater; import tech.pegasys.teku.spec.datastructures.hashtree.HashTree; @@ -971,6 +972,16 @@ public VoteTracker getVote(final UInt64 validatorIndex) { } } + @Override + public VoteSnapshot getVoteSnapshot() { + readVotesLock.lock(); + try { + return VoteSnapshot.create(highestVotedValidatorIndex, votes); + } finally { + readVotesLock.unlock(); + } + } + private SafeFuture> getAndCacheBlockAndState( final Bytes32 blockRoot) { return getOrRegenerateBlockAndState(blockRoot) diff --git a/storage/src/main/java/tech/pegasys/teku/storage/store/StoreTransaction.java b/storage/src/main/java/tech/pegasys/teku/storage/store/StoreTransaction.java index a63337afa12..ba795b437f2 100644 --- a/storage/src/main/java/tech/pegasys/teku/storage/store/StoreTransaction.java +++ b/storage/src/main/java/tech/pegasys/teku/storage/store/StoreTransaction.java @@ -45,6 +45,7 @@ import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.SignedExecutionPayloadEnvelope; import tech.pegasys.teku.spec.datastructures.execution.SlotAndExecutionPayloadSummary; import tech.pegasys.teku.spec.datastructures.forkchoice.ReadOnlyForkChoiceStrategy; +import tech.pegasys.teku.spec.datastructures.forkchoice.VoteSnapshot; import tech.pegasys.teku.spec.datastructures.forkchoice.VoteTracker; import tech.pegasys.teku.spec.datastructures.state.AnchorPoint; import tech.pegasys.teku.spec.datastructures.state.Checkpoint; @@ -296,6 +297,11 @@ public VoteTracker getVote(final UInt64 validatorIndex) { return store.getVote(validatorIndex); } + @Override + public VoteSnapshot getVoteSnapshot() { + return store.getVoteSnapshot(); + } + @Override public AnchorPoint getLatestFinalized() { if (finalizedCheckpoint.isPresent()) { diff --git a/storage/src/test/java/tech/pegasys/teku/storage/store/StoreVoteSnapshotTest.java b/storage/src/test/java/tech/pegasys/teku/storage/store/StoreVoteSnapshotTest.java new file mode 100644 index 00000000000..3cb6a1798b6 --- /dev/null +++ b/storage/src/test/java/tech/pegasys/teku/storage/store/StoreVoteSnapshotTest.java @@ -0,0 +1,77 @@ +/* + * 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.storage.store; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.Test; +import tech.pegasys.teku.infrastructure.unsigned.UInt64; +import tech.pegasys.teku.spec.TestSpecFactory; +import tech.pegasys.teku.spec.datastructures.forkchoice.VoteSnapshot; +import tech.pegasys.teku.spec.datastructures.forkchoice.VoteTracker; +import tech.pegasys.teku.spec.datastructures.forkchoice.VoteUpdater; +import tech.pegasys.teku.spec.util.DataStructureUtil; +import tech.pegasys.teku.storage.api.VoteUpdateChannel; + +class StoreVoteSnapshotTest extends AbstractStoreTest { + private final DataStructureUtil dataStructureUtil = + new DataStructureUtil(TestSpecFactory.createDefault()); + private final VoteUpdateChannel voteUpdateChannel = mock(VoteUpdateChannel.class); + private final UpdatableStore store = createGenesisStore(); + + @Test + void shouldSnapshotVotesUpToHighestVotedValidatorIndex() { + final VoteTracker vote0 = dataStructureUtil.randomVoteTracker(); + final VoteTracker vote2 = dataStructureUtil.randomVoteTracker(); + setVote(UInt64.ZERO, vote0); + setVote(UInt64.valueOf(2), vote2); + + final VoteSnapshot snapshot = store.getVoteSnapshot(); + + assertThat(snapshot.getHighestVotedValidatorIndex()).isEqualTo(UInt64.valueOf(2)); + assertThat(snapshot.size()).isEqualTo(3); + assertThat(snapshot.getVote(UInt64.ZERO)).isEqualTo(vote0); + assertThat(snapshot.getVote(UInt64.ONE)).isEqualTo(VoteTracker.DEFAULT); + assertThat(snapshot.getVote(UInt64.valueOf(2))).isEqualTo(vote2); + assertThat(snapshot.getVote(UInt64.valueOf(3))).isEqualTo(VoteTracker.DEFAULT); + } + + @Test + void shouldNotReflectVotesCommittedAfterSnapshotIsCreated() { + final VoteTracker originalVote = dataStructureUtil.randomVoteTracker(); + final VoteTracker updatedVote = dataStructureUtil.randomVoteTracker(); + setVote(UInt64.ZERO, originalVote); + + final VoteSnapshot snapshot = store.getVoteSnapshot(); + setVote(UInt64.ZERO, updatedVote); + + assertThat(snapshot.getVote(UInt64.ZERO)).isEqualTo(originalVote); + assertThat(store.getVoteSnapshot().getVote(UInt64.ZERO)).isEqualTo(updatedVote); + } + + @Test + void shouldRejectNegativeValidatorIndex() { + final VoteSnapshot snapshot = store.getVoteSnapshot(); + + assertThatIllegalArgumentException().isThrownBy(() -> snapshot.getVote(-1)); + } + + private void setVote(final UInt64 validatorIndex, final VoteTracker vote) { + final VoteUpdater voteUpdater = store.startVoteUpdate(voteUpdateChannel); + voteUpdater.putVote(validatorIndex, vote); + voteUpdater.commit(); + } +} diff --git a/teku/src/main/java/tech/pegasys/teku/cli/options/Eth2NetworkOptions.java b/teku/src/main/java/tech/pegasys/teku/cli/options/Eth2NetworkOptions.java index b0e085f43f4..1336c67f1f5 100644 --- a/teku/src/main/java/tech/pegasys/teku/cli/options/Eth2NetworkOptions.java +++ b/teku/src/main/java/tech/pegasys/teku/cli/options/Eth2NetworkOptions.java @@ -154,6 +154,17 @@ public class Eth2NetworkOptions { private boolean forkChoiceLateBlockReorgEnabled = Eth2NetworkConfiguration.DEFAULT_FORK_CHOICE_LATE_BLOCK_REORG_ENABLED; + @Option( + names = {"--Xfast-confirmation-enabled"}, + paramLabel = "", + description = "Enable the experimental fast confirmation rule.", + arity = "0..1", + fallbackValue = "true", + showDefaultValue = Visibility.ALWAYS, + hidden = true) + private boolean fastConfirmationEnabled = + Eth2NetworkConfiguration.DEFAULT_FAST_CONFIRMATION_ENABLED; + @Option( names = {"--Xprepare-block-production-enabled"}, paramLabel = "", @@ -572,6 +583,7 @@ private void configureEth2Network(final Eth2NetworkConfiguration.Builder builder .asyncP2pMaxThreads(asyncP2pMaxThreads) .asyncBeaconChainMaxThreads(asyncBeaconChainMaxThreads) .forkChoiceLateBlockReorgEnabled(forkChoiceLateBlockReorgEnabled) + .fastConfirmationEnabled(fastConfirmationEnabled) .prepareBlockProductionEnabled(prepareBlockProductionEnabled) .aggregatingAttestationPoolProfilingEnabled(aggregatingAttestationPoolProfilingEnabled) .aggregatingAttestationPoolV2BlockAggregationTimeLimit( diff --git a/teku/src/test/java/tech/pegasys/teku/cli/options/Eth2NetworkOptionsTest.java b/teku/src/test/java/tech/pegasys/teku/cli/options/Eth2NetworkOptionsTest.java index 15f5f98c259..1e903952b44 100644 --- a/teku/src/test/java/tech/pegasys/teku/cli/options/Eth2NetworkOptionsTest.java +++ b/teku/src/test/java/tech/pegasys/teku/cli/options/Eth2NetworkOptionsTest.java @@ -137,6 +137,21 @@ void shouldSetLateBlockImportEnabled(final String value) { .isEqualTo(Boolean.valueOf(value)); } + @Test + void shouldDisableFastConfirmationByDefault() { + final TekuConfiguration config = getTekuConfigurationFromArguments(); + assertThat(config.eth2NetworkConfiguration().isFastConfirmationEnabled()).isFalse(); + } + + @ParameterizedTest + @ValueSource(strings = {"true", "false"}) + void shouldSetFastConfirmationEnabled(final String value) { + final TekuConfiguration config = + getTekuConfigurationFromArguments("--Xfast-confirmation-enabled", value); + assertThat(config.eth2NetworkConfiguration().isFastConfirmationEnabled()) + .isEqualTo(Boolean.valueOf(value)); + } + @Test void shouldAggregatingAttestationPoolProfilerDisabledByDefault() { final TekuConfiguration config = getTekuConfigurationFromArguments();