From 65c2bdfafbfb0e81280c0e5fdfb2d92d7d38f4aa Mon Sep 17 00:00:00 2001 From: Paul Harris Date: Sat, 30 May 2026 11:30:42 +1000 Subject: [PATCH 1/2] Add Gloas state builders Beacon API endpoint --- ..._v1_beacon_states_{state_id}_builders.json | 100 +++++++++++ .../beacon/schema/BuilderResponse.json | 19 +++ .../schema/GetStateBuildersResponse.json | 19 +++ .../schema/PostStateBuildersRequestBody.json | 13 ++ .../addon/GloasRestApiBuilderAddon.java | 4 +- .../handlers/v1/beacon/PostStateBuilders.java | 125 ++++++++++++++ .../v1/beacon/PostStateBuildersTest.java | 156 ++++++++++++++++++ .../pegasys/teku/api/ChainDataProvider.java | 110 ++++++++++++ .../teku/api/ChainDataProviderTest.java | 125 ++++++++++++++ .../teku/api/migrated/StateBuilderData.java | 75 +++++++++ .../api/migrated/StateBuilderDataSchema.java | 40 +++++ .../beacon/StateBuilderRequestBodyType.java | 55 ++++++ 12 files changed, 840 insertions(+), 1 deletion(-) create mode 100644 data/beaconrestapi/src/integration-test/resources/tech/pegasys/teku/beaconrestapi/beacon/paths/_eth_v1_beacon_states_{state_id}_builders.json create mode 100644 data/beaconrestapi/src/integration-test/resources/tech/pegasys/teku/beaconrestapi/beacon/schema/BuilderResponse.json create mode 100644 data/beaconrestapi/src/integration-test/resources/tech/pegasys/teku/beaconrestapi/beacon/schema/GetStateBuildersResponse.json create mode 100644 data/beaconrestapi/src/integration-test/resources/tech/pegasys/teku/beaconrestapi/beacon/schema/PostStateBuildersRequestBody.json create mode 100644 data/beaconrestapi/src/main/java/tech/pegasys/teku/beaconrestapi/handlers/v1/beacon/PostStateBuilders.java create mode 100644 data/beaconrestapi/src/test/java/tech/pegasys/teku/beaconrestapi/handlers/v1/beacon/PostStateBuildersTest.java create mode 100644 data/serializer/src/main/java/tech/pegasys/teku/api/migrated/StateBuilderData.java create mode 100644 data/serializer/src/main/java/tech/pegasys/teku/api/migrated/StateBuilderDataSchema.java create mode 100644 ethereum/json-types/src/main/java/tech/pegasys/teku/ethereum/json/types/beacon/StateBuilderRequestBodyType.java diff --git a/data/beaconrestapi/src/integration-test/resources/tech/pegasys/teku/beaconrestapi/beacon/paths/_eth_v1_beacon_states_{state_id}_builders.json b/data/beaconrestapi/src/integration-test/resources/tech/pegasys/teku/beaconrestapi/beacon/paths/_eth_v1_beacon_states_{state_id}_builders.json new file mode 100644 index 00000000000..b7d0a25ae6c --- /dev/null +++ b/data/beaconrestapi/src/integration-test/resources/tech/pegasys/teku/beaconrestapi/beacon/paths/_eth_v1_beacon_states_{state_id}_builders.json @@ -0,0 +1,100 @@ +{ + "post" : { + "tags" : [ "Beacon" ], + "operationId" : "getStateBuilders", + "summary" : "Get builders from state", + "description" : "Returns filterable list of builders with their status and index.", + "parameters" : [ { + "name" : "state_id", + "required" : true, + "in" : "path", + "schema" : { + "type" : "string", + "description" : "State identifier. Can be one of: \"head\" (canonical head in node's view), \"genesis\", \"finalized\", \"justified\", <slot>, <hex encoded stateRoot with 0x prefix>.", + "example" : "head" + } + } ], + "requestBody" : { + "required" : false, + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/PostStateBuildersRequestBody" + } + } + } + }, + "responses" : { + "200" : { + "description" : "Request successful", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/GetStateBuildersResponse" + } + }, + "application/octet-stream" : { + "schema" : { + "type" : "string", + "format" : "binary" + } + } + } + }, + "404" : { + "description" : "Not found", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/HttpErrorResponse" + } + } + } + }, + "406" : { + "description" : "Not acceptable", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/HttpErrorResponse" + } + } + } + }, + "503" : { + "description" : "Service unavailable", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/HttpErrorResponse" + } + } + } + }, + "204" : { + "description" : "Data is unavailable because the chain has not yet reached genesis", + "content" : { } + }, + "400" : { + "description" : "The request could not be processed, check the response for more information.", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/HttpErrorResponse" + } + } + } + }, + "500" : { + "description" : "Internal server error", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/HttpErrorResponse" + } + } + } + } + } + } +} diff --git a/data/beaconrestapi/src/integration-test/resources/tech/pegasys/teku/beaconrestapi/beacon/schema/BuilderResponse.json b/data/beaconrestapi/src/integration-test/resources/tech/pegasys/teku/beaconrestapi/beacon/schema/BuilderResponse.json new file mode 100644 index 00000000000..fe6f87adde8 --- /dev/null +++ b/data/beaconrestapi/src/integration-test/resources/tech/pegasys/teku/beaconrestapi/beacon/schema/BuilderResponse.json @@ -0,0 +1,19 @@ +{ + "title" : "BuilderResponse", + "type" : "object", + "required" : [ "index", "status", "builder" ], + "properties" : { + "index" : { + "type" : "string", + "description" : "unsigned 64 bit integer", + "example" : "1", + "format" : "uint64" + }, + "status" : { + "type" : "number" + }, + "builder" : { + "$ref" : "#/components/schemas/Builder" + } + } +} diff --git a/data/beaconrestapi/src/integration-test/resources/tech/pegasys/teku/beaconrestapi/beacon/schema/GetStateBuildersResponse.json b/data/beaconrestapi/src/integration-test/resources/tech/pegasys/teku/beaconrestapi/beacon/schema/GetStateBuildersResponse.json new file mode 100644 index 00000000000..e7be5c29000 --- /dev/null +++ b/data/beaconrestapi/src/integration-test/resources/tech/pegasys/teku/beaconrestapi/beacon/schema/GetStateBuildersResponse.json @@ -0,0 +1,19 @@ +{ + "title" : "GetStateBuildersResponse", + "type" : "object", + "required" : [ "execution_optimistic", "finalized", "data" ], + "properties" : { + "execution_optimistic" : { + "type" : "boolean" + }, + "finalized" : { + "type" : "boolean" + }, + "data" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/BuilderResponse" + } + } + } +} diff --git a/data/beaconrestapi/src/integration-test/resources/tech/pegasys/teku/beaconrestapi/beacon/schema/PostStateBuildersRequestBody.json b/data/beaconrestapi/src/integration-test/resources/tech/pegasys/teku/beaconrestapi/beacon/schema/PostStateBuildersRequestBody.json new file mode 100644 index 00000000000..4004447be87 --- /dev/null +++ b/data/beaconrestapi/src/integration-test/resources/tech/pegasys/teku/beaconrestapi/beacon/schema/PostStateBuildersRequestBody.json @@ -0,0 +1,13 @@ +{ + "title" : "PostStateBuildersRequestBody", + "type" : "object", + "required" : [ ], + "properties" : { + "ids" : { + "type" : "array", + "items" : { + "type" : "string" + } + } + } +} diff --git a/data/beaconrestapi/src/main/java/tech/pegasys/teku/beaconrestapi/addon/GloasRestApiBuilderAddon.java b/data/beaconrestapi/src/main/java/tech/pegasys/teku/beaconrestapi/addon/GloasRestApiBuilderAddon.java index 04f2088c7c5..e87de012124 100644 --- a/data/beaconrestapi/src/main/java/tech/pegasys/teku/beaconrestapi/addon/GloasRestApiBuilderAddon.java +++ b/data/beaconrestapi/src/main/java/tech/pegasys/teku/beaconrestapi/addon/GloasRestApiBuilderAddon.java @@ -19,6 +19,7 @@ import tech.pegasys.teku.beaconrestapi.handlers.v1.beacon.GetPayloadAttestations; import tech.pegasys.teku.beaconrestapi.handlers.v1.beacon.PostPayloadAttestations; import tech.pegasys.teku.beaconrestapi.handlers.v1.beacon.PostPublishExecutionPayloadBid; +import tech.pegasys.teku.beaconrestapi.handlers.v1.beacon.PostStateBuilders; import tech.pegasys.teku.beaconrestapi.handlers.v1.validator.GetExecutionPayloadBid; import tech.pegasys.teku.infrastructure.restapi.RestApiBuilder; import tech.pegasys.teku.spec.Spec; @@ -50,6 +51,7 @@ public RestApiBuilder apply(final RestApiBuilder builder) { .endpoint(new GetPayloadAttestations(dataProvider, schemaCache)) .endpoint(new PostPayloadAttestations(dataProvider, spec, schemaCache)) .endpoint(new GetExecutionPayloadBid(dataProvider, schemaCache)) - .endpoint(new PostPublishExecutionPayloadBid(dataProvider, schemaCache)); + .endpoint(new PostPublishExecutionPayloadBid(dataProvider, schemaCache)) + .endpoint(new PostStateBuilders(dataProvider)); } } diff --git a/data/beaconrestapi/src/main/java/tech/pegasys/teku/beaconrestapi/handlers/v1/beacon/PostStateBuilders.java b/data/beaconrestapi/src/main/java/tech/pegasys/teku/beaconrestapi/handlers/v1/beacon/PostStateBuilders.java new file mode 100644 index 00000000000..46f0e72e700 --- /dev/null +++ b/data/beaconrestapi/src/main/java/tech/pegasys/teku/beaconrestapi/handlers/v1/beacon/PostStateBuilders.java @@ -0,0 +1,125 @@ +/* + * Copyright Consensys Software Inc., 2026 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package tech.pegasys.teku.beaconrestapi.handlers.v1.beacon; + +import static tech.pegasys.teku.beaconrestapi.BeaconRestApiTypes.PARAMETER_STATE_ID; +import static tech.pegasys.teku.ethereum.json.types.beacon.StateBuilderRequestBodyType.STATE_BUILDER_REQUEST_TYPE; +import static tech.pegasys.teku.infrastructure.http.HttpStatusCodes.SC_BAD_REQUEST; +import static tech.pegasys.teku.infrastructure.http.HttpStatusCodes.SC_OK; +import static tech.pegasys.teku.infrastructure.http.RestApiConstants.EXECUTION_OPTIMISTIC; +import static tech.pegasys.teku.infrastructure.http.RestApiConstants.FINALIZED; +import static tech.pegasys.teku.infrastructure.http.RestApiConstants.TAG_BEACON; +import static tech.pegasys.teku.infrastructure.json.types.CoreTypes.BOOLEAN_TYPE; +import static tech.pegasys.teku.infrastructure.json.types.CoreTypes.RAW_INTEGER_TYPE; +import static tech.pegasys.teku.infrastructure.json.types.CoreTypes.UINT64_TYPE; + +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.google.common.base.Throwables; +import java.util.List; +import java.util.Optional; +import tech.pegasys.teku.api.ChainDataProvider; +import tech.pegasys.teku.api.DataProvider; +import tech.pegasys.teku.api.migrated.StateBuilderData; +import tech.pegasys.teku.ethereum.json.types.EthereumTypes; +import tech.pegasys.teku.ethereum.json.types.beacon.StateBuilderRequestBodyType; +import tech.pegasys.teku.infrastructure.async.SafeFuture; +import tech.pegasys.teku.infrastructure.json.types.SerializableTypeDefinition; +import tech.pegasys.teku.infrastructure.restapi.endpoints.AsyncApiResponse; +import tech.pegasys.teku.infrastructure.restapi.endpoints.EndpointMetadata; +import tech.pegasys.teku.infrastructure.restapi.endpoints.RestApiEndpoint; +import tech.pegasys.teku.infrastructure.restapi.endpoints.RestApiRequest; +import tech.pegasys.teku.infrastructure.ssz.SszList; +import tech.pegasys.teku.spec.datastructures.metadata.ObjectAndMetaData; +import tech.pegasys.teku.spec.datastructures.state.versions.gloas.Builder; + +public class PostStateBuilders extends RestApiEndpoint { + public static final String ROUTE = "/eth/v1/beacon/states/{state_id}/builders"; + private final ChainDataProvider chainDataProvider; + + static final SerializableTypeDefinition STATE_BUILDER_DATA_TYPE = + SerializableTypeDefinition.object() + .name("BuilderResponse") + .withField("index", UINT64_TYPE, StateBuilderData::getIndex) + .withField("status", RAW_INTEGER_TYPE, StateBuilderData::getStatus) + .withField( + "builder", Builder.SSZ_SCHEMA.getJsonTypeDefinition(), StateBuilderData::getBuilder) + .build(); + + static final SerializableTypeDefinition>> + RESPONSE_TYPE = + SerializableTypeDefinition.>>object() + .name("GetStateBuildersResponse") + .withField( + EXECUTION_OPTIMISTIC, BOOLEAN_TYPE, ObjectAndMetaData::isExecutionOptimistic) + .withField(FINALIZED, BOOLEAN_TYPE, ObjectAndMetaData::isFinalized) + .withField( + "data", + SerializableTypeDefinition.listOf(STATE_BUILDER_DATA_TYPE), + data -> data.getData().asList()) + .build(); + + public PostStateBuilders(final DataProvider dataProvider) { + this(dataProvider.getChainDataProvider()); + } + + PostStateBuilders(final ChainDataProvider chainDataProvider) { + super( + EndpointMetadata.post(ROUTE) + .operationId("getStateBuilders") + .summary("Get builders from state") + .description("Returns filterable list of builders with their status and index.") + .pathParam(PARAMETER_STATE_ID) + .optionalRequestBody() + .requestBodyType(STATE_BUILDER_REQUEST_TYPE) + .tags(TAG_BEACON) + .response(SC_OK, "Request successful", RESPONSE_TYPE, EthereumTypes.sszResponseType()) + .withNotFoundResponse() + .withNotAcceptedResponse() + .withChainDataResponses() + .build()); + this.chainDataProvider = chainDataProvider; + } + + @Override + public void handleRequest(final RestApiRequest request) throws JsonProcessingException { + final Optional requestBody; + + try { + requestBody = request.getOptionalRequestBody(); + } catch (RuntimeException e) { + final Throwable throwable = Throwables.getRootCause(e); + if (throwable instanceof JsonParseException) { + request.respondError(SC_BAD_REQUEST, throwable.getMessage()); + } else { + throw e; + } + return; + } + + final List builderIds = + requestBody.map(StateBuilderRequestBodyType::getIds).orElse(List.of()); + + final SafeFuture>>> future = + chainDataProvider.getStateBuilders( + request.getPathParameter(PARAMETER_STATE_ID), builderIds); + + request.respondAsync( + future.thenApply( + maybeData -> + maybeData + .map(AsyncApiResponse::respondOk) + .orElseGet(AsyncApiResponse::respondNotFound))); + } +} diff --git a/data/beaconrestapi/src/test/java/tech/pegasys/teku/beaconrestapi/handlers/v1/beacon/PostStateBuildersTest.java b/data/beaconrestapi/src/test/java/tech/pegasys/teku/beaconrestapi/handlers/v1/beacon/PostStateBuildersTest.java new file mode 100644 index 00000000000..164a61589fb --- /dev/null +++ b/data/beaconrestapi/src/test/java/tech/pegasys/teku/beaconrestapi/handlers/v1/beacon/PostStateBuildersTest.java @@ -0,0 +1,156 @@ +/* + * Copyright Consensys Software Inc., 2026 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +package tech.pegasys.teku.beaconrestapi.handlers.v1.beacon; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; +import static tech.pegasys.teku.infrastructure.async.SafeFuture.completedFuture; +import static tech.pegasys.teku.infrastructure.http.HttpStatusCodes.SC_BAD_REQUEST; +import static tech.pegasys.teku.infrastructure.http.HttpStatusCodes.SC_INTERNAL_SERVER_ERROR; +import static tech.pegasys.teku.infrastructure.http.HttpStatusCodes.SC_NOT_ACCEPTABLE; +import static tech.pegasys.teku.infrastructure.http.HttpStatusCodes.SC_NOT_FOUND; +import static tech.pegasys.teku.infrastructure.http.HttpStatusCodes.SC_OK; +import static tech.pegasys.teku.infrastructure.http.HttpStatusCodes.SC_SERVICE_UNAVAILABLE; +import static tech.pegasys.teku.infrastructure.restapi.MetadataTestUtil.getResponseSszFromMetadata; +import static tech.pegasys.teku.infrastructure.restapi.MetadataTestUtil.getResponseStringFromMetadata; +import static tech.pegasys.teku.infrastructure.restapi.MetadataTestUtil.verifyMetadataErrorResponse; + +import com.fasterxml.jackson.core.JsonProcessingException; +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import org.apache.tuweni.bytes.Bytes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import tech.pegasys.teku.api.migrated.StateBuilderData; +import tech.pegasys.teku.beaconrestapi.AbstractMigratedBeaconHandlerTest; +import tech.pegasys.teku.ethereum.json.types.beacon.StateBuilderRequestBodyType; +import tech.pegasys.teku.infrastructure.restapi.StubRestApiRequest; +import tech.pegasys.teku.infrastructure.ssz.SszList; +import tech.pegasys.teku.infrastructure.unsigned.UInt64; +import tech.pegasys.teku.spec.SpecMilestone; +import tech.pegasys.teku.spec.TestSpecFactory; +import tech.pegasys.teku.spec.datastructures.metadata.ObjectAndMetaData; +import tech.pegasys.teku.spec.datastructures.state.versions.gloas.Builder; + +public class PostStateBuildersTest extends AbstractMigratedBeaconHandlerTest { + + @BeforeEach + void setup() { + setSpec(TestSpecFactory.createMinimalGloas()); + setHandler(new PostStateBuilders(chainDataProvider)); + } + + @Test + void shouldGetBuildersFromStateWithIds() throws Exception { + final StateBuilderRequestBodyType requestBody = new StateBuilderRequestBodyType(List.of("0")); + final StubRestApiRequest request = + StubRestApiRequest.builder() + .metadata(handler.getMetadata()) + .pathParameter("state_id", "head") + .build(); + request.setRequestBody(requestBody); + final ObjectAndMetaData> expectedResponse = + new ObjectAndMetaData<>(getBuildersList(), SpecMilestone.GLOAS, false, true, false); + when(chainDataProvider.getStateBuilders("head", List.of("0"))) + .thenReturn(completedFuture(Optional.of(expectedResponse))); + + handler.handleRequest(request); + + assertThat(request.getResponseCode()).isEqualTo(SC_OK); + assertThat(request.getResponseBody()).isEqualTo(expectedResponse); + } + + @Test + void shouldGetBuildersFromStateWithEmptyRequestBody() throws Exception { + final StubRestApiRequest request = + StubRestApiRequest.builder() + .metadata(handler.getMetadata()) + .pathParameter("state_id", "head") + .build(); + final ObjectAndMetaData> expectedResponse = + new ObjectAndMetaData<>(getBuildersList(), SpecMilestone.GLOAS, false, true, false); + when(chainDataProvider.getStateBuilders("head", List.of())) + .thenReturn(completedFuture(Optional.of(expectedResponse))); + + handler.handleRequest(request); + + assertThat(request.getResponseCode()).isEqualTo(SC_OK); + assertThat(request.getResponseBody()).isEqualTo(expectedResponse); + } + + @Test + void metadata_shouldHandle200() throws IOException { + final StateBuilderData stateBuilderData = getBuildersList().get(0); + final ObjectAndMetaData> responseData = + new ObjectAndMetaData<>( + StateBuilderData.SSZ_LIST_SCHEMA.of(stateBuilderData), + SpecMilestone.GLOAS, + false, + true, + false); + + final String data = getResponseStringFromMetadata(handler, SC_OK, responseData); + + assertThat(data) + .contains("\"execution_optimistic\":false") + .contains("\"finalized\":false") + .contains("\"index\":\"0\"") + .contains("\"status\":1") + .contains("\"builder\":") + .contains("\"pubkey\":\"" + stateBuilderData.getBuilder().getPublicKey() + "\""); + } + + @Test + void metadata_shouldHandle200OctetStream() throws IOException { + final SszList builders = getBuildersList(); + final ObjectAndMetaData> responseData = + new ObjectAndMetaData<>(builders, SpecMilestone.GLOAS, false, true, false); + + final byte[] data = getResponseSszFromMetadata(handler, SC_OK, responseData); + + assertThat(Bytes.of(data)).isEqualTo(builders.sszSerialize()); + } + + @Test + void metadata_shouldHandle400() throws JsonProcessingException { + verifyMetadataErrorResponse(handler, SC_BAD_REQUEST); + } + + @Test + void metadata_shouldHandle404() throws JsonProcessingException { + verifyMetadataErrorResponse(handler, SC_NOT_FOUND); + } + + @Test + void metadata_shouldHandle406() throws JsonProcessingException { + verifyMetadataErrorResponse(handler, SC_NOT_ACCEPTABLE); + } + + @Test + void metadata_shouldHandle500() throws JsonProcessingException { + verifyMetadataErrorResponse(handler, SC_INTERNAL_SERVER_ERROR); + } + + @Test + void metadata_shouldHandle503() throws JsonProcessingException { + verifyMetadataErrorResponse(handler, SC_SERVICE_UNAVAILABLE); + } + + private SszList getBuildersList() { + final Builder builder = dataStructureUtil.randomBuilder(); + return StateBuilderData.SSZ_LIST_SCHEMA.of( + StateBuilderData.create(UInt64.ZERO, StateBuilderData.STATUS_ACTIVE, builder)); + } +} diff --git a/data/provider/src/main/java/tech/pegasys/teku/api/ChainDataProvider.java b/data/provider/src/main/java/tech/pegasys/teku/api/ChainDataProvider.java index bcc497af198..f2cf5939577 100644 --- a/data/provider/src/main/java/tech/pegasys/teku/api/ChainDataProvider.java +++ b/data/provider/src/main/java/tech/pegasys/teku/api/ChainDataProvider.java @@ -45,6 +45,7 @@ import tech.pegasys.teku.api.migrated.BlockHeadersResponse; import tech.pegasys.teku.api.migrated.BlockRewardData; import tech.pegasys.teku.api.migrated.GetAttestationRewardsResponse; +import tech.pegasys.teku.api.migrated.StateBuilderData; import tech.pegasys.teku.api.migrated.StateSyncCommitteesData; import tech.pegasys.teku.api.migrated.StateValidatorBalanceData; import tech.pegasys.teku.api.migrated.StateValidatorIdentity; @@ -83,9 +84,11 @@ import tech.pegasys.teku.spec.datastructures.state.CommitteeAssignment; import tech.pegasys.teku.spec.datastructures.state.SyncCommittee; 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.state.versions.electra.PendingConsolidation; import tech.pegasys.teku.spec.datastructures.state.versions.electra.PendingDeposit; import tech.pegasys.teku.spec.datastructures.state.versions.electra.PendingPartialWithdrawal; +import tech.pegasys.teku.spec.datastructures.state.versions.gloas.Builder; import tech.pegasys.teku.spec.logic.common.statetransition.epoch.status.ValidatorStatuses; import tech.pegasys.teku.spec.logic.common.statetransition.exceptions.EpochProcessingException; import tech.pegasys.teku.spec.logic.common.statetransition.exceptions.SlotProcessingException; @@ -369,6 +372,50 @@ SszList getValidatorIdentitiesFromState( .toList()); } + public SafeFuture>>> getStateBuilders( + final String stateIdParam, final List builderIds) { + return stateSelectorFactory + .createSelectorForStateId(stateIdParam) + .getState() + .thenApply(maybeStateData -> getBuilders(maybeStateData, builderIds)); + } + + Optional>> getBuilders( + final Optional maybeStateAndMetadata, final List builderIds) { + checkMinimumMilestone(maybeStateAndMetadata, SpecMilestone.GLOAS, "builders"); + + return maybeStateAndMetadata.map( + stateAndMetaData -> + new ObjectAndMetaData<>( + getBuildersFromState(stateAndMetaData.getData(), builderIds), + stateAndMetaData.getMilestone(), + stateAndMetaData.isExecutionOptimistic(), + stateAndMetaData.isCanonical(), + stateAndMetaData.isFinalized())); + } + + @VisibleForTesting + SszList getBuildersFromState( + final BeaconState state, final List builderIds) { + final BeaconStateGloas gloasState = + state + .toVersionGloas() + .orElseThrow( + () -> + new BadRequestException( + "The state was successfully retrieved, but was prior to GLOAS and does not contain builders.")); + + return StateBuilderData.SSZ_LIST_SCHEMA.createFromElements( + getBuilderSelector(gloasState, builderIds) + .mapToObj( + index -> + StateBuilderData.create( + UInt64.valueOf(index), + getBuilderStatus(gloasState, index), + gloasState.getBuilders().get(index))) + .toList()); + } + public Optional getStateRootFromBlockRoot(final Bytes32 blockRoot) { return combinedChainDataClient .getStateByBlockRoot(blockRoot) @@ -535,6 +582,20 @@ private IntPredicate getStatusPredicate( : i -> statusFilter.contains(getValidatorStatus(state, i, epoch, FAR_FUTURE_EPOCH)); } + private int getBuilderStatus(final BeaconStateGloas state, final int builderIndex) { + final Builder builder = state.getBuilders().get(builderIndex); + if (!builder.getWithdrawableEpoch().equals(FAR_FUTURE_EPOCH)) { + return StateBuilderData.STATUS_EXITED; + } + return spec.atSlot(state.getSlot()) + .miscHelpers() + .toVersionGloas() + .orElseThrow() + .isActiveBuilder(state, UInt64.valueOf(builderIndex)) + ? StateBuilderData.STATUS_ACTIVE + : StateBuilderData.STATUS_PENDING; + } + private IntStream getValidatorSelector(final BeaconState state, final List validators) { return validators.isEmpty() ? IntStream.range(0, state.getValidators().size()) @@ -544,6 +605,55 @@ private IntStream getValidatorSelector(final BeaconState state, final List a)); } + private IntStream getBuilderSelector( + final BeaconStateGloas state, final List builderIds) { + return builderIds.isEmpty() + ? IntStream.range(0, state.getBuilders().size()) + : builderIds.stream() + .flatMapToInt( + builderParameter -> + builderParameterToIndex(state, builderParameter).stream().mapToInt(a -> a)); + } + + private Optional builderParameterToIndex( + final BeaconStateGloas state, final String builderParameter) { + if (!isStoreAvailable()) { + throw new ChainDataUnavailableException(); + } + + if (builderParameter.toLowerCase(Locale.ROOT).startsWith("0x")) { + final Bytes48 keyBytes = getBytes48FromParameter(builderParameter); + try { + return findBuilderIndexByPublicKey(state, BLSPublicKey.fromBytesCompressed(keyBytes)); + } catch (IllegalArgumentException ex) { + return Optional.empty(); + } + } + try { + final UInt64 numericBuilder = UInt64.valueOf(builderParameter); + if (numericBuilder.isGreaterThan(UInt64.valueOf(Integer.MAX_VALUE))) { + throw new BadRequestException( + String.format("Builder Index is too high to use: %s", builderParameter)); + } + final int builderIndex = numericBuilder.intValue(); + if (builderIndex >= state.getBuilders().size()) { + return Optional.empty(); + } + return Optional.of(builderIndex); + } catch (NumberFormatException ex) { + throw new BadRequestException(String.format("Invalid builder: %s", builderParameter)); + } + } + + private Optional findBuilderIndexByPublicKey( + final BeaconStateGloas state, final BLSPublicKey publicKey) { + final SszList builders = state.getBuilders(); + return IntStream.range(0, builders.size()) + .filter(i -> builders.get(i).getPublicKey().equals(publicKey)) + .boxed() + .findFirst(); + } + public List getChainHeads() { return recentChainData.getChainHeads(); } diff --git a/data/provider/src/test/java/tech/pegasys/teku/api/ChainDataProviderTest.java b/data/provider/src/test/java/tech/pegasys/teku/api/ChainDataProviderTest.java index 8996e44e082..00577679407 100644 --- a/data/provider/src/test/java/tech/pegasys/teku/api/ChainDataProviderTest.java +++ b/data/provider/src/test/java/tech/pegasys/teku/api/ChainDataProviderTest.java @@ -54,6 +54,7 @@ import tech.pegasys.teku.api.fulu.ColumnCustodyAtSlot; import tech.pegasys.teku.api.migrated.BlockHeadersResponse; import tech.pegasys.teku.api.migrated.BlockRewardData; +import tech.pegasys.teku.api.migrated.StateBuilderData; import tech.pegasys.teku.api.migrated.StateSyncCommitteesData; import tech.pegasys.teku.api.migrated.StateValidatorIdentity; import tech.pegasys.teku.api.migrated.SyncCommitteeRewardData; @@ -69,6 +70,7 @@ import tech.pegasys.teku.spec.SpecMilestone; import tech.pegasys.teku.spec.SpecVersion; import tech.pegasys.teku.spec.TestSpecFactory; +import tech.pegasys.teku.spec.config.SpecConfig; import tech.pegasys.teku.spec.datastructures.blocks.BeaconBlockHeader; import tech.pegasys.teku.spec.datastructures.blocks.SignedBeaconBlock; import tech.pegasys.teku.spec.datastructures.blocks.SignedBlockAndState; @@ -87,9 +89,12 @@ import tech.pegasys.teku.spec.datastructures.state.beaconstate.versions.altair.BeaconStateAltair; import tech.pegasys.teku.spec.datastructures.state.beaconstate.versions.electra.BeaconStateElectra; import tech.pegasys.teku.spec.datastructures.state.beaconstate.versions.fulu.BeaconStateFulu; +import tech.pegasys.teku.spec.datastructures.state.beaconstate.versions.gloas.BeaconStateGloas; +import tech.pegasys.teku.spec.datastructures.state.beaconstate.versions.gloas.BeaconStateSchemaGloas; import tech.pegasys.teku.spec.datastructures.state.versions.electra.PendingConsolidation; import tech.pegasys.teku.spec.datastructures.state.versions.electra.PendingDeposit; import tech.pegasys.teku.spec.datastructures.state.versions.electra.PendingPartialWithdrawal; +import tech.pegasys.teku.spec.datastructures.state.versions.gloas.Builder; import tech.pegasys.teku.spec.generator.AttestationGenerator; import tech.pegasys.teku.spec.generator.ChainBuilder; import tech.pegasys.teku.spec.logic.common.statetransition.epoch.EpochProcessor; @@ -725,6 +730,126 @@ public void getValidatorIdentitiesFromState_shouldGetIdentities() { assertThat(validatorIdentitiesFromState.get(0)).isEqualTo(identity); } + @Test + public void getBuildersFromState_shouldReturnAllBuildersWithDerivedStatuses() { + final Spec gloasSpec = TestSpecFactory.createMinimalGloas(); + final DataStructureUtil gloasData = new DataStructureUtil(gloasSpec); + final ChainDataProvider provider = + new ChainDataProvider( + gloasSpec, + recentChainData, + combinedChainDataClient, + rewardCalculatorMock, + mockBlobSidecarReconstructionProvider, + mockBlobReconstructionProvider); + + final Builder pendingBuilder = + gloasData + .builderBuilder() + .depositEpoch(UInt64.valueOf(5)) + .withdrawableEpoch(SpecConfig.FAR_FUTURE_EPOCH) + .build(); + final Builder activeBuilder = + gloasData + .builderBuilder() + .depositEpoch(ONE) + .withdrawableEpoch(SpecConfig.FAR_FUTURE_EPOCH) + .build(); + final Builder exitedBuilder = + gloasData.builderBuilder().depositEpoch(ONE).withdrawableEpoch(UInt64.valueOf(6)).build(); + final BeaconStateGloas state = + createGloasStateWithBuilders( + gloasData, UInt64.valueOf(3), pendingBuilder, activeBuilder, exitedBuilder); + + final SszList builders = provider.getBuildersFromState(state, List.of()); + + assertThat(builders.stream().map(StateBuilderData::getIndex).toList()) + .containsExactly(ZERO, ONE, UInt64.valueOf(2)); + assertThat(builders.stream().map(StateBuilderData::getStatus).toList()) + .containsExactly( + StateBuilderData.STATUS_PENDING, + StateBuilderData.STATUS_ACTIVE, + StateBuilderData.STATUS_EXITED); + assertThat(builders.stream().map(StateBuilderData::getBuilder).toList()) + .containsExactly(pendingBuilder, activeBuilder, exitedBuilder); + } + + @Test + public void getBuildersFromState_shouldFilterByBuilderIndexAndPubkey() { + final Spec gloasSpec = TestSpecFactory.createMinimalGloas(); + final DataStructureUtil gloasData = new DataStructureUtil(gloasSpec); + final ChainDataProvider provider = + new ChainDataProvider( + gloasSpec, + recentChainData, + combinedChainDataClient, + rewardCalculatorMock, + mockBlobSidecarReconstructionProvider, + mockBlobReconstructionProvider); + final Builder builder0 = gloasData.randomBuilder(); + final Builder builder1 = gloasData.randomBuilder(); + final Builder builder2 = gloasData.randomBuilder(); + final BeaconStateGloas state = + createGloasStateWithBuilders(gloasData, UInt64.valueOf(3), builder0, builder1, builder2); + + final SszList builders = + provider.getBuildersFromState( + state, List.of("2", builder0.getPublicKey().toString(), "12345")); + + assertThat(builders.stream().map(StateBuilderData::getIndex).toList()) + .containsExactly(UInt64.valueOf(2), ZERO); + assertThat(builders.stream().map(StateBuilderData::getBuilder).toList()) + .containsExactly(builder2, builder0); + } + + @Test + public void getBuildersFromState_shouldRejectInvalidBuilderId() { + final Spec gloasSpec = TestSpecFactory.createMinimalGloas(); + final DataStructureUtil gloasData = new DataStructureUtil(gloasSpec); + final ChainDataProvider provider = + new ChainDataProvider( + gloasSpec, + recentChainData, + combinedChainDataClient, + rewardCalculatorMock, + mockBlobSidecarReconstructionProvider, + mockBlobReconstructionProvider); + final BeaconStateGloas state = + createGloasStateWithBuilders(gloasData, UInt64.valueOf(3), gloasData.randomBuilder()); + + assertThatThrownBy(() -> provider.getBuildersFromState(state, List.of("not-a-builder"))) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Invalid builder: not-a-builder"); + } + + @Test + public void getBuildersFromState_shouldRejectPreGloasState() { + final ChainDataProvider provider = + new ChainDataProvider( + spec, + recentChainData, + combinedChainDataClient, + rewardCalculatorMock, + mockBlobSidecarReconstructionProvider, + mockBlobReconstructionProvider); + + assertThatThrownBy(() -> provider.getBuildersFromState(data.randomBeaconState(), List.of())) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("prior to GLOAS"); + } + + private BeaconStateGloas createGloasStateWithBuilders( + final DataStructureUtil gloasData, final UInt64 finalizedEpoch, final Builder... builders) { + return gloasData + .stateBuilderGloas(0, 0, 0) + .builders( + BeaconStateSchemaGloas.required(gloasData.getBeaconStateSchema()) + .getBuildersSchema() + .of(builders)) + .setFinalizedCheckpointToEpoch(finalizedEpoch) + .build(); + } + @Test public void getBlockRoot_shouldReturnRootOfBlock() throws Exception { final ChainDataProvider provider = diff --git a/data/serializer/src/main/java/tech/pegasys/teku/api/migrated/StateBuilderData.java b/data/serializer/src/main/java/tech/pegasys/teku/api/migrated/StateBuilderData.java new file mode 100644 index 00000000000..a43643504ad --- /dev/null +++ b/data/serializer/src/main/java/tech/pegasys/teku/api/migrated/StateBuilderData.java @@ -0,0 +1,75 @@ +/* + * 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.api.migrated; + +import static com.google.common.base.Preconditions.checkArgument; + +import tech.pegasys.teku.infrastructure.ssz.SszList; +import tech.pegasys.teku.infrastructure.ssz.containers.Container3; +import tech.pegasys.teku.infrastructure.ssz.containers.ContainerSchema3; +import tech.pegasys.teku.infrastructure.ssz.primitive.SszByte; +import tech.pegasys.teku.infrastructure.ssz.primitive.SszUInt64; +import tech.pegasys.teku.infrastructure.ssz.schema.SszListSchema; +import tech.pegasys.teku.infrastructure.ssz.tree.TreeNode; +import tech.pegasys.teku.infrastructure.unsigned.ByteUtil; +import tech.pegasys.teku.infrastructure.unsigned.UInt64; +import tech.pegasys.teku.spec.datastructures.state.versions.gloas.Builder; + +public class StateBuilderData extends Container3 { + public static final int STATUS_PENDING = 0; + public static final int STATUS_ACTIVE = 1; + public static final int STATUS_EXITED = 2; + + public static final StateBuilderDataSchema SSZ_SCHEMA = new StateBuilderDataSchema(); + + @SuppressWarnings("unchecked") + public static final SszListSchema> SSZ_LIST_SCHEMA = + (SszListSchema>) + SszListSchema.create(StateBuilderData.SSZ_SCHEMA, Integer.MAX_VALUE); + + protected StateBuilderData( + final ContainerSchema3 schema, + final SszUInt64 index, + final SszByte status, + final Builder builder) { + super(schema, index, status, builder); + } + + protected StateBuilderData( + final ContainerSchema3 schema, + final TreeNode node) { + super(schema, node); + } + + public static StateBuilderData create( + final UInt64 index, final int status, final Builder builder) { + checkArgument( + status == STATUS_PENDING || status == STATUS_ACTIVE || status == STATUS_EXITED, + "Invalid builder status: %s", + status); + return new StateBuilderData(SSZ_SCHEMA, SszUInt64.of(index), SszByte.asUInt8(status), builder); + } + + public UInt64 getIndex() { + return getField0().get(); + } + + public int getStatus() { + return ByteUtil.toUnsignedInt(getField1().get()); + } + + public Builder getBuilder() { + return getField2(); + } +} diff --git a/data/serializer/src/main/java/tech/pegasys/teku/api/migrated/StateBuilderDataSchema.java b/data/serializer/src/main/java/tech/pegasys/teku/api/migrated/StateBuilderDataSchema.java new file mode 100644 index 00000000000..e096cb83b70 --- /dev/null +++ b/data/serializer/src/main/java/tech/pegasys/teku/api/migrated/StateBuilderDataSchema.java @@ -0,0 +1,40 @@ +/* + * 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.api.migrated; + +import static tech.pegasys.teku.infrastructure.ssz.schema.SszPrimitiveSchemas.UINT64_SCHEMA; +import static tech.pegasys.teku.infrastructure.ssz.schema.SszPrimitiveSchemas.UINT8_SCHEMA; + +import tech.pegasys.teku.infrastructure.ssz.containers.ContainerSchema3; +import tech.pegasys.teku.infrastructure.ssz.primitive.SszByte; +import tech.pegasys.teku.infrastructure.ssz.primitive.SszUInt64; +import tech.pegasys.teku.infrastructure.ssz.tree.TreeNode; +import tech.pegasys.teku.spec.datastructures.state.versions.gloas.Builder; + +public class StateBuilderDataSchema + extends ContainerSchema3 { + + public StateBuilderDataSchema() { + super( + "BuilderResponse", + namedSchema("index", UINT64_SCHEMA), + namedSchema("status", UINT8_SCHEMA), + namedSchema("builder", Builder.SSZ_SCHEMA)); + } + + @Override + public StateBuilderData createFromBackingNode(final TreeNode node) { + return new StateBuilderData(this, node); + } +} diff --git a/ethereum/json-types/src/main/java/tech/pegasys/teku/ethereum/json/types/beacon/StateBuilderRequestBodyType.java b/ethereum/json-types/src/main/java/tech/pegasys/teku/ethereum/json/types/beacon/StateBuilderRequestBodyType.java new file mode 100644 index 00000000000..621116bdd95 --- /dev/null +++ b/ethereum/json-types/src/main/java/tech/pegasys/teku/ethereum/json/types/beacon/StateBuilderRequestBodyType.java @@ -0,0 +1,55 @@ +/* + * 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.ethereum.json.types.beacon; + +import static tech.pegasys.teku.infrastructure.json.types.CoreTypes.STRING_TYPE; + +import java.util.List; +import java.util.Optional; +import tech.pegasys.teku.infrastructure.json.types.DeserializableTypeDefinition; + +public class StateBuilderRequestBodyType { + + public static final DeserializableTypeDefinition + STATE_BUILDER_REQUEST_TYPE = + DeserializableTypeDefinition.object(StateBuilderRequestBodyType.class) + .name("PostStateBuildersRequestBody") + .initializer(StateBuilderRequestBodyType::new) + .withOptionalField( + "ids", + DeserializableTypeDefinition.listOf(STRING_TYPE), + StateBuilderRequestBodyType::getMaybeIds, + StateBuilderRequestBodyType::setIds) + .build(); + + private List ids = List.of(); + + public StateBuilderRequestBodyType() {} + + public StateBuilderRequestBodyType(final List ids) { + this.ids = ids; + } + + public List getIds() { + return ids; + } + + public Optional> getMaybeIds() { + return ids.isEmpty() ? Optional.empty() : Optional.of(ids); + } + + public void setIds(final Optional> ids) { + ids.ifPresent(i -> this.ids = i); + } +} From e6077af77b8d6a9933a5d800ab71868d1deb0587 Mon Sep 17 00:00:00 2001 From: Paul Harris Date: Sat, 30 May 2026 11:32:26 +1000 Subject: [PATCH 2/2] changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ed31d2ec39..b15f79d69cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ ### Additions and Improvements + - Added the Gloas `POST /eth/v1/beacon/states/{state_id}/builders` Beacon API endpoint from ethereum/beacon-APIs#614, with JSON and SSZ response support. + ### Bug Fixes - Fixed a scenario where keys added via validator-api that rely on external signer are not slashing protected locally until the node is restarted. To work around this issue, users should either keep slashing protection enabled on the external signer or restart the node after calling the add api.