Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
149a447
Include compRefTests
Matilda-Clerke Aug 18, 2026
dc1026c
Make cleanRefTests depend on cleanRefTestsForkChoiceCompliance
Matilda-Clerke Aug 20, 2026
4d89e71
Discard uncomfirmed block timeliness if block import is unsuccessful
Matilda-Clerke Aug 20, 2026
1d05142
Fix compile issue on BlockTimelinessTrackerTest
Matilda-Clerke Aug 23, 2026
583a0e1
spotless
Matilda-Clerke Aug 23, 2026
d041005
Update BlockTimelinessTrackerTest
Matilda-Clerke Aug 24, 2026
956c936
Merge branch 'master' into integrate-comp-ref-tests
Matilda-Clerke Aug 25, 2026
2aa5421
Update viable_for_head_roots_and_weights check in ForkChoiceTestExecutor
Matilda-Clerke Aug 26, 2026
91c01ae
Update applyAttestation to consider deferred attestation not valid
Matilda-Clerke Aug 26, 2026
b655cd6
spotless
Matilda-Clerke Aug 26, 2026
bd42780
Remove head roots and weights set size check, as it doesn't match pre…
Matilda-Clerke Aug 26, 2026
2024f44
Remove unnecessary check on actual head root and weight payload status
Matilda-Clerke Aug 26, 2026
9abcf2f
Correct spelling in assertion message
Matilda-Clerke Aug 26, 2026
a1128ad
Remove no longer used HeadRootAndWeight.equals
Matilda-Clerke Aug 26, 2026
0624cb0
Prevent slot and epoch validation on attestations from blocks
Matilda-Clerke Aug 31, 2026
58f87b2
Spotless
Matilda-Clerke Aug 31, 2026
6176155
Move epoch check out of gossip-only attestation validation. Add unit …
Matilda-Clerke Sep 2, 2026
45700ef
spotless
Matilda-Clerke Sep 2, 2026
54849bb
Include non-viable heads in AttestationStateSelector operations
Matilda-Clerke Sep 3, 2026
2ec1d55
spotless
Matilda-Clerke Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,7 @@ def slashingProtectionInterchangeRefTestBaseUrl = 'https://github.com/eth-client
def refTestDownloadDir = "${buildDir}/refTests/${refTestVersion}"
def blsRefTestDownloadDir = "${buildDir}/blsRefTests/${blsRefTestVersion}"
def slashingProtectionInterchangeRefTestDownloadDir = "${buildDir}/slashingProtectionInterchangeRefTests/${slashingProtectionInterchangeRefTestVersion}"
def compTestDownloadDir = "${buildDir}/compRefTests/${refTestVersion}"
def refTestExpandDir = "${project.rootDir}/eth-reference-tests/src/referenceTest/resources/consensus-spec-tests/"

def downloadFile(String url, String token, File outputFile) {
Expand Down Expand Up @@ -663,8 +664,19 @@ tasks.register('downloadSlashingProtectionInterchangeRefTests', Download) {
overwrite false
}

// comptests.tar.gz is only published alongside tagged consensus-specs releases (not the nightly
// vector-generation workflow), so it is skipped for nightly builds.
tasks.register('downloadCompTests', Download) {
onlyIf { !nightly }
src([
"${refTestBaseUrl}/${refTestVersion}/comptests.tar.gz"
])
dest "${compTestDownloadDir}/comptests.tar.gz"
overwrite false
}

tasks.register('downloadRefTests') {
dependsOn downloadEthRefTests, downloadBlsRefTests, downloadSlashingProtectionInterchangeRefTests
dependsOn downloadEthRefTests, downloadBlsRefTests, downloadSlashingProtectionInterchangeRefTests, downloadCompTests
}

tasks.register('cleanRefTestsGeneral', Delete) {
Expand Down Expand Up @@ -725,11 +737,31 @@ tasks.register('expandRefTestsSlashingProtectionInterchange', Copy) {
into "${refTestExpandDir}/tests/slashing-protection-interchange"
}

// comptests.tar.gz bundles the whole consensus-specs tests/ tree (pyspec source included, not
// just vectors), so only the fork_choice_compliance subtree is extracted here rather than the
// full ~3GB archive.
tasks.register('cleanRefTestsForkChoiceCompliance', Delete) {
delete fileTree(refTestExpandDir) {
include "tests/**/fork_choice_compliance/**"
}
}

tasks.register('expandRefTestsForkChoiceCompliance', Copy) {
dependsOn cleanRefTestsForkChoiceCompliance, downloadCompTests
onlyIf { !nightly }
from {
tarTree("${compTestDownloadDir}/comptests.tar.gz").matching {
include "tests/**/fork_choice_compliance/**"
}
}
into refTestExpandDir
}
Comment thread
cursor[bot] marked this conversation as resolved.

tasks.register('expandRefTests') {
dependsOn expandRefTestsGeneral, expandRefTestsMainnet, expandRefTestsMinimal, expandRefTestsBls, expandRefTestsSlashingProtectionInterchange
dependsOn expandRefTestsGeneral, expandRefTestsMainnet, expandRefTestsMinimal, expandRefTestsBls, expandRefTestsSlashingProtectionInterchange, expandRefTestsForkChoiceCompliance
}
tasks.register('cleanRefTests') {
dependsOn cleanRefTestsGeneral, cleanRefTestsMainnet, cleanRefTestsMinimal, cleanRefTestsBls, cleanRefTestsSlashingProtectionInterchange
dependsOn cleanRefTestsGeneral, cleanRefTestsMainnet, cleanRefTestsMinimal, cleanRefTestsBls, cleanRefTestsSlashingProtectionInterchange, cleanRefTestsForkChoiceCompliance
}

tasks.register('deploy') {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
Expand All @@ -40,6 +41,7 @@
import org.apache.tuweni.bytes.Bytes32;
import org.apache.tuweni.ssz.SSZ;
import org.assertj.core.api.Condition;
import org.junit.jupiter.api.Assertions;
import org.opentest4j.TestAbortedException;
import tech.pegasys.teku.bls.BLSSignature;
import tech.pegasys.teku.bls.BLSSignatureVerifier;
Expand Down Expand Up @@ -75,7 +77,6 @@
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;
import tech.pegasys.teku.spec.datastructures.forkchoice.ReadOnlyForkChoiceStrategy;
import tech.pegasys.teku.spec.datastructures.forkchoice.ReadOnlyStore;
import tech.pegasys.teku.spec.datastructures.forkchoice.VoteUpdater;
Expand Down Expand Up @@ -536,15 +537,12 @@ private void applyAttestation(
forkChoice.onAttestation(validatableAttestation);
assertThat(result).isCompleted();
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())
// If a current-slot attestation is valid but deferred by fork choice (stored and applied on the
// next tick), reference tests seem to expect it to be considered invalid, so we will not
// consider
// it a successful attestation
assertThat(processingResult.isSuccessful())
.withFailMessage("%s failed with processing result: %s", attestationName, processingResult)
.isEqualTo(valid);
}

Expand Down Expand Up @@ -847,27 +845,53 @@ private void applyChecks(

case "viable_for_head_roots_and_weights" -> {
final List<Map<String, Object>> viableHeadRootsAndWeightsData = get(checks, checkType);
final Map<Bytes32, UInt64> viableHeadRootsAndWeights =

final Set<HeadRootAndWeight> viableHeadRootsAndWeights =
viableHeadRootsAndWeightsData.stream()
.collect(
Collectors.toMap(
entry -> Bytes32.fromHexString((String) entry.get("root")),
entry -> UInt64.valueOf(entry.get("weight").toString())));
final Map<Bytes32, UInt64> chainHeadRootsAndWeights =
.map(
entry ->
new HeadRootAndWeight(
Bytes32.fromHexString((String) entry.get("root")),
UInt64.valueOf(entry.get("weight").toString()),
Optional.ofNullable((Integer) entry.get("payload_status"))
.map(this::convertToPayloadStatus)))
.collect(Collectors.toSet());
final Set<HeadRootAndWeight> chainHeadRootsAndWeights =
recentChainData
.getForkChoiceStrategy()
.map(ReadOnlyForkChoiceStrategy::getChainHeads)
.orElse(Collections.emptyList())
.stream()
.collect(Collectors.toMap(ProtoNodeData::getRoot, ProtoNodeData::getWeight));

assertThat(chainHeadRootsAndWeights.keySet())
.containsAll(viableHeadRootsAndWeights.keySet());

for (Bytes32 root : viableHeadRootsAndWeights.keySet()) {
UInt64 weight = viableHeadRootsAndWeights.get(root);
UInt64 actualWeight = chainHeadRootsAndWeights.get(root);
assertThat(actualWeight).describedAs("block %s's weight", root).isEqualTo(weight);
.map(
protoNodeData ->
new HeadRootAndWeight(
protoNodeData.getRoot(),
protoNodeData.getWeight(),
Optional.ofNullable(protoNodeData.getPayloadStatus())))
.collect(Collectors.toSet());

for (HeadRootAndWeight headRootAndWeight : viableHeadRootsAndWeights) {
boolean notPresent =
chainHeadRootsAndWeights.stream()
.noneMatch(
(chainHeadRootAndWeight) -> {
if (headRootAndWeight.root.equals(chainHeadRootAndWeight.root)
&& headRootAndWeight.weight.equals(chainHeadRootAndWeight.weight)) {
// an unset payload status means we don't need to check if payload
// status is correct
if (headRootAndWeight.payloadStatus.isPresent()) {
return headRootAndWeight.payloadStatus.equals(
chainHeadRootAndWeight.payloadStatus);
} else {
return true;
}
}
return false;
});
Assertions.assertFalse(
notPresent,
String.format(
"Unable to find %s in %s", headRootAndWeight, chainHeadRootsAndWeights));
}
}

Expand Down Expand Up @@ -1142,4 +1166,14 @@ public BlsSetting getBlsSetting() {
return BlsSetting.forCode(blsSetting);
}
}

private ForkChoicePayloadStatus convertToPayloadStatus(final int payloadStatus) {
return Arrays.stream(ForkChoicePayloadStatus.values())
.filter(fcps -> fcps.getValue() == payloadStatus)
.findAny()
.get();
}

private record HeadRootAndWeight(
Bytes32 root, UInt64 weight, Optional<ForkChoicePayloadStatus> payloadStatus) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* 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.attestation;

public enum AttestationSource {
GOSSIP,
BLOCK
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import tech.pegasys.teku.spec.config.SpecConfig;
import tech.pegasys.teku.spec.config.SpecConfigAltair;
import tech.pegasys.teku.spec.config.SpecConfigBellatrix;
import tech.pegasys.teku.spec.datastructures.attestation.AttestationSource;
import tech.pegasys.teku.spec.datastructures.attestation.ValidatableAttestation;
import tech.pegasys.teku.spec.datastructures.blobs.versions.deneb.BlobSidecar;
import tech.pegasys.teku.spec.datastructures.blocks.BeaconBlock;
Expand Down Expand Up @@ -536,13 +537,15 @@ private AttestationProcessingResult validateOnAttestation(
UInt64 currentEpoch = miscHelpers.computeEpochAtSlot(getCurrentSlot(store));
final ReadOnlyForkChoiceStrategy forkChoiceStrategy = store.getForkChoiceStrategy();

return validateOnAttestation(forkChoiceStrategy, currentEpoch, attestationData);
return validateOnAttestation(
forkChoiceStrategy, currentEpoch, attestationData, AttestationSource.GOSSIP);
}

public AttestationProcessingResult validateOnAttestation(
final ReadOnlyForkChoiceStrategy forkChoiceStrategy,
final UInt64 currentEpoch,
final AttestationData attestationData) {
final AttestationData attestationData,
final AttestationSource attestationSource) {
final Checkpoint target = attestationData.getTarget();

// Use GENESIS_EPOCH for previous when genesis to avoid underflow
Expand All @@ -551,9 +554,11 @@ public AttestationProcessingResult validateOnAttestation(
? currentEpoch.minus(UInt64.ONE)
: SpecConfig.GENESIS_EPOCH;

if (!target.getEpoch().equals(previousEpoch) && !target.getEpoch().equals(currentEpoch)) {
return AttestationProcessingResult.invalid(
"Attestations must be from the current or previous epoch");
if (attestationSource == AttestationSource.GOSSIP) {
if (!target.getEpoch().equals(previousEpoch) && !target.getEpoch().equals(currentEpoch)) {
return AttestationProcessingResult.invalid(
"Attestations must be from the current or previous epoch");
}
}

if (!target.getEpoch().equals(miscHelpers.computeEpochAtSlot(attestationData.getSlot()))) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
import tech.pegasys.teku.spec.SpecMilestone;
import tech.pegasys.teku.spec.SpecVersion;
import tech.pegasys.teku.spec.TestSpecFactory;
import tech.pegasys.teku.spec.datastructures.attestation.AttestationSource;
import tech.pegasys.teku.spec.datastructures.blobs.versions.deneb.BlobSidecar;
import tech.pegasys.teku.spec.datastructures.blocks.BlockCheckpoints;
import tech.pegasys.teku.spec.datastructures.blocks.SignedBeaconBlock;
Expand All @@ -52,8 +53,10 @@
import tech.pegasys.teku.spec.datastructures.forkchoice.ReadOnlyStore;
import tech.pegasys.teku.spec.datastructures.forkchoice.TestStoreFactory;
import tech.pegasys.teku.spec.datastructures.forkchoice.TestStoreImpl;
import tech.pegasys.teku.spec.datastructures.operations.AttestationData;
import tech.pegasys.teku.spec.datastructures.state.Checkpoint;
import tech.pegasys.teku.spec.datastructures.state.beaconstate.BeaconState;
import tech.pegasys.teku.spec.datastructures.util.AttestationProcessingResult;
import tech.pegasys.teku.spec.logic.common.statetransition.availability.AvailabilityChecker;
import tech.pegasys.teku.spec.logic.common.statetransition.availability.AvailabilityCheckerFactory;
import tech.pegasys.teku.spec.logic.common.statetransition.exceptions.EpochProcessingException;
Expand Down Expand Up @@ -624,6 +627,69 @@ void shouldOverrideFcuCheckProposerPreState_shouldReturnFalseWhenValidatorDiscon
.isFalse();
}

@Test
void
validateOnAttestation_blockSource_shouldRejectAttestationWhenTargetEpochDoesNotMatchSlotEpoch() {
// Regression test: previously the "target epoch must equal compute_epoch_at_slot(slot)"
// check was incorrectly gated behind AttestationSource.GOSSIP, so attestations embedded in
// blocks with a mismatched slot/target epoch were accepted and their votes counted toward
// fork choice. Per the spec, this structural check is unconditional.
final int slotsPerEpoch = spec.getGenesisSpecConfig().getSlotsPerEpoch();
final UInt64 currentEpoch = UInt64.valueOf(10);
final UInt64 attestationSlot = UInt64.valueOf(currentEpoch.longValue() * slotsPerEpoch);
// Target epoch is the previous epoch, so it would pass the current/previous-epoch recency
// check, but it does not match compute_epoch_at_slot(attestationSlot) == currentEpoch.
final Checkpoint mismatchedTarget = dataStructureUtil.randomCheckpoint(currentEpoch.minus(1));
final AttestationData attestationData =
new AttestationData(
attestationSlot,
UInt64.ZERO,
dataStructureUtil.randomBytes32(),
dataStructureUtil.randomCheckpoint(currentEpoch.minus(2)),
mismatchedTarget);
final ReadOnlyForkChoiceStrategy strategy = mock(ReadOnlyForkChoiceStrategy.class);

final AttestationProcessingResult result =
forkChoiceUtil.validateOnAttestation(
strategy, currentEpoch, attestationData, AttestationSource.BLOCK);

assertThat(result.isInvalid()).isTrue();
assertThat(result.getInvalidReason())
.contains("Attestation slot must be within specified epoch");
// The attestation should be rejected before any fork choice lookups are attempted.
verify(strategy, never()).contains(any());
}

@Test
void validateOnAttestation_blockSource_shouldSkipRecencyCheckButStillEnforceEpochConsistency() {
// AttestationSource.BLOCK is only meant to skip the current/previous-epoch recency check
// (which does not apply to attestations already embedded in a finalized-chain block).
// Here the target epoch is neither current nor previous, but it is internally consistent
// with the attestation slot, so validation should proceed past the epoch checks.
final int slotsPerEpoch = spec.getGenesisSpecConfig().getSlotsPerEpoch();
final UInt64 currentEpoch = UInt64.valueOf(10);
final UInt64 oldEpoch = UInt64.ZERO;
final UInt64 attestationSlot = UInt64.valueOf(oldEpoch.longValue() * slotsPerEpoch);
final Checkpoint consistentTarget = dataStructureUtil.randomCheckpoint(oldEpoch);
final AttestationData attestationData =
new AttestationData(
attestationSlot,
UInt64.ZERO,
dataStructureUtil.randomBytes32(),
dataStructureUtil.randomCheckpoint(oldEpoch),
consistentTarget);
final ReadOnlyForkChoiceStrategy strategy = mock(ReadOnlyForkChoiceStrategy.class);
when(strategy.contains(consistentTarget.getRoot())).thenReturn(false);

final AttestationProcessingResult result =
forkChoiceUtil.validateOnAttestation(
strategy, currentEpoch, attestationData, AttestationSource.BLOCK);

// Falls through to the unknown-block check rather than being rejected for being outside the
// current/previous epoch, confirming the recency check was skipped as intended.
assertThat(result).isEqualTo(AttestationProcessingResult.UNKNOWN_BLOCK);
}

private ReadOnlyStore mockStore(
final long currentSlot, final Bytes32... blocksWithNonDefaultPayloads) {
final ReadOnlyStore store = mock(ReadOnlyStore.class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,13 @@ public SafeFuture<InternalValidationResult> validateAndImportBlock(
// block failed gossip validation, let's drop it from the pool, so it won't be served
// via RPC anymore. This should not be done on ignore result (i.e. duplicate blocks
// could cause an unwanted drop)
case REJECT -> blockEventsListener.removeAllForBlock(block.getSlotAndBlockRoot());
case REJECT -> {
blockEventsListener.removeAllForBlock(block.getSlotAndBlockRoot());
// This attempt never reached import, so any timeliness recorded from its raw
// arrival shouldn't stick around to affect a later, separate import attempt for
// the same block (e.g. if it's subsequently re-fetched by root).
recentChainData.invalidateUnconfirmedBlockTimeliness(block);
}
case IGNORE -> {}
}
});
Expand Down Expand Up @@ -313,7 +319,16 @@ private SafeFuture<BlockImportResult> handleBlockImport(
result -> {
if (result.isSuccessful()) {
LOG.trace("Imported block: {}", block);
// Successful import confirms (and, if necessary, refreshes) the block's
// timeliness recording, so it no longer matters whether an earlier attempt for
// this block was premature or otherwise didn't succeed.
recentChainData.confirmBlockTimeliness(block);
} else {
// This attempt didn't result in a successful import. Discard any unconfirmed
// timeliness recording tied to it so a later, successful attempt (e.g. a retry
// from the pending/future block pool) can record fresh, accurate timeliness
// instead of being stuck with this attempt's possibly premature/invalid value.
recentChainData.invalidateUnconfirmedBlockTimeliness(block);
switch (result.getFailureReason()) {
case UNKNOWN_PARENT -> {
// Add to the pending pool so it is triggered once the parent is imported
Expand Down
Loading
Loading