From c3eede3e972e422ac51acb961f10548a0cbc5f6d Mon Sep 17 00:00:00 2001 From: Enrico Del Fante Date: Fri, 11 Sep 2026 16:10:07 +0200 Subject: [PATCH] Enforce progressive-list soft limits at network decode time --- .../multipeer/batches/SyncSourceBatch.java | 4 + .../batches/SyncSourceBatchTest.java | 32 ++ .../blocks/SignedBeaconBlockSchema.java | 14 +- .../SignedExecutionPayloadEnvelopeSchema.java | 14 +- .../util/GloasNetworkLimits.java | 194 ++++++++++ .../registry/SchemaRegistryBuilder.java | 13 +- .../blocks/SignedBeaconBlockSchemaTest.java | 115 ++++++ ...nedExecutionPayloadEnvelopeSchemaTest.java | 114 ++++++ .../util/GloasNetworkLimitsTest.java | 337 ++++++++++++++++++ .../NetworkSszValidatorConventionTest.java | 117 ++++++ .../teku/spec/util/DataStructureUtil.java | 15 +- .../validation/BlockGossipValidator.java | 153 +------- .../ExecutionPayloadGossipValidator.java | 72 +--- .../ExecutionPayloadGossipValidatorTest.java | 66 ++++ .../ssz/schema/SszNetworkValidator.java | 35 ++ .../infrastructure/ssz/schema/SszSchema.java | 11 + .../ssz/schema/SszSchemaTestBase.java | 6 + .../eth2/gossip/encoding/SszGossipCodec.java | 1 + .../eth2/rpc/core/RpcException.java | 47 ++- .../ssz/DefaultRpcPayloadEncoder.java | 4 +- .../gossip/encoding/SszGossipCodecTest.java | 39 ++ .../DefaultRpcPayloadEncoderTest.java | 36 ++ specrefs/.ethspecify.yml | 5 - specrefs/functions.yml | 20 +- .../duties/BlockProductionDutyTest.java | 4 +- 25 files changed, 1242 insertions(+), 226 deletions(-) create mode 100644 ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/util/GloasNetworkLimits.java create mode 100644 ethereum/spec/src/test/java/tech/pegasys/teku/spec/datastructures/blocks/SignedBeaconBlockSchemaTest.java create mode 100644 ethereum/spec/src/test/java/tech/pegasys/teku/spec/datastructures/epbs/versions/gloas/SignedExecutionPayloadEnvelopeSchemaTest.java create mode 100644 ethereum/spec/src/test/java/tech/pegasys/teku/spec/datastructures/util/GloasNetworkLimitsTest.java create mode 100644 ethereum/spec/src/test/java/tech/pegasys/teku/spec/schemas/registry/NetworkSszValidatorConventionTest.java create mode 100644 infrastructure/ssz/src/main/java/tech/pegasys/teku/infrastructure/ssz/schema/SszNetworkValidator.java diff --git a/beacon/sync/src/main/java/tech/pegasys/teku/beacon/sync/forward/multipeer/batches/SyncSourceBatch.java b/beacon/sync/src/main/java/tech/pegasys/teku/beacon/sync/forward/multipeer/batches/SyncSourceBatch.java index 1e636326d28..6d44f569d9a 100644 --- a/beacon/sync/src/main/java/tech/pegasys/teku/beacon/sync/forward/multipeer/batches/SyncSourceBatch.java +++ b/beacon/sync/src/main/java/tech/pegasys/teku/beacon/sync/forward/multipeer/batches/SyncSourceBatch.java @@ -38,6 +38,7 @@ import tech.pegasys.teku.infrastructure.unsigned.UInt64; import tech.pegasys.teku.networking.eth2.peers.SyncSource; import tech.pegasys.teku.networking.eth2.rpc.beaconchain.methods.BlocksByRangeResponseInvalidResponseException; +import tech.pegasys.teku.networking.eth2.rpc.core.RpcException.MalformedDataException; import tech.pegasys.teku.networking.p2p.peer.PeerDisconnectedException; import tech.pegasys.teku.networking.p2p.rpc.RpcResponseListener; import tech.pegasys.teku.spec.Spec; @@ -328,6 +329,9 @@ private void handleRequestErrors(final Throwable error) { } else if (rootCause instanceof BlocksByRangeResponseInvalidResponseException) { LOG.debug("Inconsistent blocks returned from blocks by range request", error); markAsInvalid(); + } else if (rootCause instanceof MalformedDataException) { + LOG.debug("Malformed response received while requesting batch data", error); + markAsInvalid(); } else { LOG.debug("Error while requesting blocks", error); currentSyncSource = Optional.empty(); diff --git a/beacon/sync/src/test/java/tech/pegasys/teku/beacon/sync/forward/multipeer/batches/SyncSourceBatchTest.java b/beacon/sync/src/test/java/tech/pegasys/teku/beacon/sync/forward/multipeer/batches/SyncSourceBatchTest.java index b3c5e4c4d2a..18f2ce5a915 100644 --- a/beacon/sync/src/test/java/tech/pegasys/teku/beacon/sync/forward/multipeer/batches/SyncSourceBatchTest.java +++ b/beacon/sync/src/test/java/tech/pegasys/teku/beacon/sync/forward/multipeer/batches/SyncSourceBatchTest.java @@ -18,6 +18,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -25,6 +26,7 @@ import static org.mockito.Mockito.when; import static tech.pegasys.teku.beacon.sync.forward.multipeer.batches.BatchAssert.assertThatBatch; import static tech.pegasys.teku.beacon.sync.forward.multipeer.chains.TargetChainTestUtil.chainWith; +import static tech.pegasys.teku.networking.eth2.rpc.core.RpcResponseStatus.SERVER_ERROR_CODE; import java.util.ArrayList; import java.util.HashMap; @@ -44,6 +46,8 @@ import tech.pegasys.teku.networking.eth2.peers.StubSyncSource; import tech.pegasys.teku.networking.eth2.rpc.beaconchain.methods.BlocksByRangeResponseInvalidResponseException; import tech.pegasys.teku.networking.eth2.rpc.beaconchain.methods.BlocksByRangeResponseInvalidResponseException.InvalidResponseType; +import tech.pegasys.teku.networking.eth2.rpc.core.RpcException; +import tech.pegasys.teku.networking.eth2.rpc.core.RpcException.DeserializationFailedException; import tech.pegasys.teku.networking.p2p.peer.PeerDisconnectedException; import tech.pegasys.teku.spec.Spec; import tech.pegasys.teku.spec.TestSpecFactory; @@ -209,6 +213,34 @@ void shouldBeInvalidWhenInconsistentResponseReceived() { assertThatBatch(batch).isNotComplete(); } + @Test + void shouldBeInvalidWhenMalformedResponseReceived() { + final Runnable callback = mock(Runnable.class); + final Batch batch = createBatch(10, 10); + batch.requestMoreBlocks(callback); + + requestError(batch, new DeserializationFailedException()); + + verify(conflictResolutionStrategy).reportInvalidBatch(batch, getSyncSource(batch)); + verify(callback).run(); + assertThatBatch(batch).isEmpty(); + assertThatBatch(batch).isNotComplete(); + } + + @Test + void shouldNotBeInvalidWhenPeerRespondsWithError() { + final Runnable callback = mock(Runnable.class); + final Batch batch = createBatch(10, 10); + batch.requestMoreBlocks(callback); + + requestError(batch, new RpcException(SERVER_ERROR_CODE, "peer failed")); + + verify(conflictResolutionStrategy, never()).reportInvalidBatch(any(), any()); + verify(callback).run(); + assertThatBatch(batch).isEmpty(); + assertThatBatch(batch).isNotComplete(); + } + @Test void shouldReportAsInvalidToConflictResolutionStrategyWhenMarkedAsInvalid() { final Batch batch = createBatch(10, 10); diff --git a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/blocks/SignedBeaconBlockSchema.java b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/blocks/SignedBeaconBlockSchema.java index e7b06ca076d..528e33878e3 100644 --- a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/blocks/SignedBeaconBlockSchema.java +++ b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/blocks/SignedBeaconBlockSchema.java @@ -14,9 +14,11 @@ package tech.pegasys.teku.spec.datastructures.blocks; import it.unimi.dsi.fastutil.longs.LongList; +import java.util.Optional; import java.util.OptionalLong; import tech.pegasys.teku.bls.BLSSignature; import tech.pegasys.teku.infrastructure.ssz.containers.ContainerSchema2; +import tech.pegasys.teku.infrastructure.ssz.schema.SszNetworkValidator; import tech.pegasys.teku.infrastructure.ssz.tree.GIndexUtil; import tech.pegasys.teku.infrastructure.ssz.tree.TreeNode; import tech.pegasys.teku.spec.datastructures.type.SszSignature; @@ -27,21 +29,24 @@ public class SignedBeaconBlockSchema implements SignedBlockContainerSchema { private final OptionalLong networkSszLengthBytesUpperBound; + private final Optional> networkSszValidator; public SignedBeaconBlockSchema( final BeaconBlockSchema beaconBlockSchema, final String containerName) { - this(beaconBlockSchema, containerName, OptionalLong.empty()); + this(beaconBlockSchema, containerName, OptionalLong.empty(), Optional.empty()); } public SignedBeaconBlockSchema( final BeaconBlockSchema beaconBlockSchema, final String containerName, - final OptionalLong networkSszLengthBytesUpperBound) { + final OptionalLong networkSszLengthBytesUpperBound, + final Optional> networkSszValidator) { super( containerName, namedSchema(SignedBeaconBlockFields.MESSAGE, beaconBlockSchema), namedSchema(SignedBeaconBlockFields.SIGNATURE, SszSignatureSchema.INSTANCE)); this.networkSszLengthBytesUpperBound = networkSszLengthBytesUpperBound; + this.networkSszValidator = networkSszValidator; validateNetworkSszLengthBytesUpperBound(); } @@ -50,6 +55,11 @@ public OptionalLong getNetworkSszLengthBytesUpperBound() { return networkSszLengthBytesUpperBound; } + @Override + public Optional> getNetworkSszValidator() { + return networkSszValidator; + } + public SignedBeaconBlock create(final BeaconBlock message, final BLSSignature signature) { return new SignedBeaconBlock(this, message, signature); } diff --git a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/epbs/versions/gloas/SignedExecutionPayloadEnvelopeSchema.java b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/epbs/versions/gloas/SignedExecutionPayloadEnvelopeSchema.java index dcccf05861b..e34caaa0edc 100644 --- a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/epbs/versions/gloas/SignedExecutionPayloadEnvelopeSchema.java +++ b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/epbs/versions/gloas/SignedExecutionPayloadEnvelopeSchema.java @@ -15,8 +15,10 @@ import static tech.pegasys.teku.spec.schemas.registry.SchemaTypes.EXECUTION_PAYLOAD_ENVELOPE_SCHEMA; +import java.util.Optional; import tech.pegasys.teku.bls.BLSSignature; import tech.pegasys.teku.infrastructure.ssz.containers.ContainerSchema2; +import tech.pegasys.teku.infrastructure.ssz.schema.SszNetworkValidator; import tech.pegasys.teku.infrastructure.ssz.tree.TreeNode; import tech.pegasys.teku.spec.datastructures.type.SszSignature; import tech.pegasys.teku.spec.datastructures.type.SszSignatureSchema; @@ -26,11 +28,21 @@ public class SignedExecutionPayloadEnvelopeSchema extends ContainerSchema2< SignedExecutionPayloadEnvelope, ExecutionPayloadEnvelope, SszSignature> { - public SignedExecutionPayloadEnvelopeSchema(final SchemaRegistry schemaRegistry) { + private final Optional> networkSszValidator; + + public SignedExecutionPayloadEnvelopeSchema( + final SchemaRegistry schemaRegistry, + final Optional> networkSszValidator) { super( "SignedExecutionPayloadEnvelope", namedSchema("message", schemaRegistry.get(EXECUTION_PAYLOAD_ENVELOPE_SCHEMA)), namedSchema("signature", SszSignatureSchema.INSTANCE)); + this.networkSszValidator = networkSszValidator; + } + + @Override + public Optional> getNetworkSszValidator() { + return networkSszValidator; } public SignedExecutionPayloadEnvelope create( diff --git a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/util/GloasNetworkLimits.java b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/util/GloasNetworkLimits.java new file mode 100644 index 00000000000..503ebdd0aed --- /dev/null +++ b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/util/GloasNetworkLimits.java @@ -0,0 +1,194 @@ +/* + * 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.util; + +import java.util.Optional; +import tech.pegasys.teku.infrastructure.ssz.schema.SszNetworkValidator; +import tech.pegasys.teku.infrastructure.ssz.sos.SszDeserializeException; +import tech.pegasys.teku.spec.config.SpecConfigGloas; +import tech.pegasys.teku.spec.datastructures.blocks.SignedBeaconBlock; +import tech.pegasys.teku.spec.datastructures.blocks.blockbody.versions.gloas.BeaconBlockBodyGloas; +import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.ExecutionPayloadEnvelope; +import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.SignedExecutionPayloadEnvelope; +import tech.pegasys.teku.spec.datastructures.execution.ExecutionPayload; +import tech.pegasys.teku.spec.datastructures.execution.versions.capella.ExecutionPayloadCapella; +import tech.pegasys.teku.spec.datastructures.execution.versions.gloas.ExecutionRequestsGloas; + +/** + * Count limits that Gloas no longer expresses in SSZ (EIP-7688 progressive lists) and that the spec + * allows to be enforced when deserializing network messages. Shared by the network decoders + * (through the wire-level schema validators) and by the gossip validators, which also see locally + * published messages that never crossed the wire. + */ +public final class GloasNetworkLimits { + + /** Subject used in violation messages for a block's {@code parent_execution_requests}. */ + public static final String PARENT_EXECUTION_REQUESTS_SUBJECT = "Parent execution requests"; + + /** Subject used in violation messages for an execution payload envelope. */ + public static final String EXECUTION_PAYLOAD_ENVELOPE_SUBJECT = "Execution payload envelope"; + + private GloasNetworkLimits() {} + + /** + * Validator for blocks decoded from the network: spec {@code verify_block_body_operation_limits} + * and {@code verify_execution_requests_limits} on the parent execution requests. + */ + public static SszNetworkValidator signedBeaconBlockNetworkValidator( + final SpecConfigGloas config) { + return block -> { + final BeaconBlockBodyGloas body = BeaconBlockBodyGloas.required(block.getMessage().getBody()); + verifyBlockBodyOperationLimits(body, config) + .or( + () -> + verifyExecutionRequestsLimits( + PARENT_EXECUTION_REQUESTS_SUBJECT, + ExecutionRequestsGloas.required(body.getParentExecutionRequests()), + config)) + .ifPresent(GloasNetworkLimits::rejectDecoded); + }; + } + + /** + * Validator for execution payload envelopes decoded from the network: the execution request and + * withdrawal count limits of {@code validate_execution_payload_envelope_gossip}. + */ + public static SszNetworkValidator + signedExecutionPayloadEnvelopeNetworkValidator(final SpecConfigGloas config) { + return signedEnvelope -> { + final ExecutionPayloadEnvelope envelope = signedEnvelope.getMessage(); + verifyExecutionRequestsLimits( + EXECUTION_PAYLOAD_ENVELOPE_SUBJECT, + ExecutionRequestsGloas.required(envelope.getExecutionRequests()), + config) + .or( + () -> + verifyWithdrawalsLimit( + EXECUTION_PAYLOAD_ENVELOPE_SUBJECT, envelope.getPayload(), config)) + .ifPresent(GloasNetworkLimits::rejectDecoded); + }; + } + + private static void rejectDecoded(final LimitViolation violation) { + throw new SszDeserializeException(violation.describe()); + } + + /** The first count that exceeds its limit. A zero limit means the list must be empty. */ + public record LimitViolation(String subject, String description, int count, int limit) { + public String describe() { + if (limit == 0) { + return String.format("%s must not contain %s, found %d", subject, description, count); + } + return String.format("%s has %d %s, max allowed %d", subject, count, description, limit); + } + } + + /** Spec {@code verify_block_body_operation_limits}. */ + public static Optional verifyBlockBodyOperationLimits( + final BeaconBlockBodyGloas body, final SpecConfigGloas config) { + final String subject = "Block"; + return check( + subject, + "proposer slashings", + body.getProposerSlashings().size(), + config.getMaxProposerSlashings()) + .or( + () -> + check( + subject, + "attester slashings", + body.getAttesterSlashings().size(), + config.getMaxAttesterSlashingsElectra())) + .or( + () -> + check( + subject, + "attestations", + body.getAttestations().size(), + config.getMaxAttestationsElectra())) + .or(() -> check(subject, "deposits", body.getDeposits().size(), 0)) + .or( + () -> + check( + subject, + "voluntary exits", + body.getVoluntaryExits().size(), + config.getMaxVoluntaryExits())) + .or( + () -> + check( + subject, + "bls to execution changes", + body.getBlsToExecutionChanges().size(), + config.getMaxBlsToExecutionChanges())) + .or( + () -> + check( + subject, + "payload attestations", + body.getPayloadAttestations().size(), + config.getMaxPayloadAttestations())); + } + + /** Spec {@code verify_execution_requests_limits}. Deposit requests have no Gloas limit. */ + public static Optional verifyExecutionRequestsLimits( + final String subject, final ExecutionRequestsGloas requests, final SpecConfigGloas config) { + return check( + subject, + "withdrawal requests", + requests.getWithdrawals().size(), + config.getMaxWithdrawalRequestsPerPayload()) + .or( + () -> + check( + subject, + "consolidation requests", + requests.getConsolidations().size(), + config.getMaxConsolidationRequestsPerPayload())) + .or( + () -> + check( + subject, + "builder deposit requests", + requests.getBuilderDeposits().size(), + config.getMaxBuilderDepositRequestsPerPayload())) + .or( + () -> + check( + subject, + "builder exit requests", + requests.getBuilderExits().size(), + config.getMaxBuilderExitRequestsPerPayload())); + } + + /** + * The {@code MAX_WITHDRAWALS_PER_PAYLOAD} rule of {@code + * validate_execution_payload_envelope_gossip}. + */ + public static Optional verifyWithdrawalsLimit( + final String subject, final ExecutionPayload payload, final SpecConfigGloas config) { + return check( + subject, + "withdrawals", + ExecutionPayloadCapella.required(payload).getWithdrawals().size(), + config.getMaxWithdrawalsPerPayload()); + } + + private static Optional check( + final String subject, final String description, final int count, final int limit) { + return count > limit + ? Optional.of(new LimitViolation(subject, description, count, limit)) + : Optional.empty(); + } +} diff --git a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/schemas/registry/SchemaRegistryBuilder.java b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/schemas/registry/SchemaRegistryBuilder.java index 5b2ab126d9c..babb8bc7d29 100644 --- a/ethereum/spec/src/main/java/tech/pegasys/teku/spec/schemas/registry/SchemaRegistryBuilder.java +++ b/ethereum/spec/src/main/java/tech/pegasys/teku/spec/schemas/registry/SchemaRegistryBuilder.java @@ -118,6 +118,7 @@ import com.google.common.annotations.VisibleForTesting; import java.util.HashSet; +import java.util.Optional; import java.util.OptionalLong; import java.util.Set; import tech.pegasys.teku.infrastructure.ssz.schema.SszListSchema; @@ -263,6 +264,7 @@ import tech.pegasys.teku.spec.datastructures.state.versions.gloas.BuilderPendingPaymentSchema; import tech.pegasys.teku.spec.datastructures.state.versions.gloas.BuilderPendingWithdrawalSchema; import tech.pegasys.teku.spec.datastructures.state.versions.gloas.PtcWindowSchema; +import tech.pegasys.teku.spec.datastructures.util.GloasNetworkLimits; import tech.pegasys.teku.spec.schemas.registry.SchemaTypes.SchemaId; // TODO Error Prone's JavaCase check doesn't yet recognize Java 25 unnamed lambda parameters. @@ -693,7 +695,10 @@ private static SchemaProvider createSignedBeaconBlockSchemaProvider() { new SignedBeaconBlockSchema( registry.get(BEACON_BLOCK_SCHEMA), schemaName, - OptionalLong.of(specConfig.getMaxPayloadSize()))) + OptionalLong.of(specConfig.getMaxPayloadSize()), + Optional.of( + GloasNetworkLimits.signedBeaconBlockNetworkValidator( + SpecConfigGloas.required(specConfig))))) .build(); } @@ -1374,7 +1379,11 @@ private static SchemaProvider createSignedExecutionPayloadEnvelopeSchemaProvi .withCreator( GLOAS, (registry, specConfig, schemaName) -> - new SignedExecutionPayloadEnvelopeSchema(registry)) + new SignedExecutionPayloadEnvelopeSchema( + registry, + Optional.of( + GloasNetworkLimits.signedExecutionPayloadEnvelopeNetworkValidator( + SpecConfigGloas.required(specConfig))))) .build(); } diff --git a/ethereum/spec/src/test/java/tech/pegasys/teku/spec/datastructures/blocks/SignedBeaconBlockSchemaTest.java b/ethereum/spec/src/test/java/tech/pegasys/teku/spec/datastructures/blocks/SignedBeaconBlockSchemaTest.java new file mode 100644 index 00000000000..397d7ec20c4 --- /dev/null +++ b/ethereum/spec/src/test/java/tech/pegasys/teku/spec/datastructures/blocks/SignedBeaconBlockSchemaTest.java @@ -0,0 +1,115 @@ +/* + * 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.blocks; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; +import tech.pegasys.teku.infrastructure.ssz.schema.SszNetworkValidator; +import tech.pegasys.teku.infrastructure.ssz.sos.SszDeserializeException; +import tech.pegasys.teku.infrastructure.unsigned.UInt64; +import tech.pegasys.teku.spec.Spec; +import tech.pegasys.teku.spec.SpecMilestone; +import tech.pegasys.teku.spec.TestSpecFactory; +import tech.pegasys.teku.spec.config.SpecConfigGloas; +import tech.pegasys.teku.spec.datastructures.blocks.blockbody.BeaconBlockBody; +import tech.pegasys.teku.spec.datastructures.execution.ExecutionRequests; +import tech.pegasys.teku.spec.datastructures.execution.versions.electra.WithdrawalRequest; +import tech.pegasys.teku.spec.util.DataStructureUtil; + +class SignedBeaconBlockSchemaTest { + + private final Spec spec = TestSpecFactory.createMinimalGloas(); + private final DataStructureUtil dataStructureUtil = new DataStructureUtil(spec); + private final UInt64 slot = UInt64.ONE; + private final SpecConfigGloas config = + SpecConfigGloas.required(spec.forMilestone(SpecMilestone.GLOAS).getConfig()); + private final SignedBeaconBlockSchema schema = + spec.atSlot(slot).getSchemaDefinitions().getSignedBeaconBlockSchema(); + + @Test + void getNetworkSszValidator_shouldBeEmptyBeforeGloas() { + final Spec electraSpec = TestSpecFactory.createMinimalElectra(); + assertThat( + electraSpec + .getGenesisSchemaDefinitions() + .getSignedBeaconBlockSchema() + .getNetworkSszValidator()) + .isEmpty(); + assertThat( + electraSpec + .getGenesisSchemaDefinitions() + .getSignedBlindedBeaconBlockSchema() + .getNetworkSszValidator()) + .isEmpty(); + } + + @Test + void getNetworkSszValidator_shouldAcceptValidGloasBlock() { + final SignedBeaconBlock block = dataStructureUtil.randomSignedBeaconBlock(slot); + assertThatNoException().isThrownBy(() -> validator().validate(block)); + } + + @Test + void getNetworkSszValidator_shouldRejectTooManyAttestations() { + final int limit = config.getMaxAttestationsElectra(); + final SignedBeaconBlock block = + blockWithBody( + dataStructureUtil.randomBeaconBlockBody( + slot, + builder -> + builder.attestations(dataStructureUtil.randomAttestations(limit + 1, slot)))); + + assertThatThrownBy(() -> validator().validate(block)) + .isInstanceOf(SszDeserializeException.class) + .hasMessage("Block has %d attestations, max allowed %d", limit + 1, limit); + } + + @Test + void getNetworkSszValidator_shouldRejectTooManyParentWithdrawalRequests() { + final int limit = config.getMaxWithdrawalRequestsPerPayload(); + final List tooManyWithdrawalRequests = + IntStream.range(0, limit + 1) + .mapToObj(__ -> dataStructureUtil.randomWithdrawalRequest()) + .toList(); + final ExecutionRequests parentExecutionRequests = + dataStructureUtil + .randomExecutionRequestsBuilder(slot) + .withdrawals(tooManyWithdrawalRequests) + .build(); + final SignedBeaconBlock block = + blockWithBody( + dataStructureUtil.randomBeaconBlockBody( + slot, builder -> builder.parentExecutionRequests(parentExecutionRequests))); + + assertThatThrownBy(() -> validator().validate(block)) + .isInstanceOf(SszDeserializeException.class) + .hasMessage( + "Parent execution requests has %d withdrawal requests, max allowed %d", + limit + 1, limit); + } + + private SszNetworkValidator validator() { + return schema.getNetworkSszValidator().orElseThrow(); + } + + private SignedBeaconBlock blockWithBody(final BeaconBlockBody body) { + return schema.create( + dataStructureUtil.randomBeaconBlock(slot, body), dataStructureUtil.randomSignature()); + } +} diff --git a/ethereum/spec/src/test/java/tech/pegasys/teku/spec/datastructures/epbs/versions/gloas/SignedExecutionPayloadEnvelopeSchemaTest.java b/ethereum/spec/src/test/java/tech/pegasys/teku/spec/datastructures/epbs/versions/gloas/SignedExecutionPayloadEnvelopeSchemaTest.java new file mode 100644 index 00000000000..9b50f5924f6 --- /dev/null +++ b/ethereum/spec/src/test/java/tech/pegasys/teku/spec/datastructures/epbs/versions/gloas/SignedExecutionPayloadEnvelopeSchemaTest.java @@ -0,0 +1,114 @@ +/* + * 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.epbs.versions.gloas; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; +import tech.pegasys.teku.infrastructure.ssz.schema.SszNetworkValidator; +import tech.pegasys.teku.infrastructure.ssz.sos.SszDeserializeException; +import tech.pegasys.teku.infrastructure.unsigned.UInt64; +import tech.pegasys.teku.spec.Spec; +import tech.pegasys.teku.spec.SpecMilestone; +import tech.pegasys.teku.spec.TestSpecFactory; +import tech.pegasys.teku.spec.config.SpecConfigGloas; +import tech.pegasys.teku.spec.datastructures.execution.ExecutionPayload; +import tech.pegasys.teku.spec.datastructures.execution.ExecutionRequests; +import tech.pegasys.teku.spec.datastructures.execution.versions.electra.WithdrawalRequest; +import tech.pegasys.teku.spec.schemas.SchemaDefinitionsGloas; +import tech.pegasys.teku.spec.util.DataStructureUtil; + +class SignedExecutionPayloadEnvelopeSchemaTest { + + private final Spec spec = TestSpecFactory.createMinimalGloas(); + private final DataStructureUtil dataStructureUtil = new DataStructureUtil(spec); + private final UInt64 slot = UInt64.ONE; + private final SpecConfigGloas config = + SpecConfigGloas.required(spec.forMilestone(SpecMilestone.GLOAS).getConfig()); + private final SchemaDefinitionsGloas schemaDefinitions = + SchemaDefinitionsGloas.required(spec.atSlot(slot).getSchemaDefinitions()); + private final SignedExecutionPayloadEnvelopeSchema schema = + schemaDefinitions.getSignedExecutionPayloadEnvelopeSchema(); + + @Test + void getNetworkSszValidator_shouldAcceptValidEnvelope() { + final SignedExecutionPayloadEnvelope envelope = + dataStructureUtil.randomSignedExecutionPayloadEnvelope(slot.longValue()); + assertThatNoException().isThrownBy(() -> validator().validate(envelope)); + } + + @Test + void getNetworkSszValidator_shouldRejectTooManyWithdrawals() { + final int limit = config.getMaxWithdrawalsPerPayload(); + final ExecutionPayload payload = + dataStructureUtil.randomExecutionPayload( + slot, + builder -> + builder.withdrawals( + () -> + IntStream.range(0, limit + 1) + .mapToObj(__ -> dataStructureUtil.randomWithdrawal()) + .toList())); + final SignedExecutionPayloadEnvelope envelope = + envelope(payload, dataStructureUtil.randomExecutionRequests(slot)); + + assertThatThrownBy(() -> validator().validate(envelope)) + .isInstanceOf(SszDeserializeException.class) + .hasMessage( + "Execution payload envelope has %d withdrawals, max allowed %d", limit + 1, limit); + } + + @Test + void getNetworkSszValidator_shouldRejectTooManyWithdrawalRequests() { + final int limit = config.getMaxWithdrawalRequestsPerPayload(); + final List tooManyWithdrawalRequests = + IntStream.range(0, limit + 1) + .mapToObj(__ -> dataStructureUtil.randomWithdrawalRequest()) + .toList(); + final ExecutionRequests requests = + dataStructureUtil + .randomExecutionRequestsBuilder(slot) + .withdrawals(tooManyWithdrawalRequests) + .build(); + final SignedExecutionPayloadEnvelope envelope = + envelope(dataStructureUtil.randomExecutionPayload(slot), requests); + + assertThatThrownBy(() -> validator().validate(envelope)) + .isInstanceOf(SszDeserializeException.class) + .hasMessage( + "Execution payload envelope has %d withdrawal requests, max allowed %d", + limit + 1, limit); + } + + private SszNetworkValidator validator() { + return schema.getNetworkSszValidator().orElseThrow(); + } + + private SignedExecutionPayloadEnvelope envelope( + final ExecutionPayload payload, final ExecutionRequests requests) { + return schema.create( + schemaDefinitions + .getExecutionPayloadEnvelopeSchema() + .create( + payload, + requests, + dataStructureUtil.randomUInt64(), + dataStructureUtil.randomBytes32(), + dataStructureUtil.randomBytes32()), + dataStructureUtil.randomSignature()); + } +} diff --git a/ethereum/spec/src/test/java/tech/pegasys/teku/spec/datastructures/util/GloasNetworkLimitsTest.java b/ethereum/spec/src/test/java/tech/pegasys/teku/spec/datastructures/util/GloasNetworkLimitsTest.java new file mode 100644 index 00000000000..227eed3618a --- /dev/null +++ b/ethereum/spec/src/test/java/tech/pegasys/teku/spec/datastructures/util/GloasNetworkLimitsTest.java @@ -0,0 +1,337 @@ +/* + * 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.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Supplier; +import java.util.stream.IntStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import tech.pegasys.teku.infrastructure.ssz.SszData; +import tech.pegasys.teku.infrastructure.ssz.SszList; +import tech.pegasys.teku.infrastructure.ssz.schema.SszListSchema; +import tech.pegasys.teku.infrastructure.unsigned.UInt64; +import tech.pegasys.teku.spec.Spec; +import tech.pegasys.teku.spec.SpecMilestone; +import tech.pegasys.teku.spec.TestSpecFactory; +import tech.pegasys.teku.spec.config.SpecConfigGloas; +import tech.pegasys.teku.spec.datastructures.blocks.blockbody.BeaconBlockBodyBuilder; +import tech.pegasys.teku.spec.datastructures.blocks.blockbody.versions.gloas.BeaconBlockBodyGloas; +import tech.pegasys.teku.spec.datastructures.blocks.blockbody.versions.gloas.BeaconBlockBodySchemaGloas; +import tech.pegasys.teku.spec.datastructures.execution.ExecutionPayload; +import tech.pegasys.teku.spec.datastructures.execution.ExecutionRequestsBuilder; +import tech.pegasys.teku.spec.datastructures.execution.versions.gloas.ExecutionRequestsGloas; +import tech.pegasys.teku.spec.datastructures.util.GloasNetworkLimits.LimitViolation; +import tech.pegasys.teku.spec.util.DataStructureUtil; + +class GloasNetworkLimitsTest { + + private final Spec spec = TestSpecFactory.createMinimalGloas(); + private final DataStructureUtil dataStructureUtil = new DataStructureUtil(spec); + private final UInt64 slot = UInt64.ONE; + private final SpecConfigGloas config = + SpecConfigGloas.required(spec.forMilestone(SpecMilestone.GLOAS).getConfig()); + private BeaconBlockBodySchemaGloas bodySchema; + + @BeforeEach + void setUp() { + bodySchema = + BeaconBlockBodySchemaGloas.required( + spec.atSlot(slot).getSchemaDefinitions().getBeaconBlockBodySchema()); + } + + @Test + void verifyBlockBodyOperationLimits_shouldAcceptBodyAtLimits() { + final BeaconBlockBodyGloas body = + body( + builder -> + builder + .proposerSlashings( + list( + bodySchema.getProposerSlashingsSchema(), + dataStructureUtil::randomProposerSlashing, + config.getMaxProposerSlashings())) + .attesterSlashings( + list( + bodySchema.getAttesterSlashingsSchema(), + dataStructureUtil::randomAttesterSlashing, + config.getMaxAttesterSlashingsElectra())) + .attestations( + dataStructureUtil.randomAttestations( + config.getMaxAttestationsElectra(), slot)) + .voluntaryExits( + list( + bodySchema.getVoluntaryExitsSchema(), + dataStructureUtil::randomSignedVoluntaryExit, + config.getMaxVoluntaryExits())) + .blsToExecutionChanges( + list( + bodySchema.getBlsToExecutionChangesSchema(), + dataStructureUtil::randomSignedBlsToExecutionChange, + config.getMaxBlsToExecutionChanges())) + .payloadAttestations( + list( + bodySchema.getPayloadAttestationsSchema(), + dataStructureUtil::randomPayloadAttestation, + config.getMaxPayloadAttestations()))); + + assertThat(GloasNetworkLimits.verifyBlockBodyOperationLimits(body, config)).isEmpty(); + } + + @Test + void verifyBlockBodyOperationLimits_shouldRejectTooManyProposerSlashings() { + final int limit = config.getMaxProposerSlashings(); + final BeaconBlockBodyGloas body = + body( + builder -> + builder.proposerSlashings( + list( + bodySchema.getProposerSlashingsSchema(), + dataStructureUtil::randomProposerSlashing, + limit + 1))); + + assertThat(GloasNetworkLimits.verifyBlockBodyOperationLimits(body, config)) + .contains(new LimitViolation("Block", "proposer slashings", limit + 1, limit)); + } + + @Test + void verifyBlockBodyOperationLimits_shouldRejectTooManyAttesterSlashings() { + final int limit = config.getMaxAttesterSlashingsElectra(); + final BeaconBlockBodyGloas body = + body( + builder -> + builder.attesterSlashings( + list( + bodySchema.getAttesterSlashingsSchema(), + dataStructureUtil::randomAttesterSlashing, + limit + 1))); + + assertThat(GloasNetworkLimits.verifyBlockBodyOperationLimits(body, config)) + .contains(new LimitViolation("Block", "attester slashings", limit + 1, limit)); + } + + @Test + void verifyBlockBodyOperationLimits_shouldRejectTooManyAttestations() { + final int limit = config.getMaxAttestationsElectra(); + final BeaconBlockBodyGloas body = + body( + builder -> builder.attestations(dataStructureUtil.randomAttestations(limit + 1, slot))); + + assertThat(GloasNetworkLimits.verifyBlockBodyOperationLimits(body, config)) + .contains(new LimitViolation("Block", "attestations", limit + 1, limit)); + } + + @Test + void verifyBlockBodyOperationLimits_shouldRejectAnyDeposit() { + final BeaconBlockBodyGloas body = + body( + builder -> + builder.deposits( + list(bodySchema.getDepositsSchema(), dataStructureUtil::randomDeposit, 1))); + + final Optional violation = + GloasNetworkLimits.verifyBlockBodyOperationLimits(body, config); + assertThat(violation).contains(new LimitViolation("Block", "deposits", 1, 0)); + assertThat(violation.orElseThrow().describe()) + .isEqualTo("Block must not contain deposits, found 1"); + } + + @Test + void verifyBlockBodyOperationLimits_shouldRejectTooManyVoluntaryExits() { + final int limit = config.getMaxVoluntaryExits(); + final BeaconBlockBodyGloas body = + body( + builder -> + builder.voluntaryExits( + list( + bodySchema.getVoluntaryExitsSchema(), + dataStructureUtil::randomSignedVoluntaryExit, + limit + 1))); + + assertThat(GloasNetworkLimits.verifyBlockBodyOperationLimits(body, config)) + .contains(new LimitViolation("Block", "voluntary exits", limit + 1, limit)); + } + + @Test + void verifyBlockBodyOperationLimits_shouldRejectTooManyBlsToExecutionChanges() { + final int limit = config.getMaxBlsToExecutionChanges(); + final BeaconBlockBodyGloas body = + body( + builder -> + builder.blsToExecutionChanges( + list( + bodySchema.getBlsToExecutionChangesSchema(), + dataStructureUtil::randomSignedBlsToExecutionChange, + limit + 1))); + + assertThat(GloasNetworkLimits.verifyBlockBodyOperationLimits(body, config)) + .contains(new LimitViolation("Block", "bls to execution changes", limit + 1, limit)); + } + + @Test + void verifyBlockBodyOperationLimits_shouldRejectTooManyPayloadAttestations() { + final int limit = config.getMaxPayloadAttestations(); + final BeaconBlockBodyGloas body = + body( + builder -> + builder.payloadAttestations( + list( + bodySchema.getPayloadAttestationsSchema(), + dataStructureUtil::randomPayloadAttestation, + limit + 1))); + + final Optional violation = + GloasNetworkLimits.verifyBlockBodyOperationLimits(body, config); + assertThat(violation) + .contains(new LimitViolation("Block", "payload attestations", limit + 1, limit)); + assertThat(violation.orElseThrow().describe()) + .isEqualTo( + String.format("Block has %d payload attestations, max allowed %d", limit + 1, limit)); + } + + @Test + void verifyExecutionRequestsLimits_shouldAcceptRequestsAtLimits() { + final ExecutionRequestsGloas requests = + requests( + builder -> + builder + .withdrawals( + items( + dataStructureUtil::randomWithdrawalRequest, + config.getMaxWithdrawalRequestsPerPayload())) + .consolidations( + items( + dataStructureUtil::randomConsolidationRequest, + config.getMaxConsolidationRequestsPerPayload())) + .builderDeposits( + () -> + items( + dataStructureUtil::randomBuilderDepositRequest, + config.getMaxBuilderDepositRequestsPerPayload())) + .builderExits( + () -> + items( + dataStructureUtil::randomBuilderExitRequest, + config.getMaxBuilderExitRequestsPerPayload()))); + + assertThat(GloasNetworkLimits.verifyExecutionRequestsLimits("Subject", requests, config)) + .isEmpty(); + } + + @Test + void verifyExecutionRequestsLimits_shouldRejectTooManyWithdrawalRequests() { + final int limit = config.getMaxWithdrawalRequestsPerPayload(); + final ExecutionRequestsGloas requests = + requests( + builder -> + builder.withdrawals(items(dataStructureUtil::randomWithdrawalRequest, limit + 1))); + + assertThat(GloasNetworkLimits.verifyExecutionRequestsLimits("Subject", requests, config)) + .contains(new LimitViolation("Subject", "withdrawal requests", limit + 1, limit)); + } + + @Test + void verifyExecutionRequestsLimits_shouldRejectTooManyConsolidationRequests() { + final int limit = config.getMaxConsolidationRequestsPerPayload(); + final ExecutionRequestsGloas requests = + requests( + builder -> + builder.consolidations( + items(dataStructureUtil::randomConsolidationRequest, limit + 1))); + + assertThat(GloasNetworkLimits.verifyExecutionRequestsLimits("Subject", requests, config)) + .contains(new LimitViolation("Subject", "consolidation requests", limit + 1, limit)); + } + + @Test + void verifyExecutionRequestsLimits_shouldRejectTooManyBuilderDepositRequests() { + final int limit = config.getMaxBuilderDepositRequestsPerPayload(); + final ExecutionRequestsGloas requests = + requests( + builder -> + builder.builderDeposits( + () -> items(dataStructureUtil::randomBuilderDepositRequest, limit + 1))); + + assertThat(GloasNetworkLimits.verifyExecutionRequestsLimits("Subject", requests, config)) + .contains(new LimitViolation("Subject", "builder deposit requests", limit + 1, limit)); + } + + @Test + void verifyExecutionRequestsLimits_shouldRejectTooManyBuilderExitRequests() { + final int limit = config.getMaxBuilderExitRequestsPerPayload(); + final ExecutionRequestsGloas requests = + requests( + builder -> + builder.builderExits( + () -> items(dataStructureUtil::randomBuilderExitRequest, limit + 1))); + + assertThat(GloasNetworkLimits.verifyExecutionRequestsLimits("Subject", requests, config)) + .contains(new LimitViolation("Subject", "builder exit requests", limit + 1, limit)); + } + + @Test + void verifyWithdrawalsLimit_shouldAcceptPayloadAtLimit() { + final int limit = config.getMaxWithdrawalsPerPayload(); + final ExecutionPayload payload = + dataStructureUtil.randomExecutionPayload( + slot, + builder -> + builder.withdrawals(() -> items(dataStructureUtil::randomWithdrawal, limit))); + + assertThat(GloasNetworkLimits.verifyWithdrawalsLimit("Subject", payload, config)).isEmpty(); + } + + @Test + void verifyWithdrawalsLimit_shouldRejectTooManyWithdrawals() { + final int limit = config.getMaxWithdrawalsPerPayload(); + final ExecutionPayload payload = + dataStructureUtil.randomExecutionPayload( + slot, + builder -> + builder.withdrawals(() -> items(dataStructureUtil::randomWithdrawal, limit + 1))); + + assertThat(GloasNetworkLimits.verifyWithdrawalsLimit("Subject", payload, config)) + .contains(new LimitViolation("Subject", "withdrawals", limit + 1, limit)); + } + + private BeaconBlockBodyGloas body(final Consumer modifier) { + return BeaconBlockBodyGloas.required( + dataStructureUtil.randomBeaconBlockBody( + slot, + builder -> { + // the random body carries one deposit, which Gloas forbids + builder.deposits(bodySchema.getDepositsSchema().getDefault()); + modifier.accept(builder); + })); + } + + private ExecutionRequestsGloas requests(final Consumer modifier) { + final ExecutionRequestsBuilder builder = dataStructureUtil.randomExecutionRequestsBuilder(slot); + modifier.accept(builder); + return ExecutionRequestsGloas.required(builder.build()); + } + + private SszList list( + final SszListSchema schema, final Supplier generator, final int count) { + return dataStructureUtil.randomSszList(schema, generator, count); + } + + private List items(final Supplier generator, final int count) { + return IntStream.range(0, count).mapToObj(__ -> generator.get()).toList(); + } +} diff --git a/ethereum/spec/src/test/java/tech/pegasys/teku/spec/schemas/registry/NetworkSszValidatorConventionTest.java b/ethereum/spec/src/test/java/tech/pegasys/teku/spec/schemas/registry/NetworkSszValidatorConventionTest.java new file mode 100644 index 00000000000..4c2db7e3bae --- /dev/null +++ b/ethereum/spec/src/test/java/tech/pegasys/teku/spec/schemas/registry/NetworkSszValidatorConventionTest.java @@ -0,0 +1,117 @@ +/* + * 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.schemas.registry; + +import static org.assertj.core.api.Assertions.assertThat; +import static tech.pegasys.teku.spec.schemas.registry.SchemaTypes.SIGNED_BEACON_BLOCK_SCHEMA; +import static tech.pegasys.teku.spec.schemas.registry.SchemaTypes.SIGNED_EXECUTION_PAYLOAD_ENVELOPE_SCHEMA; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.TestTemplate; +import tech.pegasys.teku.infrastructure.ssz.schema.SszCollectionSchema; +import tech.pegasys.teku.infrastructure.ssz.schema.SszContainerSchema; +import tech.pegasys.teku.infrastructure.ssz.schema.SszOptionalSchema; +import tech.pegasys.teku.infrastructure.ssz.schema.SszSchema; +import tech.pegasys.teku.spec.SpecMilestone; +import tech.pegasys.teku.spec.TestSpecContext; +import tech.pegasys.teku.spec.TestSpecInvocationContextProvider.SpecContext; +import tech.pegasys.teku.spec.schemas.registry.SchemaTypes.SchemaId; + +/** + * Network validators are consulted only on the schema a gossip or RPC message is decoded with, so + * declaring one on a nested schema is dead code. This test pins the set of schemas that declare one + * to the wire-level schemas that actually need it. + */ +@TestSpecContext(allMilestones = true) +class NetworkSszValidatorConventionTest { + + @TestTemplate + void onlyWireLevelSchemasDeclareNetworkValidators(final SpecContext specContext) { + final SchemaRegistry registry = + specContext.getSpec().getGenesisSchemaDefinitions().getSchemaRegistry(); + + final Set> visited = Collections.newSetFromMap(new IdentityHashMap<>()); + final Set> withValidator = Collections.newSetFromMap(new IdentityHashMap<>()); + for (final SchemaId schemaId : allSchemaIds()) { + registeredSchema(registry, schemaId) + .ifPresent(schema -> walk(schema, visited, withValidator)); + } + + if (specContext.getSpecMilestone().isGreaterThanOrEqualTo(SpecMilestone.GLOAS)) { + assertThat(withValidator) + .containsExactlyInAnyOrder( + registry.get(SIGNED_BEACON_BLOCK_SCHEMA), + registry.get(SIGNED_EXECUTION_PAYLOAD_ENVELOPE_SCHEMA)); + } else { + assertThat(withValidator).isEmpty(); + } + } + + private static void walk( + final SszSchema schema, + final Set> visited, + final Set> withValidator) { + if (!visited.add(schema)) { + return; + } + if (schema.getNetworkSszValidator().isPresent()) { + withValidator.add(schema); + } + switch (schema) { + case SszContainerSchema container -> { + for (int i = 0; i < container.getFieldsCount(); i++) { + walk(container.getChildSchema(i), visited, withValidator); + } + } + case SszCollectionSchema collection -> + walk(collection.getElementSchema(), visited, withValidator); + case SszOptionalSchema optional -> + walk(optional.getChildSchema(), visited, withValidator); + default -> {} + } + } + + private static Optional> registeredSchema( + final SchemaRegistry registry, final SchemaId schemaId) { + try { + final Object schema = registry.get(schemaId); + return schema instanceof SszSchema sszSchema ? Optional.of(sszSchema) : Optional.empty(); + } catch (final IllegalArgumentException notRegisteredForMilestone) { + return Optional.empty(); + } + } + + private static List> allSchemaIds() { + return Arrays.stream(SchemaTypes.class.getDeclaredFields()) + .filter(field -> Modifier.isStatic(field.getModifiers())) + .filter(field -> SchemaId.class.isAssignableFrom(field.getType())) + .map(NetworkSszValidatorConventionTest::readSchemaId) + .toList(); + } + + private static SchemaId readSchemaId(final Field field) { + try { + return (SchemaId) field.get(null); + } catch (final IllegalAccessException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/ethereum/spec/src/testFixtures/java/tech/pegasys/teku/spec/util/DataStructureUtil.java b/ethereum/spec/src/testFixtures/java/tech/pegasys/teku/spec/util/DataStructureUtil.java index 948fe2cc791..b499ae09316 100644 --- a/ethereum/spec/src/testFixtures/java/tech/pegasys/teku/spec/util/DataStructureUtil.java +++ b/ethereum/spec/src/testFixtures/java/tech/pegasys/teku/spec/util/DataStructureUtil.java @@ -1527,8 +1527,7 @@ public BeaconBlockBody randomBlindedBeaconBlockBody( schema.getAttesterSlashingsSchema(), this::randomAttesterSlashing, 1)) .attestations( randomSszList(schema.getAttestationsSchema(), this::randomAttestation, 3)) - .deposits( - randomSszList(schema.getDepositsSchema(), this::randomDepositWithoutIndex, 1)) + .deposits(randomBlockBodyDeposits(slot, schema)) .voluntaryExits( randomSszList( schema.getVoluntaryExitsSchema(), this::randomSignedVoluntaryExit, 1)); @@ -1658,6 +1657,15 @@ public BeaconBlockBody randomBeaconBlockBody( return randomBeaconBlockBody(randomUInt64(), builderModifier); } + /** Gloas blocks must not contain deposits (they are sourced from execution requests). */ + private SszList randomBlockBodyDeposits( + final UInt64 slot, final BeaconBlockBodySchema schema) { + if (spec.atSlot(slot).getMilestone().isGreaterThanOrEqualTo(SpecMilestone.GLOAS)) { + return schema.getDepositsSchema().getDefault(); + } + return randomSszList(schema.getDepositsSchema(), this::randomDepositWithoutIndex, 1); + } + public BeaconBlockBody randomBeaconBlockBody( final UInt64 slot, final Consumer builderModifier) { final BeaconBlockBodySchema schema = @@ -1680,8 +1688,7 @@ public BeaconBlockBody randomBeaconBlockBody( .attestations( randomSszList( schema.getAttestationsSchema(), () -> randomAttestation(slot), 3)) - .deposits( - randomSszList(schema.getDepositsSchema(), this::randomDepositWithoutIndex, 1)) + .deposits(randomBlockBodyDeposits(slot, schema)) .voluntaryExits( randomSszList( schema.getVoluntaryExitsSchema(), this::randomSignedVoluntaryExit, 1)); diff --git a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/validation/BlockGossipValidator.java b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/validation/BlockGossipValidator.java index dd63cf7f5d4..951c5b3d4c6 100644 --- a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/validation/BlockGossipValidator.java +++ b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/validation/BlockGossipValidator.java @@ -35,21 +35,17 @@ import tech.pegasys.teku.infrastructure.unsigned.UInt64; import tech.pegasys.teku.spec.Spec; import tech.pegasys.teku.spec.SpecMilestone; -import tech.pegasys.teku.spec.config.SpecConfig; -import tech.pegasys.teku.spec.config.SpecConfigCapella; -import tech.pegasys.teku.spec.config.SpecConfigElectra; import tech.pegasys.teku.spec.config.SpecConfigGloas; import tech.pegasys.teku.spec.datastructures.blocks.SignedBeaconBlock; -import tech.pegasys.teku.spec.datastructures.blocks.blockbody.BeaconBlockBody; +import tech.pegasys.teku.spec.datastructures.blocks.blockbody.versions.gloas.BeaconBlockBodyGloas; import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.ExecutionPayloadBid; -import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.PayloadAttestation; import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.SignedExecutionPayloadBid; import tech.pegasys.teku.spec.datastructures.execution.ExecutionPayload; -import tech.pegasys.teku.spec.datastructures.execution.ExecutionRequests; import tech.pegasys.teku.spec.datastructures.execution.versions.gloas.ExecutionRequestsGloas; import tech.pegasys.teku.spec.datastructures.state.beaconstate.BeaconState; import tech.pegasys.teku.spec.datastructures.state.beaconstate.versions.gloas.BeaconStateGloas; import tech.pegasys.teku.spec.datastructures.type.SszKZGCommitment; +import tech.pegasys.teku.spec.datastructures.util.GloasNetworkLimits; import tech.pegasys.teku.spec.logic.common.helpers.MiscHelpers; import tech.pegasys.teku.spec.logic.versions.gloas.helpers.MiscHelpersGloas; import tech.pegasys.teku.spec.signatures.SigningRootUtil; @@ -320,150 +316,37 @@ private InternalValidationResult equivocationCheckResultToInternalValidationResu } /** - * Verifies that each Gloas block body operation count is within its limit and that the block - * contains no deposits. This rule is Gloas-only: EIP-7688 turned these operation lists into - * unbounded progressive lists, so SSZ no longer enforces the limits and this must be checked - * during gossip validation instead. Pre-Gloas blocks are unaffected, since their operation lists - * are still bounded at the SSZ level. + * Spec {@code verify_block_body_operation_limits}. Gloas-only: EIP-7688 turned the operation + * lists into unbounded progressive lists, so SSZ no longer enforces the limits. Network-decoded + * blocks are already checked by the schema's network validator; this also covers locally + * published blocks. */ private Optional verifyBlockBodyOperationLimits( final SignedBeaconBlock block) { if (!spec.atSlot(block.getSlot()).getMilestone().isGreaterThanOrEqualTo(SpecMilestone.GLOAS)) { return Optional.empty(); } - - final BeaconBlockBody body = block.getMessage().getBody(); - final SszList payloadAttestations = - body.getOptionalPayloadAttestations().orElseThrow(); - - final SpecConfig specConfig = spec.atSlot(block.getSlot()).getConfig(); - - final int maxProposerSlashings = specConfig.getMaxProposerSlashings(); - final int proposerSlashingsCount = body.getProposerSlashings().size(); - if (proposerSlashingsCount > maxProposerSlashings) { - return Optional.of( - reject( - "Block has %d proposer slashings, max allowed %d", - proposerSlashingsCount, maxProposerSlashings)); - } - - final int maxAttesterSlashings = - SpecConfigElectra.required(specConfig).getMaxAttesterSlashingsElectra(); - final int attesterSlashingsCount = body.getAttesterSlashings().size(); - if (attesterSlashingsCount > maxAttesterSlashings) { - return Optional.of( - reject( - "Block has %d attester slashings, max allowed %d", - attesterSlashingsCount, maxAttesterSlashings)); - } - - final int maxAttestations = SpecConfigElectra.required(specConfig).getMaxAttestationsElectra(); - final int attestationsCount = body.getAttestations().size(); - if (attestationsCount > maxAttestations) { - return Optional.of( - reject("Block has %d attestations, max allowed %d", attestationsCount, maxAttestations)); - } - - final int depositsCount = body.getDeposits().size(); - if (depositsCount != 0) { - return Optional.of(reject("Block must not contain deposits, found %d", depositsCount)); - } - - final int maxVoluntaryExits = specConfig.getMaxVoluntaryExits(); - final int voluntaryExitsCount = body.getVoluntaryExits().size(); - if (voluntaryExitsCount > maxVoluntaryExits) { - return Optional.of( - reject( - "Block has %d voluntary exits, max allowed %d", - voluntaryExitsCount, maxVoluntaryExits)); - } - - final int maxBlsToExecutionChanges = - SpecConfigCapella.required(specConfig).getMaxBlsToExecutionChanges(); - final int blsToExecutionChangesCount = - body.getOptionalBlsToExecutionChanges().orElseThrow().size(); - if (blsToExecutionChangesCount > maxBlsToExecutionChanges) { - return Optional.of( - reject( - "Block has %d bls to execution changes, max allowed %d", - blsToExecutionChangesCount, maxBlsToExecutionChanges)); - } - - final int maxPayloadAttestations = - SpecConfigGloas.required(specConfig).getMaxPayloadAttestations(); - final int payloadAttestationsCount = payloadAttestations.size(); - if (payloadAttestationsCount > maxPayloadAttestations) { - return Optional.of( - reject( - "Block has %d payload attestations, max allowed %d", - payloadAttestationsCount, maxPayloadAttestations)); - } - - return Optional.empty(); + return GloasNetworkLimits.verifyBlockBodyOperationLimits( + BeaconBlockBodyGloas.required(block.getMessage().getBody()), + SpecConfigGloas.required(spec.atSlot(block.getSlot()).getConfig())) + .map(violation -> reject("%s", violation.describe())); } /** - * Verifies that each Gloas parent execution request count is within its limit. This rule is - * Gloas-only: EIP-7688 turned these request lists into unbounded progressive lists, so SSZ no - * longer enforces the limits and this must be checked during gossip validation instead. Pre-Gloas - * blocks are unaffected, since they don't carry parent execution requests at all. + * Spec {@code verify_execution_requests_limits} applied to the parent execution requests. + * Gloas-only, see {@link #verifyBlockBodyOperationLimits}. */ private Optional verifyExecutionRequestsLimits( final SignedBeaconBlock block) { if (!spec.atSlot(block.getSlot()).getMilestone().isGreaterThanOrEqualTo(SpecMilestone.GLOAS)) { return Optional.empty(); } - - final BeaconBlockBody body = block.getMessage().getBody(); - final ExecutionRequests parentExecutionRequests = - body.getOptionalParentExecutionRequests().orElseThrow(); - final ExecutionRequestsGloas parentExecutionRequestsGloas = - ExecutionRequestsGloas.required(parentExecutionRequests); - - final SpecConfig specConfig = spec.atSlot(block.getSlot()).getConfig(); - - final int maxWithdrawalRequests = - SpecConfigElectra.required(specConfig).getMaxWithdrawalRequestsPerPayload(); - final int withdrawalRequestsCount = parentExecutionRequests.getWithdrawals().size(); - if (withdrawalRequestsCount > maxWithdrawalRequests) { - return Optional.of( - reject( - "Parent execution requests has %d withdrawal requests, max allowed %d", - withdrawalRequestsCount, maxWithdrawalRequests)); - } - - final int maxConsolidationRequests = - SpecConfigElectra.required(specConfig).getMaxConsolidationRequestsPerPayload(); - final int consolidationRequestsCount = parentExecutionRequests.getConsolidations().size(); - if (consolidationRequestsCount > maxConsolidationRequests) { - return Optional.of( - reject( - "Parent execution requests has %d consolidation requests, max allowed %d", - consolidationRequestsCount, maxConsolidationRequests)); - } - - final int maxBuilderDepositRequests = - SpecConfigGloas.required(specConfig).getMaxBuilderDepositRequestsPerPayload(); - final int builderDepositRequestsCount = - parentExecutionRequestsGloas.getBuilderDeposits().size(); - if (builderDepositRequestsCount > maxBuilderDepositRequests) { - return Optional.of( - reject( - "Parent execution requests has %d builder deposit requests, max allowed %d", - builderDepositRequestsCount, maxBuilderDepositRequests)); - } - - final int maxBuilderExitRequests = - SpecConfigGloas.required(specConfig).getMaxBuilderExitRequestsPerPayload(); - final int builderExitRequestsCount = parentExecutionRequestsGloas.getBuilderExits().size(); - if (builderExitRequestsCount > maxBuilderExitRequests) { - return Optional.of( - reject( - "Parent execution requests has %d builder exit requests, max allowed %d", - builderExitRequestsCount, maxBuilderExitRequests)); - } - - return Optional.empty(); + final BeaconBlockBodyGloas body = BeaconBlockBodyGloas.required(block.getMessage().getBody()); + return GloasNetworkLimits.verifyExecutionRequestsLimits( + GloasNetworkLimits.PARENT_EXECUTION_REQUESTS_SUBJECT, + ExecutionRequestsGloas.required(body.getParentExecutionRequests()), + SpecConfigGloas.required(spec.atSlot(block.getSlot()).getConfig())) + .map(violation -> reject("%s", violation.describe())); } /** diff --git a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/validation/ExecutionPayloadGossipValidator.java b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/validation/ExecutionPayloadGossipValidator.java index 311d68c045d..a43b3a50906 100644 --- a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/validation/ExecutionPayloadGossipValidator.java +++ b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/validation/ExecutionPayloadGossipValidator.java @@ -23,7 +23,6 @@ import java.util.Map; import java.util.Optional; import java.util.Set; -import java.util.stream.Stream; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.tuweni.bytes.Bytes; @@ -40,9 +39,9 @@ import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.SignedExecutionPayloadBid; import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.SignedExecutionPayloadEnvelope; import tech.pegasys.teku.spec.datastructures.execution.ExecutionPayload; -import tech.pegasys.teku.spec.datastructures.execution.versions.capella.ExecutionPayloadCapella; import tech.pegasys.teku.spec.datastructures.execution.versions.gloas.ExecutionRequestsGloas; import tech.pegasys.teku.spec.datastructures.state.beaconstate.BeaconState; +import tech.pegasys.teku.spec.datastructures.util.GloasNetworkLimits; import tech.pegasys.teku.spec.datastructures.validator.BroadcastValidationLevel; import tech.pegasys.teku.spec.logic.common.statetransition.results.BlockImportResult; import tech.pegasys.teku.spec.signatures.SigningRootUtil; @@ -296,62 +295,29 @@ private Optional performPreBlockValidation( } /** - * [REJECT] Each execution request count, and the withdrawal count, is within its configured limit - * -- i.e. spec {@code verify_execution_requests_limits} plus the MAX_WITHDRAWALS_PER_PAYLOAD - * check. These bound the work an envelope can impose before it is propagated, so they are checked - * ahead of the expensive state lookup and signature verification. + * Spec {@code verify_execution_requests_limits} plus the MAX_WITHDRAWALS_PER_PAYLOAD check. + * Network-decoded envelopes are already checked by the schema's network validator; this also + * covers locally built envelopes validated before broadcast. */ private Optional verifyRequestAndWithdrawalLimits( final ExecutionPayloadEnvelope envelope) { final SpecConfigGloas config = SpecConfigGloas.required(spec.atSlot(envelope.getSlot()).getConfig()); - final ExecutionRequestsGloas executionRequests = - ExecutionRequestsGloas.required(envelope.getExecutionRequests()); - - final Optional requestLimitResult = - Stream.of( - rejectIfOverLimit( - "withdrawal requests", - executionRequests.getWithdrawals().size(), - config.getMaxWithdrawalRequestsPerPayload()), - rejectIfOverLimit( - "consolidation requests", - executionRequests.getConsolidations().size(), - config.getMaxConsolidationRequestsPerPayload()), - rejectIfOverLimit( - "builder deposit requests", - executionRequests.getBuilderDeposits().size(), - config.getMaxBuilderDepositRequestsPerPayload()), - rejectIfOverLimit( - "builder exit requests", - executionRequests.getBuilderExits().size(), - config.getMaxBuilderExitRequestsPerPayload())) - .flatMap(Optional::stream) - .findFirst(); - if (requestLimitResult.isPresent()) { - return requestLimitResult; - } - - return rejectIfOverLimit( - "withdrawals", - ExecutionPayloadCapella.required(envelope.getPayload()).getWithdrawals().size(), - config.getMaxWithdrawalsPerPayload()); - } - - private Optional rejectIfOverLimit( - final String description, final int count, final int limit) { - if (count <= limit) { - return Optional.empty(); - } - LOG.trace( - "Execution payload envelope has {} {} which exceeds the limit of {}. Rejecting the execution payload envelope", - count, - description, - limit); - return Optional.of( - reject( - "Execution payload envelope has %s %s which exceeds the limit of %s", - count, description, limit)); + return GloasNetworkLimits.verifyExecutionRequestsLimits( + GloasNetworkLimits.EXECUTION_PAYLOAD_ENVELOPE_SUBJECT, + ExecutionRequestsGloas.required(envelope.getExecutionRequests()), + config) + .or( + () -> + GloasNetworkLimits.verifyWithdrawalsLimit( + GloasNetworkLimits.EXECUTION_PAYLOAD_ENVELOPE_SUBJECT, + envelope.getPayload(), + config)) + .map( + violation -> { + LOG.trace("{}. Rejecting the execution payload envelope", violation.describe()); + return reject("%s", violation.describe()); + }); } /** diff --git a/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/validation/ExecutionPayloadGossipValidatorTest.java b/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/validation/ExecutionPayloadGossipValidatorTest.java index 161e3296acf..959581a2dd0 100644 --- a/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/validation/ExecutionPayloadGossipValidatorTest.java +++ b/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/validation/ExecutionPayloadGossipValidatorTest.java @@ -26,6 +26,7 @@ import java.util.HashMap; import java.util.Map; import java.util.Optional; +import java.util.stream.IntStream; import org.apache.tuweni.bytes.Bytes32; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.TestTemplate; @@ -36,14 +37,18 @@ import tech.pegasys.teku.spec.SpecVersion; import tech.pegasys.teku.spec.TestSpecContext; import tech.pegasys.teku.spec.TestSpecInvocationContextProvider.SpecContext; +import tech.pegasys.teku.spec.config.SpecConfigGloas; import tech.pegasys.teku.spec.datastructures.blocks.BeaconBlock; import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.ExecutionPayloadEnvelope; import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.SignedExecutionPayloadBid; import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.SignedExecutionPayloadEnvelope; +import tech.pegasys.teku.spec.datastructures.execution.ExecutionPayload; +import tech.pegasys.teku.spec.datastructures.execution.ExecutionRequests; import tech.pegasys.teku.spec.datastructures.state.beaconstate.BeaconState; import tech.pegasys.teku.spec.datastructures.validator.BroadcastValidationLevel; import tech.pegasys.teku.spec.logic.common.helpers.MiscHelpers; import tech.pegasys.teku.spec.logic.common.statetransition.results.BlockImportResult; +import tech.pegasys.teku.spec.schemas.SchemaDefinitionsGloas; import tech.pegasys.teku.spec.util.DataStructureUtil; @TestSpecContext(milestone = {SpecMilestone.GLOAS}) @@ -54,6 +59,7 @@ public class ExecutionPayloadGossipValidatorTest { private final Map invalidBlockRoots = new HashMap<>(); private ExecutionPayloadGossipValidator validator; private DataStructureUtil dataStructureUtil; + private Spec specContextSpec; private SignedExecutionPayloadEnvelope signedEnvelope; private ExecutionPayloadEnvelope envelope; @@ -65,6 +71,7 @@ public class ExecutionPayloadGossipValidatorTest { @BeforeEach void setUp(final SpecContext specContext) { dataStructureUtil = specContext.getDataStructureUtil(); + specContextSpec = specContext.getSpec(); validator = new ExecutionPayloadGossipValidator( spec, gossipValidationHelper, blockGossipValidator, invalidBlockRoots); @@ -230,6 +237,65 @@ void shouldRejectIfExecutionRequestsMismatch() { mismatchedBid.getMessage().getExecutionRequestsRoot())); } + @TestTemplate + void shouldRejectTooManyWithdrawals() { + final int limit = + SpecConfigGloas.required(spec.atSlot(slot).getConfig()).getMaxWithdrawalsPerPayload(); + final ExecutionPayload payload = + dataStructureUtil.randomExecutionPayload( + slot, + builder -> + builder.withdrawals( + () -> + IntStream.range(0, limit + 1) + .mapToObj(__ -> dataStructureUtil.randomWithdrawal()) + .toList())); + + assertThatSafeFuture(validator.validate(envelopeWith(payload, envelope.getExecutionRequests()))) + .isCompletedWithValue( + reject( + "Execution payload envelope has %d withdrawals, max allowed %d", limit + 1, limit)); + } + + @TestTemplate + void shouldRejectTooManyWithdrawalRequests() { + final int limit = + SpecConfigGloas.required(spec.atSlot(slot).getConfig()) + .getMaxWithdrawalRequestsPerPayload(); + final ExecutionRequests requests = + dataStructureUtil + .randomExecutionRequestsBuilder(slot) + .withdrawals( + IntStream.range(0, limit + 1) + .mapToObj(__ -> dataStructureUtil.randomWithdrawalRequest()) + .toList()) + .build(); + + assertThatSafeFuture(validator.validate(envelopeWith(envelope.getPayload(), requests))) + .isCompletedWithValue( + reject( + "Execution payload envelope has %d withdrawal requests, max allowed %d", + limit + 1, limit)); + } + + private SignedExecutionPayloadEnvelope envelopeWith( + final ExecutionPayload payload, final ExecutionRequests requests) { + final SchemaDefinitionsGloas schemaDefinitions = + SchemaDefinitionsGloas.required(specContextSpec.atSlot(slot).getSchemaDefinitions()); + return schemaDefinitions + .getSignedExecutionPayloadEnvelopeSchema() + .create( + schemaDefinitions + .getExecutionPayloadEnvelopeSchema() + .create( + payload, + requests, + envelope.getBuilderIndex(), + envelope.getBeaconBlockRoot(), + envelope.getParentBeaconBlockRoot()), + signedEnvelope.getSignature()); + } + @TestTemplate void shouldRejectIfSignatureIsInvalid() { when(gossipValidationHelper.isSignatureValidWithRespectToBuilderIndex( diff --git a/infrastructure/ssz/src/main/java/tech/pegasys/teku/infrastructure/ssz/schema/SszNetworkValidator.java b/infrastructure/ssz/src/main/java/tech/pegasys/teku/infrastructure/ssz/schema/SszNetworkValidator.java new file mode 100644 index 00000000000..695e33ff88b --- /dev/null +++ b/infrastructure/ssz/src/main/java/tech/pegasys/teku/infrastructure/ssz/schema/SszNetworkValidator.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.infrastructure.ssz.schema; + +import tech.pegasys.teku.infrastructure.ssz.SszData; +import tech.pegasys.teku.infrastructure.ssz.sos.SszDeserializeException; + +/** + * Validation applied by the network decoders (gossip and RPC) to a value right after it has been + * SSZ deserialized. Used to enforce constraints that the SSZ type itself no longer expresses, such + * as the soft length limits of progressive lists. + * + *

Only schemas that are decoded directly from the network declare a validator. Nested schemas + * must not, since nothing consults them: the decoders ask the schema they decode with and nothing + * else. + */ +@FunctionalInterface +public interface SszNetworkValidator { + + /** + * @throws SszDeserializeException when the value violates a network-level constraint + */ + void validate(T value); +} diff --git a/infrastructure/ssz/src/main/java/tech/pegasys/teku/infrastructure/ssz/schema/SszSchema.java b/infrastructure/ssz/src/main/java/tech/pegasys/teku/infrastructure/ssz/schema/SszSchema.java index b8a21ef6471..cc2f535a5d5 100644 --- a/infrastructure/ssz/src/main/java/tech/pegasys/teku/infrastructure/ssz/schema/SszSchema.java +++ b/infrastructure/ssz/src/main/java/tech/pegasys/teku/infrastructure/ssz/schema/SszSchema.java @@ -84,6 +84,17 @@ default SszDataT sszDeserialize(final Bytes ssz) throws SszDeserializeException return sszDeserialize(SszReader.fromBytes(ssz)); } + /** + * Validation applied by the gossip and RPC decoders after {@link #sszDeserialize(Bytes)}. Empty + * means there is nothing to check beyond SSZ itself. + * + *

Only schemas decoded directly from the network declare a validator; nested schemas must not, + * since nothing consults them. + */ + default Optional> getNetworkSszValidator() { + return Optional.empty(); + } + DeserializableTypeDefinition getJsonTypeDefinition(); default void jsonSerialize(final SszDataT view, final JsonGenerator gen) throws IOException { diff --git a/infrastructure/ssz/src/test/java/tech/pegasys/teku/infrastructure/ssz/schema/SszSchemaTestBase.java b/infrastructure/ssz/src/test/java/tech/pegasys/teku/infrastructure/ssz/schema/SszSchemaTestBase.java index 61c385bcdfb..3880d26dfe2 100644 --- a/infrastructure/ssz/src/test/java/tech/pegasys/teku/infrastructure/ssz/schema/SszSchemaTestBase.java +++ b/infrastructure/ssz/src/test/java/tech/pegasys/teku/infrastructure/ssz/schema/SszSchemaTestBase.java @@ -43,6 +43,12 @@ void getDefaultTree_shouldBeEqualToDefaultStructure(final SszSchema sch SszDataAssert.assertThatSszData(defaultTreeData).isEqualByAllMeansTo(schema.getDefault()); } + @MethodSource("testSchemaArguments") + @ParameterizedTest + void getNetworkSszValidator_shouldBeEmptyByDefault(final SszSchema schema) { + assertThat(schema.getNetworkSszValidator()).isEmpty(); + } + @MethodSource("testSchemaArguments") @ParameterizedTest void sszDeserialize_tooLongSszShouldFailFastWithoutReadingWholeInput( diff --git a/networking/eth2/src/main/java/tech/pegasys/teku/networking/eth2/gossip/encoding/SszGossipCodec.java b/networking/eth2/src/main/java/tech/pegasys/teku/networking/eth2/gossip/encoding/SszGossipCodec.java index 713fd8997f6..c8e48c77b24 100644 --- a/networking/eth2/src/main/java/tech/pegasys/teku/networking/eth2/gossip/encoding/SszGossipCodec.java +++ b/networking/eth2/src/main/java/tech/pegasys/teku/networking/eth2/gossip/encoding/SszGossipCodec.java @@ -35,6 +35,7 @@ public T decode(final Bytes data, final SszSchema valueTy if (result == null) { throw new DecodingException("Unable to decode value"); } + valueType.getNetworkSszValidator().ifPresent(validator -> validator.validate(result)); return result; } catch (SSZException e) { throw new DecodingException("Failed to deserialize value", e); diff --git a/networking/eth2/src/main/java/tech/pegasys/teku/networking/eth2/rpc/core/RpcException.java b/networking/eth2/src/main/java/tech/pegasys/teku/networking/eth2/rpc/core/RpcException.java index e1c910f162f..ffcd24b66d0 100644 --- a/networking/eth2/src/main/java/tech/pegasys/teku/networking/eth2/rpc/core/RpcException.java +++ b/networking/eth2/src/main/java/tech/pegasys/teku/networking/eth2/rpc/core/RpcException.java @@ -36,23 +36,32 @@ public ServerErrorException() { } } + /** + * Raised locally when data received from a peer cannot be decoded as the protocol requires. As + * opposed to an error response sent by the peer, this indicates the peer is misbehaving. + */ + public abstract static class MalformedDataException extends RpcException { + protected MalformedDataException(final String errorMessage) { + super(INVALID_REQUEST_CODE, errorMessage); + } + } + // Malformed data - public static class DeserializationFailedException extends RpcException { + public static class DeserializationFailedException extends MalformedDataException { public DeserializationFailedException() { - super(INVALID_REQUEST_CODE, "Failed to deserialize payload"); + super("Failed to deserialize payload"); } } - public static class DecompressFailedException extends RpcException { + public static class DecompressFailedException extends MalformedDataException { public DecompressFailedException() { - super(INVALID_REQUEST_CODE, "Failed to uncompress message"); + super("Failed to uncompress message"); } } - public static class UnrecognizedContextBytesException extends RpcException { + public static class UnrecognizedContextBytesException extends MalformedDataException { public UnrecognizedContextBytesException(final String context) { super( - INVALID_REQUEST_CODE, "Failed to recognize context bytes: " + context + ". Must request blocks with compatible fork."); @@ -60,38 +69,38 @@ public UnrecognizedContextBytesException(final String context) { } // Unexpected message length - public static class ExtraDataAppendedException extends RpcException { + public static class ExtraDataAppendedException extends MalformedDataException { public ExtraDataAppendedException() { - super(INVALID_REQUEST_CODE, "Extra data appended to end of message"); + super("Extra data appended to end of message"); } public ExtraDataAppendedException(final String details) { - super(INVALID_REQUEST_CODE, "Extra data appended to end of message: " + details); + super("Extra data appended to end of message: " + details); } } - public static class MessageTruncatedException extends RpcException { + public static class MessageTruncatedException extends MalformedDataException { public MessageTruncatedException() { - super(INVALID_REQUEST_CODE, "Message was truncated"); + super("Message was truncated"); } } - public static class PayloadTruncatedException extends RpcException { + public static class PayloadTruncatedException extends MalformedDataException { public PayloadTruncatedException() { - super(INVALID_REQUEST_CODE, "Message payload smaller than expected"); + super("Message payload smaller than expected"); } } - public static class AdditionalDataReceivedException extends RpcException { + public static class AdditionalDataReceivedException extends MalformedDataException { public AdditionalDataReceivedException() { - super(INVALID_REQUEST_CODE, "Received additional response after request completed"); + super("Received additional response after request completed"); } } // Constraint violation - public static class ChunkTooLongException extends RpcException { + public static class ChunkTooLongException extends MalformedDataException { public ChunkTooLongException() { - super(INVALID_REQUEST_CODE, "Chunk exceeds maximum allowed length"); + super("Chunk exceeds maximum allowed length"); } } @@ -111,9 +120,9 @@ public ResourceUnavailableException(final String errorMessage) { // Custom errors - public static class LengthOutOfBoundsException extends RpcException { + public static class LengthOutOfBoundsException extends MalformedDataException { public LengthOutOfBoundsException() { - super(INVALID_REQUEST_CODE, "Chunk length is not within bounds for expected type"); + super("Chunk length is not within bounds for expected type"); } } diff --git a/networking/eth2/src/main/java/tech/pegasys/teku/networking/eth2/rpc/core/encodings/ssz/DefaultRpcPayloadEncoder.java b/networking/eth2/src/main/java/tech/pegasys/teku/networking/eth2/rpc/core/encodings/ssz/DefaultRpcPayloadEncoder.java index 2ff456323ea..361f943e4b4 100644 --- a/networking/eth2/src/main/java/tech/pegasys/teku/networking/eth2/rpc/core/encodings/ssz/DefaultRpcPayloadEncoder.java +++ b/networking/eth2/src/main/java/tech/pegasys/teku/networking/eth2/rpc/core/encodings/ssz/DefaultRpcPayloadEncoder.java @@ -39,7 +39,9 @@ public Bytes encode(final T message) { @Override public T decode(final Bytes message) throws RpcException { try { - return type.sszDeserialize(message); + final T decoded = type.sszDeserialize(message); + type.getNetworkSszValidator().ifPresent(validator -> validator.validate(decoded)); + return decoded; } catch (final SszDeserializeException e) { if (LOG.isTraceEnabled()) { LOG.trace("Failed to parse network message: " + message, e); diff --git a/networking/eth2/src/test/java/tech/pegasys/teku/networking/eth2/gossip/encoding/SszGossipCodecTest.java b/networking/eth2/src/test/java/tech/pegasys/teku/networking/eth2/gossip/encoding/SszGossipCodecTest.java index b8a5cf9bc92..f598ef66aec 100644 --- a/networking/eth2/src/test/java/tech/pegasys/teku/networking/eth2/gossip/encoding/SszGossipCodecTest.java +++ b/networking/eth2/src/test/java/tech/pegasys/teku/networking/eth2/gossip/encoding/SszGossipCodecTest.java @@ -14,16 +14,20 @@ package tech.pegasys.teku.networking.eth2.gossip.encoding; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import java.util.Optional; import org.apache.tuweni.bytes.Bytes; import org.junit.jupiter.api.Test; import tech.pegasys.teku.infrastructure.ssz.SszData; +import tech.pegasys.teku.infrastructure.ssz.schema.SszNetworkValidator; import tech.pegasys.teku.infrastructure.ssz.schema.SszSchema; +import tech.pegasys.teku.infrastructure.ssz.sos.SszDeserializeException; import tech.pegasys.teku.infrastructure.ssz.sos.SszLengthBounds; class SszGossipCodecTest { @@ -43,4 +47,39 @@ void decode_shouldUseNetworkSszLengthBounds() throws DecodingException { verify(schema).getNetworkSszLengthBounds(); verify(schema, never()).getSszLengthBounds(); } + + @Test + void decode_shouldRunNetworkSszValidatorOnDecodedValue() throws DecodingException { + @SuppressWarnings("unchecked") + final SszSchema schema = mock(SszSchema.class); + @SuppressWarnings("unchecked") + final SszNetworkValidator validator = mock(SszNetworkValidator.class); + final SszData expected = mock(SszData.class); + doReturn(SszLengthBounds.ofBytes(0, 100)).when(schema).getNetworkSszLengthBounds(); + doReturn(expected).when(schema).sszDeserialize(any(Bytes.class)); + doReturn(Optional.of(validator)).when(schema).getNetworkSszValidator(); + + final SszData actual = new SszGossipCodec().decode(Bytes.random(12), schema); + + assertThat(actual).isSameAs(expected); + verify(validator).validate(expected); + } + + @Test + void decode_shouldFailWhenNetworkSszValidatorRejectsValue() { + @SuppressWarnings("unchecked") + final SszSchema schema = mock(SszSchema.class); + final SszData decoded = mock(SszData.class); + doReturn(SszLengthBounds.ofBytes(0, 100)).when(schema).getNetworkSszLengthBounds(); + doReturn(decoded).when(schema).sszDeserialize(any(Bytes.class)); + final SszNetworkValidator validator = + __ -> { + throw new SszDeserializeException("too many things"); + }; + doReturn(Optional.of(validator)).when(schema).getNetworkSszValidator(); + + assertThatThrownBy(() -> new SszGossipCodec().decode(Bytes.random(12), schema)) + .isInstanceOf(DecodingException.class) + .hasRootCauseMessage("too many things"); + } } diff --git a/networking/eth2/src/test/java/tech/pegasys/teku/networking/eth2/rpc/core/encodings/DefaultRpcPayloadEncoderTest.java b/networking/eth2/src/test/java/tech/pegasys/teku/networking/eth2/rpc/core/encodings/DefaultRpcPayloadEncoderTest.java index 123acc7bc10..0eaefc8f511 100644 --- a/networking/eth2/src/test/java/tech/pegasys/teku/networking/eth2/rpc/core/encodings/DefaultRpcPayloadEncoderTest.java +++ b/networking/eth2/src/test/java/tech/pegasys/teku/networking/eth2/rpc/core/encodings/DefaultRpcPayloadEncoderTest.java @@ -15,14 +15,18 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import java.util.Optional; import org.apache.tuweni.bytes.Bytes; import org.junit.jupiter.api.Test; +import tech.pegasys.teku.infrastructure.ssz.schema.SszNetworkValidator; import tech.pegasys.teku.infrastructure.ssz.schema.SszSchema; +import tech.pegasys.teku.infrastructure.ssz.sos.SszDeserializeException; import tech.pegasys.teku.infrastructure.ssz.sos.SszLengthBounds; import tech.pegasys.teku.networking.eth2.rpc.core.RpcException.DeserializationFailedException; import tech.pegasys.teku.networking.eth2.rpc.core.encodings.ssz.DefaultRpcPayloadEncoder; @@ -65,4 +69,36 @@ public void isLengthWithinBounds_shouldUseNetworkSszLengthBounds() { verify(schema).getNetworkSszLengthBounds(); verify(schema, never()).getSszLengthBounds(); } + + @Test + public void decode_shouldRunNetworkSszValidatorOnDecodedValue() throws Exception { + @SuppressWarnings("unchecked") + final SszSchema schema = mock(SszSchema.class); + @SuppressWarnings("unchecked") + final SszNetworkValidator validator = mock(SszNetworkValidator.class); + final StatusMessage expected = StatusMessagePhase0.createPreGenesisStatus(spec); + doReturn(expected).when(schema).sszDeserialize(any(Bytes.class)); + doReturn(Optional.of(validator)).when(schema).getNetworkSszValidator(); + + final StatusMessage actual = new DefaultRpcPayloadEncoder<>(schema).decode(Bytes.random(8)); + + assertThat(actual).isSameAs(expected); + verify(validator).validate(expected); + } + + @Test + public void decode_shouldFailWhenNetworkSszValidatorRejectsValue() { + @SuppressWarnings("unchecked") + final SszSchema schema = mock(SszSchema.class); + final StatusMessage decoded = StatusMessagePhase0.createPreGenesisStatus(spec); + doReturn(decoded).when(schema).sszDeserialize(any(Bytes.class)); + final SszNetworkValidator validator = + __ -> { + throw new SszDeserializeException("too many things"); + }; + doReturn(Optional.of(validator)).when(schema).getNetworkSszValidator(); + + assertThatThrownBy(() -> new DefaultRpcPayloadEncoder<>(schema).decode(Bytes.random(8))) + .isInstanceOf(DeserializationFailedException.class); + } } diff --git a/specrefs/.ethspecify.yml b/specrefs/.ethspecify.yml index c61f427e827..46b7db23820 100644 --- a/specrefs/.ethspecify.yml +++ b/specrefs/.ethspecify.yml @@ -273,11 +273,6 @@ specrefs: - upgrade_attester_slashing_to_gloas#gloas - upgrade_indexed_attestation_to_gloas#gloas - # Not implemented yet: Gloas EIP-7688 block body / execution requests limit - # checks are not yet enforced as explicit gossip validation steps (alpha.14) - - verify_block_body_operation_limits#gloas - - verify_execution_requests_limits#gloas - # Not implemented yet: the CustodyColumnBits-returning helper is not built; # Teku only has the underlying custody group/column index computation # (MiscHelpersFulu#computeCustodyColumnIndices), not the bitvector form (alpha.14) diff --git a/specrefs/functions.yml b/specrefs/functions.yml index 68e357a5c39..c22edd3ff89 100644 --- a/specrefs/functions.yml +++ b/specrefs/functions.yml @@ -16639,7 +16639,13 @@ - name: verify_block_body_operation_limits#gloas - sources: [] + sources: + - file: ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/util/GloasNetworkLimits.java + search: public static Optional verifyBlockBodyOperationLimits( + - file: ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/util/GloasNetworkLimits.java + search: public static SszNetworkValidator signedBeaconBlockNetworkValidator( + - file: ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/validation/BlockGossipValidator.java + search: private Optional verifyBlockBodyOperationLimits( spec: | def verify_block_body_operation_limits(body: BeaconBlockBody) -> None: @@ -16916,7 +16922,17 @@ - name: verify_execution_requests_limits#gloas - sources: [] + sources: + - file: ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/util/GloasNetworkLimits.java + search: public static Optional verifyExecutionRequestsLimits( + - file: ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/util/GloasNetworkLimits.java + search: public static SszNetworkValidator signedBeaconBlockNetworkValidator( + - file: ethereum/spec/src/main/java/tech/pegasys/teku/spec/datastructures/util/GloasNetworkLimits.java + search: signedExecutionPayloadEnvelopeNetworkValidator(final SpecConfigGloas config) { + - file: ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/validation/BlockGossipValidator.java + search: private Optional verifyExecutionRequestsLimits( + - file: ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/validation/ExecutionPayloadGossipValidator.java + search: private Optional verifyRequestAndWithdrawalLimits( spec: | def verify_execution_requests_limits(execution_requests: ExecutionRequests) -> None: diff --git a/validator/client/src/test/java/tech/pegasys/teku/validator/client/duties/BlockProductionDutyTest.java b/validator/client/src/test/java/tech/pegasys/teku/validator/client/duties/BlockProductionDutyTest.java index 248b5f310b5..737e1bb9497 100644 --- a/validator/client/src/test/java/tech/pegasys/teku/validator/client/duties/BlockProductionDutyTest.java +++ b/validator/client/src/test/java/tech/pegasys/teku/validator/client/duties/BlockProductionDutyTest.java @@ -537,8 +537,8 @@ public void gloasBlockSummary() { final BeaconBlockBody block = dataStructureUtil.randomBeaconBlockBody(); assertThat(duty.getBlockSummary(block)) .containsExactly( - "Blobs: 7", - "Builder: 1125033, Bid gas limit: 4759212943510379790, Bid EL block: 5999d9..3515"); + "Blobs: 4", + "Builder: 1702157, Bid gas limit: 4817049881864128048, Bid EL block: d3b102..314a"); } public void assertDutyFails(final RuntimeException error) {