diff --git a/validator/remote/src/integration-test/java/tech/pegasys/teku/validator/remote/typedef/handlers/GetExecutionPayloadBidRequestTest.java b/validator/remote/src/integration-test/java/tech/pegasys/teku/validator/remote/typedef/handlers/GetExecutionPayloadBidRequestTest.java new file mode 100644 index 00000000000..adfe642792e --- /dev/null +++ b/validator/remote/src/integration-test/java/tech/pegasys/teku/validator/remote/typedef/handlers/GetExecutionPayloadBidRequestTest.java @@ -0,0 +1,103 @@ +/* + * 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.validator.remote.typedef.handlers; + +import static org.assertj.core.api.Assertions.assertThat; +import static tech.pegasys.teku.infrastructure.http.HttpStatusCodes.SC_NOT_FOUND; +import static tech.pegasys.teku.infrastructure.http.HttpStatusCodes.SC_OK; + +import java.util.Map; +import java.util.Optional; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.RecordedRequest; +import okio.Buffer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import tech.pegasys.teku.infrastructure.unsigned.UInt64; +import tech.pegasys.teku.spec.SpecMilestone; +import tech.pegasys.teku.spec.TestSpecContext; +import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.ExecutionPayloadBid; +import tech.pegasys.teku.spec.networks.Eth2Network; +import tech.pegasys.teku.validator.remote.apiclient.ValidatorApiMethod; +import tech.pegasys.teku.validator.remote.typedef.AbstractTypeDefRequestTestBase; + +@TestSpecContext(milestone = SpecMilestone.GLOAS, network = Eth2Network.MINIMAL) +class GetExecutionPayloadBidRequestTest extends AbstractTypeDefRequestTestBase { + + private static final UInt64 BUILDER_INDEX = UInt64.valueOf(5); + + private GetExecutionPayloadBidRequest request; + private ExecutionPayloadBid bid; + + @BeforeEach + void setupRequest() { + request = new GetExecutionPayloadBidRequest(mockWebServer.url("/"), okHttpClient, spec); + bid = dataStructureUtil.randomExecutionPayloadBid(); + } + + @TestTemplate + public void makesExpectedRequest() throws Exception { + mockWebServer.enqueue(new MockResponse().setResponseCode(SC_NOT_FOUND)); + + request.submit(bid.getSlot(), BUILDER_INDEX); + + final RecordedRequest recorded = mockWebServer.takeRequest(); + + assertThat(recorded.getMethod()).isEqualTo("GET"); + assertThat(recorded.getRequestUrl().encodedPath()) + .isEqualTo( + "/" + + ValidatorApiMethod.GET_EXECUTION_PAYLOAD_BID.getPath( + Map.of( + "slot", bid.getSlot().toString(), + "builder_index", BUILDER_INDEX.toString()))); + assertThat(recorded.getHeader("Accept")) + .isEqualTo("application/octet-stream;q=0.9, application/json;q=0.4"); + } + + @TestTemplate + public void whenNotFound_returnsEmpty() { + mockWebServer.enqueue(new MockResponse().setResponseCode(SC_NOT_FOUND)); + + assertThat(request.submit(bid.getSlot(), BUILDER_INDEX)).isEmpty(); + } + + @TestTemplate + public void dataCanBeRead() throws Exception { + mockWebServer.enqueue( + new MockResponse() + .setResponseCode(SC_OK) + .setBody(serializeSszObjectToJsonWithDataWrapper(bid))); + + final Optional result = request.submit(bid.getSlot(), BUILDER_INDEX); + + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(bid); + } + + @TestTemplate + public void sszDataCanBeRead() { + final Buffer responseBody = new Buffer().write(bid.sszSerialize().toArrayUnsafe()); + mockWebServer.enqueue( + new MockResponse() + .setResponseCode(SC_OK) + .setHeader("Content-Type", OCTET_STREAM_CONTENT_TYPE) + .setBody(responseBody)); + + final Optional result = request.submit(bid.getSlot(), BUILDER_INDEX); + + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(bid); + } +} diff --git a/validator/remote/src/main/java/tech/pegasys/teku/validator/remote/RemoteValidatorApiHandler.java b/validator/remote/src/main/java/tech/pegasys/teku/validator/remote/RemoteValidatorApiHandler.java index 35bafcabb3d..f9e12b659b7 100644 --- a/validator/remote/src/main/java/tech/pegasys/teku/validator/remote/RemoteValidatorApiHandler.java +++ b/validator/remote/src/main/java/tech/pegasys/teku/validator/remote/RemoteValidatorApiHandler.java @@ -387,7 +387,7 @@ public SafeFuture>> getSyncCommitteeS @Override public SafeFuture> createUnsignedExecutionPayloadBid( final UInt64 slot, final UInt64 builderIndex) { - return SafeFuture.failedFuture(new UnsupportedOperationException("Not yet implemented")); + return sendRequest(() -> typeDefClient.createUnsignedExecutionPayloadBid(slot, builderIndex)); } @Override diff --git a/validator/remote/src/main/java/tech/pegasys/teku/validator/remote/apiclient/ValidatorApiMethod.java b/validator/remote/src/main/java/tech/pegasys/teku/validator/remote/apiclient/ValidatorApiMethod.java index 38371380ef9..1b6d2ac7d6a 100644 --- a/validator/remote/src/main/java/tech/pegasys/teku/validator/remote/apiclient/ValidatorApiMethod.java +++ b/validator/remote/src/main/java/tech/pegasys/teku/validator/remote/apiclient/ValidatorApiMethod.java @@ -30,6 +30,7 @@ public enum ValidatorApiMethod { GET_PAYLOAD_ATTESTATION_DATA("eth/v1/validator/payload_attestation_data"), GET_EXECUTION_PAYLOAD_ENVELOPE( "eth/v1/validator/execution_payload_envelopes/:slot/:beacon_block_root"), + GET_EXECUTION_PAYLOAD_BID("eth/v1/validator/execution_payload_bids/:slot/:builder_index"), SEND_SIGNED_ATTESTATION("eth/v1/beacon/pool/attestations"), SEND_SIGNED_ATTESTATION_V2("eth/v2/beacon/pool/attestations"), SEND_PAYLOAD_ATTESTATION_MESSAGES("eth/v1/beacon/pool/payload_attestations"), diff --git a/validator/remote/src/main/java/tech/pegasys/teku/validator/remote/typedef/OkHttpValidatorTypeDefClient.java b/validator/remote/src/main/java/tech/pegasys/teku/validator/remote/typedef/OkHttpValidatorTypeDefClient.java index 5b8ee227562..43d8425b965 100644 --- a/validator/remote/src/main/java/tech/pegasys/teku/validator/remote/typedef/OkHttpValidatorTypeDefClient.java +++ b/validator/remote/src/main/java/tech/pegasys/teku/validator/remote/typedef/OkHttpValidatorTypeDefClient.java @@ -42,6 +42,7 @@ import tech.pegasys.teku.spec.datastructures.blocks.SignedBlockContainer; import tech.pegasys.teku.spec.datastructures.builder.SignedValidatorRegistration; import tech.pegasys.teku.spec.datastructures.builder.versions.gloas.BuilderConfig; +import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.ExecutionPayloadBid; import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.ExecutionPayloadEnvelope; import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.PayloadAttestationData; import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.PayloadAttestationMessage; @@ -70,6 +71,7 @@ import tech.pegasys.teku.validator.remote.typedef.handlers.CreateAttestationDataRequest; import tech.pegasys.teku.validator.remote.typedef.handlers.CreatePayloadAttestationDataRequest; import tech.pegasys.teku.validator.remote.typedef.handlers.CreateSyncCommitteeContributionRequest; +import tech.pegasys.teku.validator.remote.typedef.handlers.GetExecutionPayloadBidRequest; import tech.pegasys.teku.validator.remote.typedef.handlers.GetExecutionPayloadEnvelopeRequest; import tech.pegasys.teku.validator.remote.typedef.handlers.GetPeerCountRequest; import tech.pegasys.teku.validator.remote.typedef.handlers.GetProposerDutiesRequest; @@ -233,6 +235,12 @@ public Optional getExecutionPayloadEnvelope( .submit(slot, beaconBlockRoot); } + public Optional createUnsignedExecutionPayloadBid( + final UInt64 slot, final UInt64 builderIndex) { + return new GetExecutionPayloadBidRequest(getBaseEndpoint(), getOkHttpClient(), spec) + .submit(slot, builderIndex); + } + public Optional> getBeaconCommitteeSelectionProof( final List validatorsPartialProofs) { diff --git a/validator/remote/src/main/java/tech/pegasys/teku/validator/remote/typedef/handlers/GetExecutionPayloadBidRequest.java b/validator/remote/src/main/java/tech/pegasys/teku/validator/remote/typedef/handlers/GetExecutionPayloadBidRequest.java new file mode 100644 index 00000000000..e63975bb26e --- /dev/null +++ b/validator/remote/src/main/java/tech/pegasys/teku/validator/remote/typedef/handlers/GetExecutionPayloadBidRequest.java @@ -0,0 +1,86 @@ +/* + * 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.validator.remote.typedef.handlers; + +import static java.util.Collections.emptyMap; +import static tech.pegasys.teku.ethereum.json.types.SharedApiTypes.withDataWrapper; +import static tech.pegasys.teku.infrastructure.http.HttpStatusCodes.SC_OK; +import static tech.pegasys.teku.validator.remote.apiclient.ValidatorApiMethod.GET_EXECUTION_PAYLOAD_BID; + +import com.google.common.net.MediaType; +import java.io.IOException; +import java.util.Map; +import java.util.Optional; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.apache.tuweni.bytes.Bytes; +import tech.pegasys.teku.infrastructure.unsigned.UInt64; +import tech.pegasys.teku.spec.Spec; +import tech.pegasys.teku.spec.SpecMilestone; +import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.ExecutionPayloadBid; +import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.ExecutionPayloadBidSchema; +import tech.pegasys.teku.spec.schemas.SchemaDefinitionsGloas; +import tech.pegasys.teku.validator.remote.typedef.ResponseHandler; + +public class GetExecutionPayloadBidRequest extends AbstractTypeDefRequest { + private final Spec spec; + + public GetExecutionPayloadBidRequest( + final HttpUrl baseEndpoint, final OkHttpClient okHttpClient, final Spec spec) { + super(baseEndpoint, okHttpClient); + this.spec = spec; + } + + public Optional submit(final UInt64 slot, final UInt64 builderIndex) { + if (spec.atSlot(slot).getMilestone().isLessThan(SpecMilestone.GLOAS)) { + return Optional.empty(); + } + final ExecutionPayloadBidSchema bidSchema = + SchemaDefinitionsGloas.required(spec.atSlot(slot).getSchemaDefinitions()) + .getExecutionPayloadBidSchema(); + final ResponseHandler jsonResponseHandler = + new ResponseHandler<>(withDataWrapper(bidSchema)); + final ResponseHandler responseHandler = + new ResponseHandler<>(withDataWrapper(bidSchema)) + .withHandler( + SC_OK, + (request, response) -> + handleResponse(request, response, bidSchema, jsonResponseHandler)); + final Map urlParams = + Map.of("slot", slot.toString(), "builder_index", builderIndex.toString()); + final Map headers = + Map.of("Accept", "application/octet-stream;q=0.9, application/json;q=0.4"); + return get( + GET_EXECUTION_PAYLOAD_BID, urlParams, emptyMap(), emptyMap(), headers, responseHandler); + } + + private Optional handleResponse( + final Request request, + final Response response, + final ExecutionPayloadBidSchema bidSchema, + final ResponseHandler jsonResponseHandler) + throws IOException { + final String responseContentType = response.header("Content-Type"); + if (responseContentType != null + && MediaType.parse(responseContentType).is(MediaType.OCTET_STREAM)) { + if (response.body() == null) { + return Optional.empty(); + } + return Optional.of(bidSchema.sszDeserialize(Bytes.of(response.body().bytes()))); + } + return jsonResponseHandler.handleResponse(request, response); + } +} diff --git a/validator/remote/src/test/java/tech/pegasys/teku/validator/remote/RemoteValidatorApiHandlerTest.java b/validator/remote/src/test/java/tech/pegasys/teku/validator/remote/RemoteValidatorApiHandlerTest.java index 5dd48c68a77..7abcee78440 100644 --- a/validator/remote/src/test/java/tech/pegasys/teku/validator/remote/RemoteValidatorApiHandlerTest.java +++ b/validator/remote/src/test/java/tech/pegasys/teku/validator/remote/RemoteValidatorApiHandlerTest.java @@ -73,6 +73,7 @@ import tech.pegasys.teku.spec.datastructures.blocks.SignedBeaconBlock; import tech.pegasys.teku.spec.datastructures.builder.SignedValidatorRegistration; import tech.pegasys.teku.spec.datastructures.builder.versions.gloas.BuilderConfig; +import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.ExecutionPayloadBid; import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.ExecutionPayloadEnvelope; import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.PayloadAttestationData; import tech.pegasys.teku.spec.datastructures.epbs.versions.gloas.PayloadAttestationMessage; @@ -536,6 +537,28 @@ public void createPayloadAttestationData_WhenFound_ReturnsPayloadAttestationData assertThatSszData(unwrapToValue(future)).isEqualByAllMeansTo(payloadAttestationData); } + @Test + public void createUnsignedExecutionPayloadBid_WhenNone_ReturnsEmpty() { + when(typeDefClient.createUnsignedExecutionPayloadBid(ONE, ONE)).thenReturn(Optional.empty()); + + final SafeFuture> future = + apiHandler.createUnsignedExecutionPayloadBid(ONE, ONE); + + assertThat(unwrapToOptional(future)).isEmpty(); + } + + @Test + public void createUnsignedExecutionPayloadBid_WhenFound_ReturnsBid() { + final ExecutionPayloadBid bid = + new DataStructureUtil(TestSpecFactory.createMinimalGloas()).randomExecutionPayloadBid(); + when(typeDefClient.createUnsignedExecutionPayloadBid(ONE, ONE)).thenReturn(Optional.of(bid)); + + final SafeFuture> future = + apiHandler.createUnsignedExecutionPayloadBid(ONE, ONE); + + assertThatSszData(unwrapToValue(future)).isEqualByAllMeansTo(bid); + } + @Test public void createUnsignedExecutionPayload_WhenNone_ReturnsEmpty() { final Bytes32 beaconBlockRoot = dataStructureUtil.randomBytes32();